From b8f73881f1cf3037b7ac4a8f1485e37b0616e8cc Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 11:29:04 -0800 Subject: [PATCH 01/10] midway checkpoint, WIP: initial implementation of schema support for various new functions --- packages/ts-transformers/src/ast/call-kind.ts | 85 +++++- packages/ts-transformers/src/ast/mod.ts | 1 + .../ts-transformers/src/ast/type-inference.ts | 13 + .../src/transformers/schema-injection.ts | 256 ++++++++++++++++++ .../test/schema-injection-new.test.ts | 150 ++++++++++ 5 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 packages/ts-transformers/test/schema-injection-new.test.ts diff --git a/packages/ts-transformers/src/ast/call-kind.ts b/packages/ts-transformers/src/ast/call-kind.ts index af64abdd9f..31920cc93e 100644 --- a/packages/ts-transformers/src/ast/call-kind.ts +++ b/packages/ts-transformers/src/ast/call-kind.ts @@ -21,11 +21,28 @@ const OPAQUE_REF_OWNER_NAMES = new Set([ "OpaqueRef", ]); +const CELL_LIKE_CLASSES = new Set([ + "Cell", + "OpaqueCell", + "Stream", + "ComparableCell", + "ReadonlyCell", + "WriteonlyCell", + "CellTypeConstructor", +]); + +const CELL_FACTORY_NAMES = new Set(["of"]); +const CELL_FOR_NAMES = new Set(["for"]); + export type CallKind = | { kind: "ifElse"; symbol?: ts.Symbol } | { kind: "builder"; symbol?: ts.Symbol; builderName: string } | { kind: "array-map"; symbol?: ts.Symbol } - | { kind: "derive"; symbol?: ts.Symbol }; + | { kind: "derive"; symbol?: ts.Symbol } + | { kind: "cell-factory"; symbol?: ts.Symbol; factoryName: string } + | { kind: "cell-for"; symbol?: ts.Symbol } + | { kind: "wish"; symbol?: ts.Symbol } + | { kind: "generate-object"; symbol?: ts.Symbol }; export function detectCallKind( call: ts.CallExpression, @@ -50,6 +67,15 @@ function resolveExpressionKind( if (name === "ifElse") { return { kind: "ifElse" }; } + if (name === "cell") { + return { kind: "cell-factory", factoryName: "cell" }; + } + if (name === "wish") { + return { kind: "wish" }; + } + if (name === "generateObject") { + return { kind: "generate-object" }; + } if (BUILDER_SYMBOL_NAMES.has(name)) { return { kind: "builder", builderName: name }; } @@ -89,6 +115,12 @@ function resolveExpressionKind( if (name === "ifElse") { return { kind: "ifElse" }; } + if (name === "wish") { + return { kind: "wish" }; + } + if (name === "generateObject") { + return { kind: "generate-object" }; + } if (BUILDER_SYMBOL_NAMES.has(name)) { return { kind: "builder", builderName: name }; } @@ -144,6 +176,10 @@ function resolveSymbolKind( for (const declaration of declarations) { const builderKind = detectBuilderFromDeclaration(resolved, declaration); if (builderKind) return builderKind; + + const cellKind = detectCellMethodFromDeclaration(resolved, declaration); + if (cellKind) return cellKind; + if ( isArrayMapDeclaration(declaration) || isOpaqueRefMapDeclaration(declaration) @@ -172,10 +208,23 @@ function resolveSymbolKind( return { kind: "derive", symbol: resolved }; } + if (name === "cell" && isCommonToolsSymbol(resolved)) { + return { kind: "cell-factory", symbol: resolved, factoryName: "cell" }; + } + + if (name === "wish" && isCommonToolsSymbol(resolved)) { + return { kind: "wish", symbol: resolved }; + } + + if (name === "generateObject" && isCommonToolsSymbol(resolved)) { + return { kind: "generate-object", symbol: resolved }; + } + if (BUILDER_SYMBOL_NAMES.has(name) && isCommonToolsSymbol(resolved)) { return { kind: "builder", symbol: resolved, builderName: name }; } + // Fallback for when isCommonToolsSymbol check fails (e.g. in tests or incomplete environments) if (name === "ifElse") { return { kind: "ifElse", symbol: resolved }; } @@ -184,6 +233,18 @@ function resolveSymbolKind( return { kind: "derive", symbol: resolved }; } + if (name === "cell") { + return { kind: "cell-factory", symbol: resolved, factoryName: "cell" }; + } + + if (name === "wish") { + return { kind: "wish", symbol: resolved }; + } + + if (name === "generateObject") { + return { kind: "generate-object", symbol: resolved }; + } + if (BUILDER_SYMBOL_NAMES.has(name)) { return { kind: "builder", symbol: resolved, builderName: name }; } @@ -227,6 +288,28 @@ function detectBuilderFromDeclaration( }; } +function detectCellMethodFromDeclaration( + symbol: ts.Symbol, + declaration: ts.Declaration, +): CallKind | undefined { + if (!hasIdentifierName(declaration)) return undefined; + + const name = declaration.name.text; + + // Check for static methods on Cell-like classes + const owner = findOwnerName(declaration); + if (owner && CELL_LIKE_CLASSES.has(owner)) { + if (CELL_FACTORY_NAMES.has(name)) { + return { kind: "cell-factory", symbol, factoryName: name }; + } + if (CELL_FOR_NAMES.has(name)) { + return { kind: "cell-for", symbol }; + } + } + + return undefined; +} + function isArrayMapDeclaration(declaration: ts.Declaration): boolean { if (!hasIdentifierName(declaration)) return false; if (declaration.name.text !== "map") return false; diff --git a/packages/ts-transformers/src/ast/mod.ts b/packages/ts-transformers/src/ast/mod.ts index 3378378325..fd83c8f3a7 100644 --- a/packages/ts-transformers/src/ast/mod.ts +++ b/packages/ts-transformers/src/ast/mod.ts @@ -20,6 +20,7 @@ export { } from "./utils.ts"; export { getTypeReferenceArgument, + inferContextualType, inferParameterType, inferReturnType, isAnyOrUnknownType, diff --git a/packages/ts-transformers/src/ast/type-inference.ts b/packages/ts-transformers/src/ast/type-inference.ts index c3b19a8414..be4e20f2f1 100644 --- a/packages/ts-transformers/src/ast/type-inference.ts +++ b/packages/ts-transformers/src/ast/type-inference.ts @@ -453,3 +453,16 @@ export function inferArrayElementType( typeNode: factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), }; } +/** + * Infers the expected type of an expression from its context (e.g., variable assignment). + */ +export function inferContextualType( + node: ts.Expression, + checker: ts.TypeChecker, +): ts.Type | undefined { + const contextualType = checker.getContextualType(node); + if (contextualType && !isAnyOrUnknownType(contextualType)) { + return contextualType; + } + return undefined; +} diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts index ce8b7bc7db..07afb7d38d 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -2,11 +2,13 @@ import ts from "typescript"; import { detectCallKind, + inferContextualType, inferParameterType, inferReturnType, isAnyOrUnknownType, isFunctionLikeExpression, typeToSchemaTypeNode, + unwrapOpaqueLikeType, } from "../ast/mod.ts"; import { TransformationContext, @@ -974,6 +976,260 @@ export class SchemaInjectionTransformer extends Transformer { } } + if (callKind?.kind === "cell-factory") { + const factory = transformation.factory; + const typeArgs = node.typeArguments; + const args = node.arguments; + + // If already has 2 arguments, assume schema is already present + if (args.length >= 2) { + return ts.visitEachChild(node, visit, transformation); + } + + let typeNode: ts.TypeNode | undefined; + let type: ts.Type | undefined; + + if (typeArgs && typeArgs.length > 0) { + // Use explicit type argument + typeNode = typeArgs[0]; + if (typeNode && typeRegistry) { + type = typeRegistry.get(typeNode); + } + } else if (args.length > 0) { + // Infer from value argument + const valueArg = args[0]; + if (valueArg) { + const valueType = checker.getTypeAtLocation(valueArg); + if (valueType && !isAnyOrUnknownType(valueType)) { + type = valueType; + typeNode = typeToSchemaTypeNode(valueType, checker, sourceFile); + } + } + } + + if (typeNode) { + const schemaCall = createSchemaCallWithRegistryTransfer( + context, + typeNode, + typeRegistry, + ); + + // If we inferred the type (no explicit type arg), register it + if ((!typeArgs || typeArgs.length === 0) && type && typeRegistry) { + typeRegistry.set(schemaCall, type); + } + + const updated = factory.createCallExpression( + node.expression, + node.typeArguments, + [...args, schemaCall], + ); + return ts.visitEachChild(updated, visit, transformation); + } + } + + if (callKind?.kind === "cell-for") { + const factory = transformation.factory; + const typeArgs = node.typeArguments; + + // Check if already wrapped in asSchema + if ( + ts.isPropertyAccessExpression(node.parent) && + node.parent.name.text === "asSchema" + ) { + return ts.visitEachChild(node, visit, transformation); + } + + let typeNode: ts.TypeNode | undefined; + let type: ts.Type | undefined; + + if (typeArgs && typeArgs.length > 0) { + typeNode = typeArgs[0]; + if (typeNode && typeRegistry) { + type = typeRegistry.get(typeNode); + } + } else { + // Infer from contextual type (variable assignment) + const contextualType = inferContextualType(node, checker); + if (contextualType) { + // We need to unwrap Cell to get T + const unwrapped = unwrapOpaqueLikeType(contextualType, checker); + if (unwrapped) { + type = unwrapped; + typeNode = typeToSchemaTypeNode(unwrapped, checker, sourceFile); + } + } + } + + if (typeNode) { + const schemaCall = createSchemaCallWithRegistryTransfer( + context, + typeNode, + typeRegistry, + ); + if ((!typeArgs || typeArgs.length === 0) && type && typeRegistry) { + typeRegistry.set(schemaCall, type); + } + + // Visit the original node's children first to ensure nested transformations happen + const visitedNode = ts.visitEachChild(node, visit, transformation); + + const asSchema = factory.createPropertyAccessExpression( + visitedNode, + factory.createIdentifier("asSchema"), + ); + const updated = factory.createCallExpression( + asSchema, + undefined, + [schemaCall], + ); + // Return updated directly to avoid re-visiting the inner CallExpression which would trigger infinite recursion + return updated; + } + } + + if (callKind?.kind === "wish") { + const factory = transformation.factory; + const typeArgs = node.typeArguments; + const args = node.arguments; + + if (args.length >= 2) { + return ts.visitEachChild(node, visit, transformation); + } + + let typeNode: ts.TypeNode | undefined; + let type: ts.Type | undefined; + + if (typeArgs && typeArgs.length > 0) { + typeNode = typeArgs[0]; + if (typeNode && typeRegistry) { + type = typeRegistry.get(typeNode); + } + } else { + // Infer from contextual type + const contextualType = inferContextualType(node, checker); + if (contextualType) { + type = contextualType; + typeNode = typeToSchemaTypeNode( + contextualType, + checker, + sourceFile, + ); + } + } + + if (typeNode) { + const schemaCall = createSchemaCallWithRegistryTransfer( + context, + typeNode, + typeRegistry, + ); + if ((!typeArgs || typeArgs.length === 0) && type && typeRegistry) { + typeRegistry.set(schemaCall, type); + } + + const updated = factory.createCallExpression( + node.expression, + node.typeArguments, + [...args, schemaCall], + ); + return ts.visitEachChild(updated, visit, transformation); + } + } + + if (callKind?.kind === "generate-object") { + const factory = transformation.factory; + const typeArgs = node.typeArguments; + const args = node.arguments; + + // Check if schema is already present in options + if (args.length > 0 && ts.isObjectLiteralExpression(args[0]!)) { + const props = (args[0] as ts.ObjectLiteralExpression).properties; + if ( + props.some((p: ts.ObjectLiteralElementLike) => + p.name && ts.isIdentifier(p.name) && p.name.text === "schema" + ) + ) { + return ts.visitEachChild(node, visit, transformation); + } + } + + let typeNode: ts.TypeNode | undefined; + let type: ts.Type | undefined; + + if (typeArgs && typeArgs.length > 0) { + typeNode = typeArgs[0]; + if (typeNode && typeRegistry) { + type = typeRegistry.get(typeNode); + } + } else { + // Infer from contextual type + const contextualType = inferContextualType(node, checker); + if (contextualType) { + const objectProp = contextualType.getProperty("object"); + if (objectProp) { + const objectType = checker.getTypeOfSymbolAtLocation( + objectProp, + node, + ); + if (objectType) { + type = objectType; + typeNode = typeToSchemaTypeNode( + objectType, + checker, + sourceFile, + ); + } + } + } + } + + if (typeNode) { + const schemaCall = createSchemaCallWithRegistryTransfer( + context, + typeNode, + typeRegistry, + ); + if ((!typeArgs || typeArgs.length === 0) && type && typeRegistry) { + typeRegistry.set(schemaCall, type); + } + + let newOptions: ts.Expression; + if (args.length > 0 && ts.isObjectLiteralExpression(args[0]!)) { + // Add schema property to existing object literal + newOptions = factory.createObjectLiteralExpression( + [ + ...(args[0] as ts.ObjectLiteralExpression).properties, + factory.createPropertyAssignment("schema", schemaCall), + ], + true, + ); + } else if (args.length > 0) { + // Options is an expression (not literal) -> { ...opts, schema: ... } + newOptions = factory.createObjectLiteralExpression( + [ + factory.createSpreadAssignment(args[0]!), + factory.createPropertyAssignment("schema", schemaCall), + ], + true, + ); + } else { + // No options -> { schema: ... } + newOptions = factory.createObjectLiteralExpression( + [factory.createPropertyAssignment("schema", schemaCall)], + true, + ); + } + + const updated = factory.createCallExpression( + node.expression, + node.typeArguments, + [newOptions, ...args.slice(1)], + ); + return ts.visitEachChild(updated, visit, transformation); + } + } + return ts.visitEachChild(node, visit, transformation); }; diff --git a/packages/ts-transformers/test/schema-injection-new.test.ts b/packages/ts-transformers/test/schema-injection-new.test.ts new file mode 100644 index 0000000000..4586843350 --- /dev/null +++ b/packages/ts-transformers/test/schema-injection-new.test.ts @@ -0,0 +1,150 @@ +import { assert } from "@std/assert"; +import { transformSource } from "./utils.ts"; + +const COMMON_TOOLS_D_TS = ` +export declare const CELL_BRAND: unique symbol; +export interface Cell { + [CELL_BRAND]: "cell"; +} +export interface CellTypeConstructor { + of(value: U): any; + for(cause: unknown): any; +} +export declare const Cell: CellTypeConstructor; +export declare const OpaqueCell: CellTypeConstructor; +export declare const Stream: CellTypeConstructor; + +export declare function wish(query: string): T; +export declare function generateObject(opts: any): T; +`; + +const options = { + types: { "commontools.d.ts": COMMON_TOOLS_D_TS }, +}; + +Deno.test("Schema Injection - Cell.of", async () => { + const code = ` + /// + import { Cell } from "commontools"; + const c1 = Cell.of("hello"); + const c2 = Cell.of(123); + `.trim(); + + const result = await transformSource(code, options); + const normalize = (s: string) => s.replace(/\s+/g, " "); + + assert( + normalize(result).includes( + 'Cell.of("hello", { type: "string" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); + assert( + normalize(result).includes( + 'Cell.of(123, { type: "number", "enum": [123] } as const satisfies __ctHelpers.JSONSchema)', + ), + ); +}); + +Deno.test("Schema Injection - Cell.for", async () => { + const code = ` + /// + import { Cell } from "commontools"; + const c1 = Cell.for("cause"); + const c2: Cell = Cell.for("cause"); + `.trim(); + + const result = await transformSource(code, options); + const normalize = (s: string) => s.replace(/\s+/g, " "); + + assert( + normalize(result).includes( + 'Cell.for("cause").asSchema({ type: "string" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); + assert( + normalize(result).includes( + 'Cell.for("cause").asSchema({ type: "number" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); +}); + +Deno.test("Schema Injection - wish", async () => { + const code = ` + /// + import { wish } from "commontools"; + const w1 = wish("query"); + const w2: string = wish("query"); + `.trim(); + + const result = await transformSource(code, options); + const normalize = (s: string) => s.replace(/\s+/g, " "); + + assert( + normalize(result).includes( + 'wish("query", { type: "string" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); + assert( + normalize(result).includes( + 'wish("query", { type: "string" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); +}); + +Deno.test("Schema Injection - generateObject", async () => { + const code = ` + /// + import { generateObject } from "commontools"; + const g1 = generateObject({ model: "gpt-4" }); + const g2: { object: number } = generateObject({ model: "gpt-4" }); + const g3 = generateObject({ model: "gpt-4", schema: { type: "string" } }); + `.trim(); + + const result = await transformSource(code, options); + const normalize = (s: string) => s.replace(/\s+/g, " "); + + assert( + normalize(result).includes( + 'generateObject({ model: "gpt-4", schema: { type: "string" } as const satisfies __ctHelpers.JSONSchema })', + ), + ); + assert( + normalize(result).includes( + 'generateObject({ model: "gpt-4", schema: { type: "number" } as const satisfies __ctHelpers.JSONSchema })', + ), + ); + // Should not double inject + assert( + normalize(result).includes( + 'generateObject({ model: "gpt-4", schema: { type: "string" } })', + ), + ); + assert( + !normalize(result).includes( + 'schema: { type: "string" }, schema: { type: "string" }', + ), + ); +}); + +Deno.test("Schema Injection - Cell-like classes", async () => { + const code = ` + /// + import { OpaqueCell, Stream } from "commontools"; + const o1 = OpaqueCell.of(true); + const s1 = Stream.of(1); + `.trim(); + + const result = await transformSource(code, options); + const normalize = (s: string) => s.replace(/\s+/g, " "); + + assert( + normalize(result).includes( + 'OpaqueCell.of(true, { type: "boolean" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); + assert( + normalize(result).includes( + 'Stream.of(1, { type: "number" } as const satisfies __ctHelpers.JSONSchema)', + ), + ); +}); From 04490e14f339f0c760c64435735b0dd5d5b27245 Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 11:44:35 -0800 Subject: [PATCH 02/10] widen literal enums into their base types --- packages/ts-transformers/src/ast/mod.ts | 1 + .../ts-transformers/src/ast/type-inference.ts | 49 ++++++++++++++++++- .../src/transformers/schema-injection.ts | 6 ++- .../derive-object-literal-input.expected.tsx | 16 ++++-- .../derive-multiple-captures.expected.tsx | 12 +++-- .../test/schema-injection-new.test.ts | 2 +- 6 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/ts-transformers/src/ast/mod.ts b/packages/ts-transformers/src/ast/mod.ts index fd83c8f3a7..83bfdbfa8d 100644 --- a/packages/ts-transformers/src/ast/mod.ts +++ b/packages/ts-transformers/src/ast/mod.ts @@ -27,4 +27,5 @@ export { typeToSchemaTypeNode, typeToTypeNode, unwrapOpaqueLikeType, + widenLiteralType, } from "./type-inference.ts"; diff --git a/packages/ts-transformers/src/ast/type-inference.ts b/packages/ts-transformers/src/ast/type-inference.ts index be4e20f2f1..c21b299c97 100644 --- a/packages/ts-transformers/src/ast/type-inference.ts +++ b/packages/ts-transformers/src/ast/type-inference.ts @@ -21,6 +21,51 @@ export function isAnyOrUnknownType(type: ts.Type | undefined): boolean { 0; } +/** + * Widen literal types to their base types for more flexible schemas. + * - NumberLiteral (e.g., 10) → number + * - StringLiteral (e.g., "hello") → string + * - BooleanLiteral (e.g., true) → boolean + * - BigIntLiteral (e.g., 10n) → bigint + * - Other types are returned unchanged + */ +export function widenLiteralType( + type: ts.Type, + checker: ts.TypeChecker, +): ts.Type { + // Number literal → number + if (type.flags & ts.TypeFlags.NumberLiteral) { + return checker.getNumberType(); + } + + // String literal → string + if (type.flags & ts.TypeFlags.StringLiteral) { + return checker.getStringType(); + } + + // Boolean literal (true/false) → boolean + if (type.flags & ts.TypeFlags.BooleanLiteral) { + // TypeChecker doesn't have getBooleanType(), so we need to create it + // by getting the union of true | false + const trueType = checker.getTrueType?.() ?? type; + const falseType = checker.getFalseType?.() ?? type; + if (trueType && falseType) { + return (checker as ts.TypeChecker & { + getUnionType?: (types: readonly ts.Type[]) => ts.Type; + }).getUnionType?.([trueType, falseType]) ?? type; + } + return type; + } + + // BigInt literal → bigint + if (type.flags & ts.TypeFlags.BigIntLiteral) { + return checker.getBigIntType(); + } + + // All other types (including already-widened types) return unchanged + return type; +} + /** * Infer the type of a function parameter, with optional fallback * Returns undefined if the type cannot be inferred @@ -359,7 +404,7 @@ export function inferArrayElementType( // getTypeAtLocationWithFallback handles undefined typeRegistry gracefully const arrayType = getTypeAtLocationWithFallback(arrayExpr, checker, typeRegistry) ?? - checker.getTypeAtLocation(arrayExpr); + checker.getTypeAtLocation(arrayExpr); // Try to unwrap OpaqueRef → T[] → T let actualType = arrayType; @@ -434,7 +479,7 @@ export function inferArrayElementType( // Convert Type to TypeNode const typeNode = typeToTypeNode(elementType, checker, context.sourceFile) ?? - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); return { typeNode, type: elementType }; } diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts index 07afb7d38d..5766c057c2 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -9,6 +9,7 @@ import { isFunctionLikeExpression, typeToSchemaTypeNode, unwrapOpaqueLikeType, + widenLiteralType, } from "../ast/mod.ts"; import { TransformationContext, @@ -1001,8 +1002,9 @@ export class SchemaInjectionTransformer extends Transformer { if (valueArg) { const valueType = checker.getTypeAtLocation(valueArg); if (valueType && !isAnyOrUnknownType(valueType)) { - type = valueType; - typeNode = typeToSchemaTypeNode(valueType, checker, sourceFile); + // Widen literal types (e.g., 10 → number) for more flexible schemas + type = widenLiteralType(valueType, checker); + typeNode = typeToSchemaTypeNode(type, checker, sourceFile); } } } diff --git a/packages/ts-transformers/test/fixtures/ast-transform/derive-object-literal-input.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/derive-object-literal-input.expected.tsx index 51bc7454ba..c3e529d80c 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/derive-object-literal-input.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/derive-object-literal-input.expected.tsx @@ -1,9 +1,17 @@ import * as __ctHelpers from "commontools"; import { cell, derive, lift } from "commontools"; -const stage = cell("initial"); -const attemptCount = cell(0); -const acceptedCount = cell(0); -const rejectedCount = cell(0); +const stage = cell("initial", { + type: "string" +} as const satisfies __ctHelpers.JSONSchema); +const attemptCount = cell(0, { + type: "number" +} as const satisfies __ctHelpers.JSONSchema); +const acceptedCount = cell(0, { + type: "number" +} as const satisfies __ctHelpers.JSONSchema); +const rejectedCount = cell(0, { + type: "number" +} as const satisfies __ctHelpers.JSONSchema); const normalizedStage = lift({ type: "string" } as const satisfies __ctHelpers.JSONSchema, { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-multiple-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-multiple-captures.expected.tsx index a91e26a9eb..198889f316 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-multiple-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-multiple-captures.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); - const multiplier = cell(2); - const offset = cell(5); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const offset = cell(5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/schema-injection-new.test.ts b/packages/ts-transformers/test/schema-injection-new.test.ts index 4586843350..c733b34e66 100644 --- a/packages/ts-transformers/test/schema-injection-new.test.ts +++ b/packages/ts-transformers/test/schema-injection-new.test.ts @@ -40,7 +40,7 @@ Deno.test("Schema Injection - Cell.of", async () => { ); assert( normalize(result).includes( - 'Cell.of(123, { type: "number", "enum": [123] } as const satisfies __ctHelpers.JSONSchema)', + 'Cell.of(123, { type: "number" } as const satisfies __ctHelpers.JSONSchema)', ), ); }); From cc5ee1155aab436e7342eeba7c1e4f494493910d Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 12:05:03 -0800 Subject: [PATCH 03/10] test: update fixtures for schema injection with literal type widening All cell(literal) calls now use widened base types per new spec. --- .../cell-map-with-captures.expected.tsx | 8 +++++++- .../computed-basic-capture.expected.tsx | 8 ++++++-- .../computed-complex-expression.expected.tsx | 12 +++++++++--- ...computed-conditional-expression.expected.tsx | 16 ++++++++++++---- .../computed-multiple-captures.expected.tsx | 12 +++++++++--- .../computed-nested-property.expected.tsx | 10 +++++++++- .../closures/computed-nested.expected.tsx | 8 ++++++-- .../computed-optional-chaining.expected.tsx | 17 +++++++++++++++-- .../computed-pattern-param-mixed.expected.tsx | 8 ++++++-- .../computed-pattern-param.expected.tsx | 4 +++- .../computed-pattern-typed.expected.tsx | 4 +++- .../computed-recipe-param-mixed.expected.tsx | 8 ++++++-- .../closures/computed-recipe-param.expected.tsx | 4 +++- .../closures/computed-recipe-typed.expected.tsx | 4 +++- ...puted-with-closed-over-cell-map.expected.tsx | 11 +++++++++-- .../closures/derive-4arg-form.expected.tsx | 8 ++++++-- .../derive-array-element-access.expected.tsx | 4 +++- .../closures/derive-basic-capture.expected.tsx | 8 ++++++-- .../derive-collision-property.expected.tsx | 4 +++- .../derive-collision-shorthand.expected.tsx | 4 +++- .../derive-complex-expression.expected.tsx | 12 +++++++++--- .../derive-computed-property.expected.tsx | 4 +++- .../derive-conditional-expression.expected.tsx | 12 +++++++++--- .../derive-destructured-param.expected.tsx | 17 +++++++++++++++-- .../derive-empty-input-no-params.expected.tsx | 8 ++++++-- .../closures/derive-local-variable.expected.tsx | 12 +++++++++--- .../derive-method-call-capture.expected.tsx | 4 +++- .../closures/derive-name-collision.expected.tsx | 4 +++- .../derive-nested-callback.expected.tsx | 11 +++++++++-- .../derive-nested-property.expected.tsx | 4 +++- .../closures/derive-no-captures.expected.tsx | 4 +++- .../derive-optional-chaining.expected.tsx | 4 +++- .../derive-param-initializer.expected.tsx | 8 ++++++-- .../closures/derive-reserved-names.expected.tsx | 8 ++++++-- .../derive-template-literal.expected.tsx | 8 ++++++-- .../closures/derive-type-assertion.expected.tsx | 8 ++++++-- .../derive-union-undefined.expected.tsx | 4 +++- .../map-with-array-param-no-name.expected.tsx | 7 ++++++- .../closures/map-with-array-param.expected.tsx | 7 ++++++- .../element-access-both-opaque.expected.tsx | 11 +++++++++-- .../map-array-length-conditional.expected.tsx | 7 ++++++- .../map-nested-conditional-no-name.expected.tsx | 17 +++++++++++++++-- .../map-nested-conditional.expected.tsx | 17 +++++++++++++++-- .../map-single-capture-no-name.expected.tsx | 16 +++++++++++++++- .../map-single-capture.expected.tsx | 16 +++++++++++++++- .../opaque-ref-cell-map.expected.tsx | 13 ++++++++++--- .../opaque-ref-operations.expected.tsx | 8 ++++++-- .../optional-chain-predicate.expected.tsx | 7 ++++++- .../optional-element-access.expected.tsx | 7 ++++++- 49 files changed, 344 insertions(+), 83 deletions(-) diff --git a/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx index 98d679a893..f140c8270b 100644 --- a/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/cell-map-with-captures.expected.tsx @@ -126,7 +126,13 @@ export default recipe({ } } as const satisfies __ctHelpers.JSONSchema, (state) => { // Explicitly type as Cell to ensure closure transformation - const typedValues: Cell = cell(state.values); + const typedValues: Cell = cell(state.values, { + type: "array", + items: { + type: "number" + }, + asOpaque: true + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{typedValues.mapWithPattern(__ctHelpers.recipe({ diff --git a/packages/ts-transformers/test/fixtures/closures/computed-basic-capture.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-basic-capture.expected.tsx index 2d5ed948eb..c0fae6b4ae 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-basic-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-basic-capture.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestCompute() { - const value = cell(10); - const multiplier = cell(2); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-complex-expression.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-complex-expression.expected.tsx index 389672e19e..2ab1bfa122 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-complex-expression.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-complex-expression.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestComputeComplexExpression() { - const a = cell(10); - const b = cell(20); - const c = cell(5); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c = cell(5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-conditional-expression.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-conditional-expression.expected.tsx index edc5e66f9e..a7bdae0a4f 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-conditional-expression.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-conditional-expression.expected.tsx @@ -1,10 +1,18 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestComputeConditionalExpression() { - const value = cell(10); - const threshold = cell(5); - const a = cell(100); - const b = cell(200); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const threshold = cell(5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const a = cell(100, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(200, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-multiple-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-multiple-captures.expected.tsx index 8d1c47cf49..6484dd4ab6 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-multiple-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-multiple-captures.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestComputeMultipleCaptures() { - const a = cell(10); - const b = cell(20); - const c = cell(30); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c = cell(30, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-nested-property.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-nested-property.expected.tsx index b3a76021a1..684587a943 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-nested-property.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-nested-property.expected.tsx @@ -1,7 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestComputeNestedProperty() { - const counter = cell({ count: 0 }); + const counter = cell({ count: 0 }, { + type: "object", + properties: { + count: { + type: "number" + } + }, + required: ["count"] + } as const satisfies __ctHelpers.JSONSchema); const doubled = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-nested.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-nested.expected.tsx index bb82b14d87..55a103d7c0 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-nested.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-nested.expected.tsx @@ -4,8 +4,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { type: "number", asOpaque: true } as const satisfies __ctHelpers.JSONSchema, () => { - const a = cell(10); - const b = cell(20); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const sum = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-optional-chaining.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-optional-chaining.expected.tsx index 9ab277198e..14b4a9dc90 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-optional-chaining.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-optional-chaining.expected.tsx @@ -3,8 +3,21 @@ import { cell, computed } from "commontools"; export default function TestComputeOptionalChaining() { const config = cell<{ multiplier?: number; - } | null>({ multiplier: 2 }); - const value = cell(10); + } | null>({ multiplier: 2 }, { + anyOf: [{ + type: "object", + properties: { + multiplier: { + type: "number" + } + } + }, { + type: "null" + }] + } as const satisfies __ctHelpers.JSONSchema); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx index 396b303129..23cdc45863 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx @@ -4,9 +4,13 @@ export default pattern((config: { base: number; multiplier: number; }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const offset = 5; // non-cell local - const threshold = cell(15); // cell local + const threshold = cell(15, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // cell local const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx index 1c6ed7a755..1829a1e079 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx @@ -3,7 +3,9 @@ import { cell, computed, pattern } from "commontools"; export default pattern((config: { multiplier: number; }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx index dcf7312486..7750d65369 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, computed, pattern } from "commontools"; export default pattern(({ multiplier }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-recipe-param-mixed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-recipe-param-mixed.expected.tsx index 257f2713d4..312c0f6427 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-recipe-param-mixed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-recipe-param-mixed.expected.tsx @@ -18,9 +18,13 @@ export default recipe({ base: number; multiplier: number; }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const offset = 5; // non-cell local - const threshold = cell(15); // cell local + const threshold = cell(15, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // cell local const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-recipe-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-recipe-param.expected.tsx index 1d50516433..1a70c7dce2 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-recipe-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-recipe-param.expected.tsx @@ -14,7 +14,9 @@ export default recipe({ } as const satisfies __ctHelpers.JSONSchema, (config: { multiplier: number; }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-recipe-typed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-recipe-typed.expected.tsx index d2ce279678..0a33187820 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-recipe-typed.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-recipe-typed.expected.tsx @@ -11,7 +11,9 @@ export default recipe({ } as const satisfies __ctHelpers.JSONSchema, { type: "number" } as const satisfies __ctHelpers.JSONSchema, ({ multiplier }) => { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/computed-with-closed-over-cell-map.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-with-closed-over-cell-map.expected.tsx index c04ccd1a38..bbdf11c587 100644 --- a/packages/ts-transformers/test/fixtures/closures/computed-with-closed-over-cell-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/computed-with-closed-over-cell-map.expected.tsx @@ -1,8 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, computed } from "commontools"; export default function TestComputedWithClosedOverCellMap() { - const numbers = cell([1, 2, 3]); - const multiplier = cell(2); + const numbers = cell([1, 2, 3], { + type: "array", + items: { + type: "number" + } + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Inside computed, we close over numbers (a Cell) // The computed gets transformed to derive({}, () => numbers.map(...)) // Inside a derive, .map on a closed-over Cell should STILL be transformed to mapWithPattern diff --git a/packages/ts-transformers/test/fixtures/closures/derive-4arg-form.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-4arg-form.expected.tsx index ed780fc6af..6cfd1e7cdd 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-4arg-form.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-4arg-form.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive, type JSONSchema } from "commontools"; export default function TestDerive() { - const value = cell(10); - const multiplier = cell(2); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Explicit 4-arg form with schemas - should still transform captures const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-array-element-access.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-array-element-access.expected.tsx index e91f14973c..b0a1933ff4 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-array-element-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-array-element-access.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const factors = [2, 3, 4]; const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-basic-capture.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-basic-capture.expected.tsx index ac81933009..ae601cd921 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-basic-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-basic-capture.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); - const multiplier = cell(2); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-collision-property.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-collision-property.expected.tsx index 5dd89699d1..30395138f2 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-collision-property.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-collision-property.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDeriveCollisionProperty() { - const multiplier = cell(2); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Input name 'multiplier' collides with captured variable 'multiplier' // The callback returns an object with a property named 'multiplier' // Only the variable reference should be renamed, NOT the property name diff --git a/packages/ts-transformers/test/fixtures/closures/derive-collision-shorthand.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-collision-shorthand.expected.tsx index 90928fc044..4744e397dd 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-collision-shorthand.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-collision-shorthand.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDeriveCollisionShorthand() { - const multiplier = cell(2); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Input name 'multiplier' collides with captured variable 'multiplier' // The callback uses shorthand property { multiplier } // This should expand to { multiplier: multiplier_1 } after renaming diff --git a/packages/ts-transformers/test/fixtures/closures/derive-complex-expression.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-complex-expression.expected.tsx index 1be24104b8..08decec6f0 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-complex-expression.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-complex-expression.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const a = cell(10); - const b = cell(20); - const c = cell(30); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c = cell(30, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-computed-property.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-computed-property.expected.tsx index 7fdc41dd80..733118eded 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-computed-property.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-computed-property.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const config = { multiplier: 2, divisor: 5 }; const key = "multiplier"; const result = __ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/closures/derive-conditional-expression.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-conditional-expression.expected.tsx index 4a11f099e4..524b322863 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-conditional-expression.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-conditional-expression.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); - const threshold = cell(5); - const multiplier = cell(2); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const threshold = cell(5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-destructured-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-destructured-param.expected.tsx index 21560a6cce..c0c0ae0fcd 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-destructured-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-destructured-param.expected.tsx @@ -5,8 +5,21 @@ interface Point { y: number; } export default function TestDerive() { - const point = cell({ x: 10, y: 20 } as Point); - const multiplier = cell(2); + const point = cell({ x: 10, y: 20 } as Point, { + type: "object", + properties: { + x: { + type: "number" + }, + y: { + type: "number" + } + }, + required: ["x", "y"] + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Destructured parameter const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-empty-input-no-params.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-empty-input-no-params.expected.tsx index 213775feb9..5129b87862 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-empty-input-no-params.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-empty-input-no-params.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDeriveEmptyInputNoParams() { - const a = cell(10); - const b = cell(20); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Zero-parameter callback that closes over a and b const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-local-variable.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-local-variable.expected.tsx index 753db99b9e..d9c7ab5b6f 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-local-variable.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-local-variable.expected.tsx @@ -1,9 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDeriveLocalVariable() { - const a = cell(10); - const b = cell(20); - const c = cell(30); + const a = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const b = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c = cell(30, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-method-call-capture.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-method-call-capture.expected.tsx index b2f523846c..398c111cd8 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-method-call-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-method-call-capture.expected.tsx @@ -6,7 +6,9 @@ interface State { }; } export default function TestDerive(state: State) { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Capture property before method call const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-name-collision.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-name-collision.expected.tsx index bdeec02ebc..7627ea19a7 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-name-collision.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-name-collision.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const multiplier = cell(2); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Input name collides with capture name // multiplier is both the input AND a captured variable (used via .get()) const result = __ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/closures/derive-nested-callback.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-nested-callback.expected.tsx index 44b6726006..f1658aeab6 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-nested-callback.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-nested-callback.expected.tsx @@ -1,8 +1,15 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const numbers = cell([1, 2, 3]); - const multiplier = cell(2); + const numbers = cell([1, 2, 3], { + type: "array", + items: { + type: "number" + } + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Nested callback - inner array map should not capture outer multiplier const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-nested-property.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-nested-property.expected.tsx index 4a32110c32..2eb0fc1372 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-nested-property.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-nested-property.expected.tsx @@ -6,7 +6,9 @@ interface State { }; } export default function TestDerive(state: State) { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-no-captures.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-no-captures.expected.tsx index 465d8393c3..c9fde92226 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-no-captures.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-no-captures.expected.tsx @@ -1,7 +1,9 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // No captures - should not be transformed const result = derive({ type: "number", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-optional-chaining.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-optional-chaining.expected.tsx index 453e4573e3..ab21d49553 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-optional-chaining.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-optional-chaining.expected.tsx @@ -4,7 +4,9 @@ interface Config { multiplier?: number; } export default function TestDerive(config: Config) { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-param-initializer.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-param-initializer.expected.tsx index c6304abee5..40212b8172 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-param-initializer.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-param-initializer.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(5); - const multiplier = cell(2); + const value = cell(5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Test parameter with default value const result = __ctHelpers.derive({ type: "object", diff --git a/packages/ts-transformers/test/fixtures/closures/derive-reserved-names.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-reserved-names.expected.tsx index 3679f7fd27..079b6cbdce 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-reserved-names.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-reserved-names.expected.tsx @@ -1,9 +1,13 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // Reserved JavaScript keyword as variable name (valid in TS with quotes) - const __ct_reserved = cell(2); + const __ct_reserved = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-template-literal.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-template-literal.expected.tsx index abf3f1e9cf..4a480ec580 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-template-literal.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-template-literal.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); - const prefix = cell("Value: "); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const prefix = cell("Value: ", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-type-assertion.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-type-assertion.expected.tsx index b03320243d..da2d92da12 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-type-assertion.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-type-assertion.expected.tsx @@ -1,8 +1,12 @@ import * as __ctHelpers from "commontools"; import { cell, derive } from "commontools"; export default function TestDerive() { - const value = cell(10); - const multiplier = cell(2); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const multiplier = cell(2, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/derive-union-undefined.expected.tsx b/packages/ts-transformers/test/fixtures/closures/derive-union-undefined.expected.tsx index cb8390b958..349bef5ba8 100644 --- a/packages/ts-transformers/test/fixtures/closures/derive-union-undefined.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/derive-union-undefined.expected.tsx @@ -5,7 +5,9 @@ interface Config { unionUndefined: number | undefined; } export default function TestDerive(config: Config) { - const value = cell(10); + const value = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); const result = __ctHelpers.derive({ type: "object", properties: { diff --git a/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx index 6c3413c9e6..8ccfa53b01 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-with-array-param-no-name.expected.tsx @@ -107,7 +107,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state: any) => { - const items = cell([1, 2, 3, 4, 5]); + const items = cell([1, 2, 3, 4, 5], { + type: "array", + items: { + type: "number" + } + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{items.mapWithPattern(__ctHelpers.recipe({ diff --git a/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx index e0ab4c1a7b..727b0b19cc 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-with-array-param.expected.tsx @@ -107,7 +107,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state) => { - const items = cell([1, 2, 3, 4, 5]); + const items = cell([1, 2, 3, 4, 5], { + type: "array", + items: { + type: "number" + } + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{items.mapWithPattern(__ctHelpers.recipe({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx index 1cc7429828..0783f0b00c 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/element-access-both-opaque.expected.tsx @@ -107,8 +107,15 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state) => { - const items = cell(["apple", "banana", "cherry"]); - const index = cell(1); + const items = cell(["apple", "banana", "cherry"], { + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema); + const index = cell(1, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (

Element Access with Both OpaqueRefs

diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx index e23ba23072..582b14163d 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx @@ -107,7 +107,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state) => { - const list = cell(["apple", "banana", "cherry"]); + const list = cell(["apple", "banana", "cherry"], { + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{__ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx index 3540b582dc..5f87bdcde0 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional-no-name.expected.tsx @@ -107,8 +107,21 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state: any) => { - const items = cell([{ name: "apple" }, { name: "banana" }]); - const showList = cell(true); + const items = cell([{ name: "apple" }, { name: "banana" }], { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + } + }, + required: ["name"] + } + } as const satisfies __ctHelpers.JSONSchema); + const showList = cell(true, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{__ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx index f606c98454..f5983ba471 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-nested-conditional.expected.tsx @@ -107,8 +107,21 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state) => { - const items = cell([{ name: "apple" }, { name: "banana" }]); - const showList = cell(true); + const items = cell([{ name: "apple" }, { name: "banana" }], { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string" + } + }, + required: ["name"] + } + } as const satisfies __ctHelpers.JSONSchema); + const showList = cell(true, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{__ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx index 8e70dd2258..2866323eda 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx @@ -110,7 +110,21 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { const people = cell([ { id: "1", name: "Alice" }, { id: "2", name: "Bob" }, - ]); + ], { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "string" + }, + name: { + type: "string" + } + }, + required: ["id", "name"] + } + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{__ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx index da377ecd26..1079d9f91c 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx @@ -110,7 +110,21 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { const people = cell([ { id: "1", name: "Alice" }, { id: "2", name: "Bob" }, - ]); + ], { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "string" + }, + name: { + type: "string" + } + }, + required: ["id", "name"] + } + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (
{__ctHelpers.derive({ diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx index d1472e4c2b..a4eabe603f 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx @@ -124,7 +124,10 @@ const createCellRef = lift({ }, undefined, ({ isInitialized, storedCellRef }) => { if (!isInitialized.get()) { console.log("Creating cellRef - first time"); - const newCellRef = Cell.for("charmsArray"); + const newCellRef = Cell.for("charmsArray").asSchema({ + type: "array", + items: true + } as const satisfies __ctHelpers.JSONSchema); newCellRef.set([]); storedCellRef.set(newCellRef); isInitialized.set(true); @@ -178,7 +181,9 @@ const createSimpleRecipe = handler(true as const satisfies __ctHelpers.JSONSchem required: ["cellRef"] } as const satisfies __ctHelpers.JSONSchema, (_, { cellRef }) => { // Create isInitialized cell for this charm addition - const isInitialized = cell(false); + const isInitialized = cell(false, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); // Create the charm const charm = SimpleRecipe({}); // Store the charm in the array and navigate @@ -309,7 +314,9 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } as const satisfies __ctHelpers.JSONSchema, () => { // cell to store array of charms we created const { cellRef } = createCellRef({ - isInitialized: cell(false), + isInitialized: cell(false, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema), storedCellRef: cell(), }); // Type assertion to help TypeScript understand cellRef is an OpaqueRef diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx index 00a6fb89a9..45a3a08374 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx @@ -107,8 +107,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, (_state) => { - const count = cell(10); - const price = cell(10); + const count = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const price = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); return { [UI]: (

Count: {count}

diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx index b266d0df5f..a4185f119f 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx @@ -110,7 +110,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, () => { - const items = cell([]); + const items = cell([], { + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema); return { [NAME]: "Optional chain predicate", [UI]: (
diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx index 1c0d69a362..4bd173a7a5 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx @@ -110,7 +110,12 @@ export default recipe(false as const satisfies __ctHelpers.JSONSchema, { } } } as const satisfies __ctHelpers.JSONSchema, () => { - const list = cell(undefined); + const list = cell(undefined, { + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema); return { [NAME]: "Optional element access", [UI]: (
From dcba56d9668084458c9dcbeaec36fab05b0a873e Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 13:59:32 -0800 Subject: [PATCH 04/10] fmt --- packages/ts-transformers/src/ast/type-inference.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ts-transformers/src/ast/type-inference.ts b/packages/ts-transformers/src/ast/type-inference.ts index c21b299c97..007ac10208 100644 --- a/packages/ts-transformers/src/ast/type-inference.ts +++ b/packages/ts-transformers/src/ast/type-inference.ts @@ -404,7 +404,7 @@ export function inferArrayElementType( // getTypeAtLocationWithFallback handles undefined typeRegistry gracefully const arrayType = getTypeAtLocationWithFallback(arrayExpr, checker, typeRegistry) ?? - checker.getTypeAtLocation(arrayExpr); + checker.getTypeAtLocation(arrayExpr); // Try to unwrap OpaqueRef → T[] → T let actualType = arrayType; @@ -479,7 +479,7 @@ export function inferArrayElementType( // Convert Type to TypeNode const typeNode = typeToTypeNode(elementType, checker, context.sourceFile) ?? - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); + factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); return { typeNode, type: elementType }; } From 43c8e3c4c27d11f485141fcc4e014abcbbd6d8e6 Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 23:03:58 -0800 Subject: [PATCH 05/10] first draft of supporting new schemas --- .../src/formatters/primitive-formatter.ts | 26 +- .../src/formatters/union-formatter.ts | 162 +++++++- packages/schema-generator/src/interface.ts | 3 + packages/schema-generator/src/plugin.ts | 3 +- .../schema-generator/src/schema-generator.ts | 5 +- .../ts-transformers/SCHEMA_INJECTION_NOTES.md | 158 +++++++ .../TEST_PLAN_schema_injection.md | 386 ++++++++++++++++++ .../src/transformers/schema-generator.ts | 18 +- .../src/transformers/schema-injection.ts | 31 +- .../test/fixture-based.test.ts | 5 + .../literal-widen-array-elements.expected.tsx | 27 ++ .../literal-widen-array-elements.input.tsx | 10 + .../literal-widen-bigint.expected.tsx | 18 + .../literal-widen-bigint.input.tsx | 10 + .../literal-widen-boolean.expected.tsx | 15 + .../literal-widen-boolean.input.tsx | 9 + ...eral-widen-explicit-type-args.expected.tsx | 18 + ...literal-widen-explicit-type-args.input.tsx | 10 + .../literal-widen-mixed-values.expected.tsx | 19 + .../literal-widen-mixed-values.input.tsx | 11 + ...iteral-widen-nested-structure.expected.tsx | 42 ++ .../literal-widen-nested-structure.input.tsx | 14 + .../literal-widen-null-undefined.expected.tsx | 13 + .../literal-widen-null-undefined.input.tsx | 9 + .../literal-widen-number.expected.tsx | 24 ++ .../literal-widen-number.input.tsx | 12 + ...teral-widen-object-properties.expected.tsx | 24 ++ .../literal-widen-object-properties.input.tsx | 8 + .../literal-widen-string.expected.tsx | 21 + .../literal-widen-string.input.tsx | 11 + 30 files changed, 1111 insertions(+), 11 deletions(-) create mode 100644 packages/ts-transformers/SCHEMA_INJECTION_NOTES.md create mode 100644 packages/ts-transformers/TEST_PLAN_schema_injection.md create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx diff --git a/packages/schema-generator/src/formatters/primitive-formatter.ts b/packages/schema-generator/src/formatters/primitive-formatter.ts index ab8dc3e285..137922655a 100644 --- a/packages/schema-generator/src/formatters/primitive-formatter.ts +++ b/packages/schema-generator/src/formatters/primitive-formatter.ts @@ -19,6 +19,8 @@ export class PrimitiveFormatter implements TypeFormatter { (flags & ts.TypeFlags.BooleanLiteral) !== 0 || (flags & ts.TypeFlags.StringLiteral) !== 0 || (flags & ts.TypeFlags.NumberLiteral) !== 0 || + (flags & ts.TypeFlags.BigInt) !== 0 || + (flags & ts.TypeFlags.BigIntLiteral) !== 0 || (flags & ts.TypeFlags.Null) !== 0 || (flags & ts.TypeFlags.Undefined) !== 0 || (flags & ts.TypeFlags.Void) !== 0 || @@ -27,28 +29,47 @@ export class PrimitiveFormatter implements TypeFormatter { (flags & ts.TypeFlags.Any) !== 0; } - formatType(type: ts.Type, _context: GenerationContext): SchemaDefinition { + formatType(type: ts.Type, context: GenerationContext): SchemaDefinition { const flags = type.flags; // Handle literal types first (more specific) + // If widenLiterals flag is set, skip enum generation and return base type if (flags & ts.TypeFlags.StringLiteral) { + if (context.widenLiterals) { + return { type: "string" }; + } return { type: "string", enum: [(type as ts.StringLiteralType).value], }; } if (flags & ts.TypeFlags.NumberLiteral) { + if (context.widenLiterals) { + return { type: "number" }; + } return { type: "number", enum: [(type as ts.NumberLiteralType).value], }; } if (flags & ts.TypeFlags.BooleanLiteral) { + if (context.widenLiterals) { + return { type: "boolean" }; + } return { type: "boolean", enum: [(type as TypeWithInternals).intrinsicName === "true"], }; } + if (flags & ts.TypeFlags.BigIntLiteral) { + if (context.widenLiterals) { + return { type: "integer" }; + } + return { + type: "integer", + enum: [Number((type as ts.BigIntLiteralType).value.base10Value)], + }; + } // Handle general primitive types if (flags & ts.TypeFlags.String) { @@ -60,6 +81,9 @@ export class PrimitiveFormatter implements TypeFormatter { if (flags & ts.TypeFlags.Boolean) { return { type: "boolean" }; } + if (flags & ts.TypeFlags.BigInt) { + return { type: "integer" }; + } if (flags & ts.TypeFlags.Null) { return { type: "null" }; } diff --git a/packages/schema-generator/src/formatters/union-formatter.ts b/packages/schema-generator/src/formatters/union-formatter.ts index 0c5af9a44c..7a91a21379 100644 --- a/packages/schema-generator/src/formatters/union-formatter.ts +++ b/packages/schema-generator/src/formatters/union-formatter.ts @@ -10,6 +10,7 @@ import { getNativeTypeSchema, TypeWithInternals, } from "../type-utils.ts"; +import { isRecord } from "@commontools/utils/types"; export class UnionFormatter implements TypeFormatter { constructor(private schemaGenerator: SchemaGenerator) {} @@ -95,14 +96,171 @@ export class UnionFormatter implements TypeFormatter { } // Fallback: anyOf of member schemas (excluding null/undefined handled above) - const anyOf = nonNull.map((m) => generate(m)); + let anyOf = nonNull.map((m) => generate(m)); if (hasNull) anyOf.push({ type: "null" }); - // If only one schema remains after filtering, return it directly without anyOf wrapper + // When widenLiterals is true, try to merge structurally identical schemas + // that only differ in literal enum values + if (context.widenLiterals && anyOf.length > 1) { + anyOf = this.mergeIdenticalSchemas(anyOf); + } + + // If only one schema remains after filtering/merging, return it directly without anyOf wrapper if (anyOf.length === 1) { return anyOf[0]!; } return { anyOf }; } + + /** + * Merge schemas that are structurally identical except for literal enum values. + * Used when widenLiterals is true to collapse unions like + * {x: {enum: [10]}} | {x: {enum: [20]}} into {x: {type: "number"}} + */ + private mergeIdenticalSchemas( + schemas: SchemaDefinition[], + ): SchemaDefinition[] { + if (schemas.length <= 1) return schemas; + + // Group schemas by their structure (ignoring enum values) + const groups = new Map(); + + for (const schema of schemas) { + const normalized = this.normalizeSchemaForComparison(schema); + const key = JSON.stringify(normalized); + const group = groups.get(key) ?? []; + group.push(schema); + groups.set(key, group); + } + + // For each group with multiple schemas, try to merge them + const result: SchemaDefinition[] = []; + for (const group of groups.values()) { + if (group.length === 1) { + result.push(group[0]!); + } else { + // Multiple schemas with same structure - merge them + result.push(this.mergeSchemaGroup(group)); + } + } + + return result; + } + + /** + * Normalize a schema for structural comparison by removing enum values + * and converting them to base types + */ + private normalizeSchemaForComparison( + schema: SchemaDefinition, + ): Record { + if (typeof schema === "boolean") return { _bool: schema }; + + const result: Record = {}; + + // Convert enum to base type for comparison + if ("enum" in schema && schema.enum) { + const firstValue = schema.enum[0]; + if (typeof firstValue === "string") { + result.type = "string"; + } else if (typeof firstValue === "number") { + result.type = "number"; + } else if (typeof firstValue === "boolean") { + result.type = "boolean"; + } + } else if ("type" in schema) { + result.type = schema.type; + } + + // Recursively normalize properties + if ("properties" in schema && isRecord(schema.properties)) { + const props: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + props[key] = this.normalizeSchemaForComparison(value as SchemaDefinition); + } + result.properties = props; + } + + // Recursively normalize items + if ("items" in schema && schema.items) { + result.items = this.normalizeSchemaForComparison( + schema.items as SchemaDefinition, + ); + } + + // Copy other structural fields + if ("required" in schema) result.required = schema.required; + if ("additionalProperties" in schema) { + result.additionalProperties = schema.additionalProperties; + } + + return result; + } + + /** + * Merge a group of structurally identical schemas by widening their enums + */ + private mergeSchemaGroup(schemas: SchemaDefinition[]): SchemaDefinition { + if (schemas.length === 0) { + throw new Error("Cannot merge empty schema group"); + } + + const first = schemas[0]!; + if (typeof first === "boolean") return first; + + const result: SchemaDefinition = {}; + + // Handle enum -> base type conversion + if ("enum" in first && first.enum) { + const firstValue = first.enum[0]; + if (typeof firstValue === "string") { + result.type = "string"; + } else if (typeof firstValue === "number") { + result.type = "number"; + } else if (typeof firstValue === "boolean") { + result.type = "boolean"; + } + } else if ("type" in first) { + result.type = first.type; + } + + // Recursively merge properties + if ("properties" in first && isRecord(first.properties)) { + const props: Record = {}; + for (const key of Object.keys(first.properties)) { + const propSchemas = schemas + .map((s) => + isRecord(s) && isRecord(s.properties) ? s.properties[key] : undefined + ) + .filter((p): p is SchemaDefinition => p !== undefined); + + if (propSchemas.length > 0) { + props[key] = this.mergeSchemaGroup(propSchemas); + } + } + result.properties = props; + } + + // Recursively merge items + if ("items" in first && first.items) { + const itemSchemas = schemas + .map((s) => + isRecord(s) && "items" in s && s.items ? s.items : undefined + ) + .filter((i): i is SchemaDefinition => i !== undefined); + + if (itemSchemas.length > 0) { + result.items = this.mergeSchemaGroup(itemSchemas); + } + } + + // Copy other structural fields from first schema + if ("required" in first) result.required = first.required; + if ("additionalProperties" in first) { + result.additionalProperties = first.additionalProperties; + } + + return result; + } } diff --git a/packages/schema-generator/src/interface.ts b/packages/schema-generator/src/interface.ts index f12b60d25f..88a47c02f9 100644 --- a/packages/schema-generator/src/interface.ts +++ b/packages/schema-generator/src/interface.ts @@ -40,6 +40,8 @@ export interface GenerationContext { typeNode?: ts.TypeNode; /** Optional type registry for synthetic nodes */ typeRegistry?: WeakMap; + /** Widen literal types to base types during schema generation */ + widenLiterals?: boolean; } /** @@ -68,6 +70,7 @@ export interface SchemaGenerator { type: ts.Type, checker: ts.TypeChecker, typeNode?: ts.TypeNode, + options?: { widenLiterals?: boolean }, ): SchemaDefinition; /** diff --git a/packages/schema-generator/src/plugin.ts b/packages/schema-generator/src/plugin.ts index 37fbce04bf..eb21585544 100644 --- a/packages/schema-generator/src/plugin.ts +++ b/packages/schema-generator/src/plugin.ts @@ -13,8 +13,9 @@ export function createSchemaTransformerV2() { type: ts.Type, checker: ts.TypeChecker, typeArg?: ts.TypeNode, + options?: { widenLiterals?: boolean }, ) { - return generator.generateSchema(type, checker, typeArg); + return generator.generateSchema(type, checker, typeArg, options); }, generateSchemaFromSyntheticTypeNode( diff --git a/packages/schema-generator/src/schema-generator.ts b/packages/schema-generator/src/schema-generator.ts index 25ff84d6e9..e31afa3808 100644 --- a/packages/schema-generator/src/schema-generator.ts +++ b/packages/schema-generator/src/schema-generator.ts @@ -44,8 +44,9 @@ export class SchemaGenerator implements ISchemaGenerator { type: ts.Type, checker: ts.TypeChecker, typeNode?: ts.TypeNode, + options?: { widenLiterals?: boolean }, ): SchemaDefinition { - return this.generateSchemaInternal(type, checker, typeNode, undefined); + return this.generateSchemaInternal(type, checker, typeNode, undefined, options); } /** @@ -79,6 +80,7 @@ export class SchemaGenerator implements ISchemaGenerator { checker: ts.TypeChecker, typeNode?: ts.TypeNode, typeRegistry?: WeakMap, + options?: { widenLiterals?: boolean }, ): SchemaDefinition { // Create unified context with all state const cycles = this.getCycles(type, checker); @@ -101,6 +103,7 @@ export class SchemaGenerator implements ISchemaGenerator { // Optional context ...(typeNode && { typeNode }), ...(typeRegistry && { typeRegistry }), + ...(options?.widenLiterals && { widenLiterals: true }), }; // Auto-detect: Should we use node-based or type-based analysis? diff --git a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md new file mode 100644 index 0000000000..f45ef3ef92 --- /dev/null +++ b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md @@ -0,0 +1,158 @@ +# Schema Injection - Implementation Notes & Known Issues + +This document captures design decisions and concerns about the schema injection implementation, particularly around literal type widening. + +## Known Issues & Concerns + +### 1. Recursive Schema Merging Feels Brittle + +**Location**: `packages/schema-generator/src/formatters/union-formatter.ts` (lines 115-262) + +**Problem**: When `widenLiterals: true`, we need to merge structurally identical schemas that differ only in literal enum values. For example: + +```typescript +// TypeScript infers this as a union of two object types: +[{active: true}, {active: false}] +// Type: Array<{active: true} | {active: false}> + +// We want this schema: +{ + type: "array", + items: { + type: "object", + properties: { + active: { type: "boolean" } // ← merged, not anyOf + } + } +} +``` + +**Current Implementation**: +- `mergeIdenticalSchemas()` - Groups schemas by normalized structure (ignoring enum values) +- `normalizeSchemaForComparison()` - Converts schemas to comparable form (enum → base type) +- `mergeSchemaGroup()` - Recursively merges schema groups by widening enums to base types + +**Concerns**: +- **Brittle**: Relies on JSON.stringify() for structural comparison, which is fragile +- **Hacky**: Recursively walks and rebuilds schema objects to merge them +- **Incomplete**: Only handles properties, items, required, additionalProperties - might miss edge cases +- **Performance**: Creates many intermediate objects and does O(n²) comparisons + +**Why This Approach**: +- TypeScript's type inference creates union types for array literals with different property values +- The top-level `widenLiteralType()` only widens the immediate type, not nested unions +- We need schema-level merging because the Type is already a union by the time it reaches the formatter + +**Possible Future Improvements**: +1. Widen the Type earlier in the pipeline (before schema generation) +2. Use a more robust structural equality check (not JSON.stringify) +3. Define a formal schema normalization/merging algorithm +4. Add comprehensive tests for edge cases (nested unions, mixed types, etc.) + +--- + +### 2. Undefined Schema Choice Is Uncertain + +**Location**: `packages/schema-generator/src/formatters/primitive-formatter.ts` (lines 90-94) + +**Problem**: What JSON Schema should we generate for `cell(undefined)`? + +**Current Behavior**: Returns `true` (JSON Schema boolean meaning "accept any value") + +```typescript +const c = cell(undefined); +// Generates: cell(undefined, true as const satisfies JSONSchema) +``` + +**Why `true` Is Questionable**: +- `undefined` is not a valid JSON value (serializes to `null` or is omitted) +- `true` means "no validation" - everything passes +- Doesn't reflect what actually happens at runtime + +**Alternative Options Considered**: + +1. **`{ type: "null" }`** - Treat undefined same as null + - Pro: Matches JSON serialization behavior + - Con: Loses semantic distinction between explicit null and undefined + +2. **Skip schema injection** - Don't inject schema for undefined at all + - Pro: Acknowledges undefined isn't JSON-serializable + - Con: Inconsistent - some cells have schemas, others don't + +3. **`true` (current)** - Accept any value + - Pro: Won't reject values at runtime (permissive) + - Con: Provides no validation benefit + +4. **`{}`** - Empty schema object (equivalent to `true`) + - Pro: Semantically similar but object form + - Con: Still provides no validation + +**Decision**: Use `true` for now +- Rationale: Permissive approach won't cause runtime rejection issues +- Trade-off: No validation benefit, but avoids breaking things +- Future: May need to revisit based on real-world usage patterns + +**Related**: TypeScript itself has ongoing confusion about `null` vs `undefined` semantics in JSON contexts. Our schema generation reflects this ambiguity. + +--- + +## Design Decisions + +### Literal Widening Strategy + +**When widening happens**: +- ✅ **Value inference path**: `cell(10)` → widens 10 to number +- ❌ **Explicit type args path**: `cell<10>(10)` → preserves literal type 10 + +**How widening is triggered**: +1. `schema-injection.ts` detects value inference (no explicit type arg) +2. Passes `{ widenLiterals: true }` option through `toSchema()` call +3. `schema-generator.ts` extracts option and passes to schema generator +4. `GenerationContext.widenLiterals` flag propagates to all formatters +5. Formatters check flag and widen literals when set + +**Affected types**: +- Number literals: `10` → `number` +- String literals: `"hello"` → `string` +- Boolean literals: `true`/`false` → `boolean` +- BigInt literals: `10n` → `bigint` (integer in JSON Schema) +- Nested literals: Recursively widened through union merging + +--- + +## Test Coverage + +All literal widening scenarios tested in `test/fixtures/schema-injection/`: +- ✅ `literal-widen-number.input.tsx` +- ✅ `literal-widen-string.input.tsx` +- ✅ `literal-widen-boolean.input.tsx` +- ✅ `literal-widen-bigint.input.tsx` +- ✅ `literal-widen-array-elements.input.tsx` +- ✅ `literal-widen-object-properties.input.tsx` +- ✅ `literal-widen-nested-structure.input.tsx` (tests recursive merging) +- ✅ `literal-widen-explicit-type-args.input.tsx` (ensures literals preserved when explicit) +- ✅ `literal-widen-mixed-values.input.tsx` +- ✅ `literal-widen-null-undefined.input.tsx` (documents undefined → `true` behavior) + +--- + +## Future Work + +### Priority: High +- [ ] Revisit undefined schema choice based on runtime behavior patterns +- [ ] Add error handling for schema merging edge cases + +### Priority: Medium +- [ ] Replace JSON.stringify comparison with proper structural equality +- [ ] Optimize schema merging performance (reduce intermediate objects) +- [ ] Add comprehensive edge case tests (deeply nested unions, mixed union types) + +### Priority: Low +- [ ] Consider widening at Type level instead of Schema level +- [ ] Explore alternative union merging strategies +- [ ] Document schema generation algorithm formally + +--- + +**Last Updated**: 2025-01-21 +**Implementation**: PR #XXXX (schema injection with literal widening) diff --git a/packages/ts-transformers/TEST_PLAN_schema_injection.md b/packages/ts-transformers/TEST_PLAN_schema_injection.md new file mode 100644 index 0000000000..a0a0c8aaf4 --- /dev/null +++ b/packages/ts-transformers/TEST_PLAN_schema_injection.md @@ -0,0 +1,386 @@ +# Comprehensive Test Plan: Schema Injection for Cell-like Objects + +## Implementation Summary + +This branch adds automatic schema injection for: +1. **Cell factory methods**: `cell()`, `Cell.of()`, `OpaqueCell.of()`, `Stream.of()`, etc. +2. **Cell.for() methods**: `Cell.for()`, `OpaqueCell.for()`, etc. (wrapped with `.asSchema()`) +3. **wish()**: Schema passed as second argument +4. **generateObject()**: Schema added to options object +5. **Literal type widening**: Number/string/boolean literals → base types + +## Test Coverage Matrix + +### 1. Cell Factory Tests (`cell()`, `*.of()`) + +#### A. Type Argument Variations + +**Happy Path:** +- [x] Explicit type arg with matching literal: `Cell.of("hello")` +- [ ] Explicit type arg with variable: `Cell.of(myVar)` +- [ ] Explicit type arg with expression: `Cell.of(10 + 20)` +- [x] No type arg, infer from number literal: `cell(123)` +- [x] No type arg, infer from string literal: `cell("hello")` +- [x] No type arg, infer from boolean literal: `cell(true)` +- [ ] No type arg, infer from array literal: `cell([1, 2, 3])` +- [ ] No type arg, infer from object literal: `cell({ x: 10 })` +- [ ] No type arg, infer from function call result: `cell(getValue())` + +**Edge Cases:** +- [ ] Type arg doesn't match value type: `Cell.of(123)` (should use type arg) +- [ ] Complex generic type: `Cell.of>(items)` +- [ ] Type with multiple generic params: `Cell.of>(map)` + +#### B. All Cell-like Classes + +**Coverage:** +- [x] `Cell.of()` +- [x] `OpaqueCell.of()` +- [x] `Stream.of()` +- [ ] `ComparableCell.of()` +- [ ] `ReadonlyCell.of()` +- [ ] `WriteonlyCell.of()` +- [ ] `cell()` function (standalone) + +#### C. Value Type Variations + +**Primitives:** +- [x] Number literal: `cell(42)` +- [x] String literal: `cell("test")` +- [x] Boolean literal: `cell(true)` and `cell(false)` +- [ ] BigInt literal: `cell(123n)` +- [ ] Null: `cell(null)` +- [ ] Undefined: `cell(undefined)` + +**Collections:** +- [ ] Empty array: `cell([])` +- [ ] Array of primitives: `cell([1, 2, 3])` +- [ ] Array of objects: `cell([{id: 1}, {id: 2}])` +- [ ] Nested arrays: `cell([[1, 2], [3, 4]])` +- [ ] Tuple: `cell([1, "hello", true] as [number, string, boolean])` +- [ ] Empty object: `cell({})` +- [ ] Object with properties: `cell({ name: "test", age: 30 })` +- [ ] Nested objects: `cell({ user: { name: "test" } })` +- [ ] Array of mixed types: `cell([1, "two", true])` + +**Complex Types:** +- [ ] Union type explicit: `cell(42)` +- [ ] Union type inferred: `const val: number | string = getValue(); cell(val)` +- [ ] Optional type: `cell("hello")` +- [ ] Intersection type: `cell(value)` +- [ ] Enum value: `cell(MyEnum.Value)` +- [ ] Literal union: `cell<"a" | "b" | "c">("a")` +- [ ] Date: `cell(new Date())` +- [ ] RegExp: `cell(/pattern/)` +- [ ] Function: `cell(() => 42)` + +#### D. Literal Widening Verification + +**Number Literals:** +- [ ] Single literal: `cell(10)` → `{ type: "number" }` not `{ type: "number", enum: [10] }` +- [ ] Negative: `cell(-5)` → `{ type: "number" }` +- [ ] Float: `cell(3.14)` → `{ type: "number" }` +- [ ] Scientific notation: `cell(1e10)` → `{ type: "number" }` + +**String Literals:** +- [ ] Simple string: `cell("hello")` → `{ type: "string" }` not enum +- [ ] Empty string: `cell("")` → `{ type: "string" }` +- [ ] String with escapes: `cell("hello\nworld")` → `{ type: "string" }` + +**Boolean Literals:** +- [ ] True: `cell(true)` → `{ type: "boolean" }` not `{ type: "boolean", enum: [true] }` +- [ ] False: `cell(false)` → `{ type: "boolean" }` not enum + +**Array Element Widening:** +- [ ] Array of number literals: `cell([1, 2, 3])` → items should be `{ type: "number" }` +- [ ] Array of string literals: `cell(["a", "b"])` → items should be `{ type: "string" }` + +**Object Property Widening:** +- [ ] Object with literal properties: `cell({ x: 10, y: 20 })` → properties should be widened + +#### E. Double-Injection Prevention + +**Should NOT transform:** +- [ ] Already has 2 arguments: `cell(10, existingSchema)` → leave unchanged +- [ ] Already has schema in wrong position: `cell(schema, 10)` → should still not transform +- [ ] Has more than 2 arguments: `cell(10, schema, extra)` → leave unchanged + +#### F. Context Variations + +**Different scopes:** +- [ ] Top-level: `const c = cell(10);` +- [ ] Inside function: `function f() { const c = cell(10); }` +- [ ] Inside arrow function: `const f = () => { const c = cell(10); }` +- [ ] Inside class method: `class C { method() { const c = cell(10); } }` +- [ ] Inside recipe/pattern: `recipe(() => { const c = cell(10); })` +- [ ] Inside handler: `handler(() => { const c = cell(10); })` + +--- + +### 2. Cell.for() Tests + +#### A. Type Argument Variations + +**Happy Path:** +- [x] Explicit type arg: `Cell.for("cause")` +- [x] No type arg, infer from variable annotation: `const c: Cell = Cell.for("cause")` +- [ ] No type arg, infer from parameter: `function f(c: Cell = Cell.for("cause")) {}` +- [ ] No type arg, infer from return type: `function f(): Cell { return Cell.for("cause"); }` + +**Edge Cases:** +- [ ] Type inference failure (no contextual type): `const c = Cell.for("cause")` → what happens? +- [ ] Complex generic type: `const c: Cell> = Cell.for("cause")` + +#### B. All Cell-like Classes + +**Coverage:** +- [x] `Cell.for()` +- [ ] `OpaqueCell.for()` +- [ ] `Stream.for()` +- [ ] `ComparableCell.for()` +- [ ] `ReadonlyCell.for()` +- [ ] `WriteonlyCell.for()` + +#### C. Wrapping Verification + +**Format:** +- [ ] Verify output is `.asSchema()` method call: `Cell.for("x").asSchema(schema)` +- [ ] Verify original arguments preserved: `Cell.for("cause", arg2).asSchema(schema)` + +#### D. Double-Wrapping Prevention + +**Should NOT transform:** +- [ ] Already wrapped: `Cell.for("cause").asSchema(schema)` → leave unchanged +- [ ] Parent is property access to asSchema: verify detection works + +--- + +### 3. wish() Tests + +#### A. Type Argument Variations + +**Happy Path:** +- [x] Explicit type arg: `wish("query")` +- [x] No type arg, infer from variable annotation: `const w: string = wish("query")` +- [ ] No type arg, infer from parameter: `function f(w: number = wish("query")) {}` +- [ ] No type arg, infer from return type: `function f(): string { return wish("query"); }` +- [ ] Infer from WishResult wrapper: `const w: WishResult = wish("query")` + +**Edge Cases:** +- [ ] Generic type: `wish>("query")` +- [ ] Union type: `wish("query")` +- [ ] Complex nested type: `wish<{ users: User[], total: number }>("query")` + +#### B. Query Argument Variations + +**Different query formats:** +- [ ] String literal: `wish("simple query")` +- [ ] Template literal: `wish(\`query with \${var}\`)` +- [ ] Variable: `const q = "query"; wish(q)` +- [ ] Expression: `wish("prefix" + variable)` + +#### C. Double-Injection Prevention + +**Should NOT transform:** +- [ ] Already has 2 arguments: `wish("query", schema)` → leave unchanged +- [ ] Has more than 2 arguments: `wish("query", schema, extra)` → leave unchanged + +--- + +### 4. generateObject() Tests + +#### A. Type Argument Variations + +**Happy Path:** +- [x] Explicit type arg with options: `generateObject({ model: "gpt-4" })` +- [x] No type arg, infer from variable annotation: `const g: { object: number } = generateObject({ model: "gpt-4" })` +- [ ] No type arg, infer from return type +- [ ] No type arg, infer from parameter type + +**Edge Cases:** +- [ ] Generic type: `generateObject>(...)` +- [ ] Complex nested type: `generateObject<{ users: User[] }>(...)` + +#### B. Options Argument Variations + +**Options formats:** +- [x] Object literal: `generateObject({ model: "gpt-4" })` +- [ ] Empty object: `generateObject({})` +- [ ] No options: `generateObject()` +- [ ] Variable: `const opts = {...}; generateObject(opts)` +- [ ] Spread in literal: `generateObject({ ...baseOpts, model: "gpt-4" })` +- [ ] Expression: `generateObject(getOptions())` + +**Schema insertion:** +- [ ] Empty object → add schema: `{}` → `{ schema: ... }` +- [ ] Existing properties → add schema: `{ model: "x" }` → `{ model: "x", schema: ... }` +- [ ] Non-literal options → spread: `opts` → `{ ...opts, schema: ... }` + +#### C. Double-Injection Prevention + +**Should NOT transform:** +- [x] Already has schema in options: `generateObject({ model: "x", schema: existingSchema })` +- [ ] Schema with different name (schemaDefinition, etc.) → should still inject +- [ ] Schema as computed property: `{ ["schema"]: existing }` → what happens? + +--- + +### 5. Integration Tests + +#### A. Multiple Functions Together + +**Combinations:** +- [ ] Multiple cells in one scope: `const a = cell(1); const b = cell(2);` +- [ ] cell() + Cell.for(): Both get schemas correctly +- [ ] wish() + cell(): Both transformed +- [ ] generateObject() + cell(): Both transformed +- [ ] All four functions in one file + +#### B. Nested in CommonTools Functions + +**Contexts:** +- [ ] cell() inside recipe: `recipe(() => { const c = cell(10); })` +- [ ] cell() inside pattern: `pattern(() => { const c = cell(10); })` +- [ ] cell() inside handler: `handler(() => { const c = cell(10); })` +- [ ] cell() inside derive callback: `derive(x, () => { const c = cell(10); })` +- [ ] cell() inside lift callback: `lift(() => { const c = cell(10); })` + +#### C. Closure Capture Interaction + +**Verify no conflicts:** +- [ ] cell() in closure that's captured: Does schema injection work? +- [ ] cell() capturing another cell: `const a = cell(1); const b = derive(a, () => cell(2))` + +--- + +### 6. Negative Tests (Should NOT Transform) + +#### A. Missing Type Information + +**Cases:** +- [ ] Type is `any`: `cell(value)` → skip? +- [ ] Type is `unknown`: `cell(value)` → skip? +- [ ] Type is `never`: `cell(value)` → skip? +- [ ] Type inference fails completely → should not transform + +#### B. Already Has Schema + +**All formats:** +- [ ] `cell(value, schema)` → leave unchanged +- [ ] `Cell.for("x").asSchema(schema)` → leave unchanged +- [ ] `wish("query", schema)` → leave unchanged +- [ ] `generateObject({ schema })` → leave unchanged + +#### C. Non-CommonTools Functions + +**Should ignore:** +- [ ] Other library's `cell()`: `import { cell } from "other-lib";` +- [ ] User-defined cell(): `function cell(x) { return x; }` +- [ ] Similarly for wish, generateObject + +--- + +### 7. Type System Edge Cases + +#### A. Advanced TypeScript Features + +**Complex types:** +- [ ] Conditional types: `cell(value)` +- [ ] Mapped types: `cell<{ [K in keyof T]: T[K] }>(value)` +- [ ] Template literal types: `cell<\`prefix_\${string}\`>(value)` +- [ ] Indexed access: `cell(value)` +- [ ] `keyof` types: `cell(value)` +- [ ] `typeof` types: `cell(value)` + +#### B. Generic Type Parameters + +**In generic functions:** +- [ ] `function f(val: T) { return cell(val); }` → how is T handled? +- [ ] `function f() { return cell(defaultValue); }` +- [ ] Constrained generics: `function f(val: T) { cell(val); }` + +#### C. Type Aliases and Interfaces + +**Indirection:** +- [ ] Type alias: `type X = number; cell(10)` +- [ ] Interface: `interface I { x: number }; cell({ x: 10 })` +- [ ] Nested type alias: `type X = Y; type Y = number; cell(10)` + +--- + +### 8. Schema Generation Verification + +#### A. Schema Shape Correctness + +**Verify schemas match JSON Schema spec:** +- [ ] Primitives have correct `type` field +- [ ] Objects have `properties` and `required` +- [ ] Arrays have `items` +- [ ] Unions use `anyOf` or appropriate construct +- [ ] All schemas have `as const satisfies __ctHelpers.JSONSchema` + +#### B. Complex Schema Structures + +**Advanced schemas:** +- [ ] Recursive types: `type Tree = { value: number, children: Tree[] }` +- [ ] Self-referential interfaces +- [ ] Mutually recursive types +- [ ] Very deeply nested structures (10+ levels) + +--- + +### 9. Error Handling and Edge Cases + +#### A. Malformed Code + +**Should handle gracefully:** +- [ ] Syntax errors in surrounding code +- [ ] Incomplete type information +- [ ] Circular type references + +#### B. Performance + +**Large codebases:** +- [ ] File with 100+ cell() calls +- [ ] Very large type definitions (1000+ properties) +- [ ] Deeply nested generic types + +--- + +### 10. Source Location Preservation + +#### A. Formatting and Whitespace + +**Verify:** +- [ ] Original formatting preserved where possible +- [ ] Line numbers stay consistent for error reporting +- [ ] Comments preserved +- [ ] Multi-line expressions handled correctly + +--- + +## Test File Organization + +Suggested new test files: + +1. `test/schema-injection-cell-factory.test.ts` - Cell/cell() comprehensive tests +2. `test/schema-injection-cell-for.test.ts` - Cell.for() comprehensive tests +3. `test/schema-injection-wish.test.ts` - wish() comprehensive tests +4. `test/schema-injection-generate-object.test.ts` - generateObject() comprehensive tests +5. `test/schema-injection-literal-widening.test.ts` - Literal widening edge cases +6. `test/schema-injection-integration.test.ts` - Integration and combination tests +7. `test/schema-injection-negative.test.ts` - Negative cases and error handling + +## Priority Levels + +**P0 (Must Have):** Marked with [x] in matrix - basic happy path already covered +**P1 (High Priority):** Unmarked items in sections 1-4 (core functionality) +**P2 (Medium Priority):** Sections 5-7 (integration, edge cases) +**P3 (Nice to Have):** Sections 8-10 (advanced features, performance) + +## Test Implementation Strategy + +1. Start with P1 tests for each function type +2. Add fixture-based tests for visual regression +3. Add unit tests for specific edge cases +4. Consider property-based testing for type system interactions diff --git a/packages/ts-transformers/src/transformers/schema-generator.ts b/packages/ts-transformers/src/transformers/schema-generator.ts index d5224125b2..13b9a2239a 100644 --- a/packages/ts-transformers/src/transformers/schema-generator.ts +++ b/packages/ts-transformers/src/transformers/schema-generator.ts @@ -46,10 +46,21 @@ export class SchemaGeneratorTransformer extends Transformer { const arg0 = node.arguments[0]; let optionsObj: Record = {}; + let widenLiterals: boolean | undefined; if (arg0 && ts.isObjectLiteralExpression(arg0)) { optionsObj = evaluateObjectLiteral(arg0, checker); + // Extract widenLiterals as a generation option (don't merge into schema) + if (typeof optionsObj.widenLiterals === "boolean") { + widenLiterals = optionsObj.widenLiterals; + delete optionsObj.widenLiterals; + } } + // Build options for schema generation + const generationOptions = widenLiterals !== undefined + ? { widenLiterals } + : undefined; + // If Type resolved to 'any' and we have a synthetic TypeNode, use new method let schema: unknown; if ( @@ -65,7 +76,12 @@ export class SchemaGeneratorTransformer extends Transformer { ); } else { // Normal Type path - schema = schemaTransformer!.generateSchema(type, checker, typeArg); + schema = schemaTransformer!.generateSchema( + type, + checker, + typeArg, + generationOptions, + ); } // Handle boolean schemas (true/false) - can't spread them diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts index 5766c057c2..901276ed34 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -161,12 +161,27 @@ function createToSchemaCall( "ctHelpers" | "factory" >, typeNode: ts.TypeNode, + options?: { widenLiterals?: boolean }, ): ts.CallExpression { const expr = ctHelpers.getHelperExpr("toSchema"); + + // Build arguments array if options are provided + const args: ts.Expression[] = []; + if (options?.widenLiterals) { + args.push( + factory.createObjectLiteralExpression([ + factory.createPropertyAssignment( + "widenLiterals", + factory.createTrue(), + ), + ]), + ); + } + return factory.createCallExpression( expr, [typeNode], - [], + args, ); } @@ -178,14 +193,16 @@ function createToSchemaCall( * @param context - Transformation context * @param typeNode - The TypeNode to create a schema for * @param typeRegistry - Optional TypeRegistry to check for existing types + * @param options - Optional schema generation options * @returns CallExpression for toSchema() with TypeRegistry entry transferred */ function createSchemaCallWithRegistryTransfer( context: Pick, typeNode: ts.TypeNode, typeRegistry?: TypeRegistry, + options?: { widenLiterals?: boolean }, ): ts.CallExpression { - const schemaCall = createToSchemaCall(context, typeNode); + const schemaCall = createToSchemaCall(context, typeNode, options); // Transfer TypeRegistry entry from source typeNode to schema call // This preserves type information for closure-captured variables @@ -990,14 +1007,17 @@ export class SchemaInjectionTransformer extends Transformer { let typeNode: ts.TypeNode | undefined; let type: ts.Type | undefined; + // Track whether we're using value inference (vs explicit type arg) + const isValueInference = !typeArgs || typeArgs.length === 0; + if (typeArgs && typeArgs.length > 0) { - // Use explicit type argument + // Use explicit type argument - preserve literal types typeNode = typeArgs[0]; if (typeNode && typeRegistry) { type = typeRegistry.get(typeNode); } } else if (args.length > 0) { - // Infer from value argument + // Infer from value argument - widen literal types const valueArg = args[0]; if (valueArg) { const valueType = checker.getTypeAtLocation(valueArg); @@ -1014,10 +1034,11 @@ export class SchemaInjectionTransformer extends Transformer { context, typeNode, typeRegistry, + isValueInference ? { widenLiterals: true } : undefined, ); // If we inferred the type (no explicit type arg), register it - if ((!typeArgs || typeArgs.length === 0) && type && typeRegistry) { + if (isValueInference && type && typeRegistry) { typeRegistry.set(schemaCall, type); } diff --git a/packages/ts-transformers/test/fixture-based.test.ts b/packages/ts-transformers/test/fixture-based.test.ts index f67cb9d98a..b34528eda2 100644 --- a/packages/ts-transformers/test/fixture-based.test.ts +++ b/packages/ts-transformers/test/fixture-based.test.ts @@ -58,6 +58,11 @@ const configs: FixtureConfig[] = [ { pattern: /^lift-/, name: "Generic closures" }, ], }, + { + directory: "schema-injection", + describe: "Schema Injection with Literal Widening", + formatTestName: (name) => `widens ${name.replace(/^literal-widen-/, "").replace(/-/g, " ")}`, + }, ]; const staticCache = new StaticCacheFS(); diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx new file mode 100644 index 0000000000..effbd6df64 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx @@ -0,0 +1,27 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenArrayElements() { + const arr1 = cell([1, 2, 3], { + type: "array", + items: { + type: "number" + } + } as const satisfies __ctHelpers.JSONSchema); + const arr2 = cell(["a", "b", "c"], { + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema); + const arr3 = cell([true, false], { + type: "array", + items: { + type: "boolean" + } + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx new file mode 100644 index 0000000000..8aac989b39 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx @@ -0,0 +1,10 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenArrayElements() { + const arr1 = cell([1, 2, 3]); + const arr2 = cell(["a", "b", "c"]); + const arr3 = cell([true, false]); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx new file mode 100644 index 0000000000..1374902313 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx @@ -0,0 +1,18 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenBigInt() { + const bi1 = cell(123n, { + type: "integer" + } as const satisfies __ctHelpers.JSONSchema); + const bi2 = cell(0n, { + type: "integer" + } as const satisfies __ctHelpers.JSONSchema); + const bi3 = cell(-456n, { + type: "integer" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx new file mode 100644 index 0000000000..b06da1a093 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx @@ -0,0 +1,10 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenBigInt() { + const bi1 = cell(123n); + const bi2 = cell(0n); + const bi3 = cell(-456n); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx new file mode 100644 index 0000000000..ee595fd19f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx @@ -0,0 +1,15 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenBoolean() { + const b1 = cell(true, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); + const b2 = cell(false, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx new file mode 100644 index 0000000000..5657d6038a --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx @@ -0,0 +1,9 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenBoolean() { + const b1 = cell(true); + const b2 = cell(false); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx new file mode 100644 index 0000000000..d6a8744b63 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx @@ -0,0 +1,18 @@ +import * as __ctHelpers from "commontools"; +import { Cell } from "commontools"; +export default function TestLiteralWidenExplicitTypeArgs() { + const c1 = Cell.of(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c2 = Cell.of("hello", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + const c3 = Cell.of(true, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx new file mode 100644 index 0000000000..bf2ed7bc6a --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx @@ -0,0 +1,10 @@ +/// +import { Cell } from "commontools"; + +export default function TestLiteralWidenExplicitTypeArgs() { + const c1 = Cell.of(10); + const c2 = Cell.of("hello"); + const c3 = Cell.of(true); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx new file mode 100644 index 0000000000..f41d2cf091 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx @@ -0,0 +1,19 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenMixedValues() { + const variable = 42; + const c1 = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c2 = cell(variable, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const c3 = cell(10 + 20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx new file mode 100644 index 0000000000..1c4ef8d889 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx @@ -0,0 +1,11 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenMixedValues() { + const variable = 42; + const c1 = cell(10); + const c2 = cell(variable); + const c3 = cell(10 + 20); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx new file mode 100644 index 0000000000..b7f806ff59 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx @@ -0,0 +1,42 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenNestedStructure() { + const nested = cell({ + users: [ + { id: 1, name: "Alice", active: true }, + { id: 2, name: "Bob", active: false } + ], + count: 2 + }, { + type: "object", + properties: { + users: { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "number" + }, + name: { + type: "string" + }, + active: { + type: "boolean" + } + }, + required: ["id", "name", "active"] + } + }, + count: { + type: "number" + } + }, + required: ["users", "count"] + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx new file mode 100644 index 0000000000..2d00eaa666 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx @@ -0,0 +1,14 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenNestedStructure() { + const nested = cell({ + users: [ + { id: 1, name: "Alice", active: true }, + { id: 2, name: "Bob", active: false } + ], + count: 2 + }); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx new file mode 100644 index 0000000000..d3332cb8d7 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx @@ -0,0 +1,13 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenNullUndefined() { + const c1 = cell(null, { + type: "null" + } as const satisfies __ctHelpers.JSONSchema); + const c2 = cell(undefined, true as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx new file mode 100644 index 0000000000..f93e0d13a2 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx @@ -0,0 +1,9 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenNullUndefined() { + const c1 = cell(null); + const c2 = cell(undefined); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx new file mode 100644 index 0000000000..877cecba6b --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx @@ -0,0 +1,24 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenNumber() { + const n1 = cell(10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const n2 = cell(-5, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const n3 = cell(3.14, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const n4 = cell(1e10, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const n5 = cell(0, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx new file mode 100644 index 0000000000..622a13bcde --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx @@ -0,0 +1,12 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenNumber() { + const n1 = cell(10); + const n2 = cell(-5); + const n3 = cell(3.14); + const n4 = cell(1e10); + const n5 = cell(0); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx new file mode 100644 index 0000000000..327b73726f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx @@ -0,0 +1,24 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenObjectProperties() { + const obj = cell({ x: 10, y: 20, name: "point" }, { + type: "object", + properties: { + x: { + type: "number" + }, + y: { + type: "number" + }, + name: { + type: "string" + } + }, + required: ["x", "y", "name"] + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx new file mode 100644 index 0000000000..ebeb97d2bf --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx @@ -0,0 +1,8 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenObjectProperties() { + const obj = cell({ x: 10, y: 20, name: "point" }); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx new file mode 100644 index 0000000000..87f2987136 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx @@ -0,0 +1,21 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestLiteralWidenString() { + const s1 = cell("hello", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + const s2 = cell("", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + const s3 = cell("hello\nworld", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + const s4 = cell("with spaces", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx new file mode 100644 index 0000000000..9de2f8c7d5 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx @@ -0,0 +1,11 @@ +/// +import { cell } from "commontools"; + +export default function TestLiteralWidenString() { + const s1 = cell("hello"); + const s2 = cell(""); + const s3 = cell("hello\nworld"); + const s4 = cell("with spaces"); + + return null; +} From f7ce27f1a70ab66bd05f62e6293f2c41a411eef0 Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Fri, 21 Nov 2025 23:21:51 -0800 Subject: [PATCH 06/10] fmt, lint --- .../src/formatters/union-formatter.ts | 8 +- .../schema-generator/src/schema-generator.ts | 8 +- .../ts-transformers/SCHEMA_INJECTION_NOTES.md | 68 ++++++--- .../TEST_PLAN_schema_injection.md | 135 +++++++++++++----- .../test/fixture-based.test.ts | 3 +- .../literal-widen-array-elements.input.tsx | 6 +- .../literal-widen-bigint.input.tsx | 6 +- .../literal-widen-boolean.input.tsx | 4 +- ...literal-widen-explicit-type-args.input.tsx | 6 +- .../literal-widen-mixed-values.input.tsx | 6 +- .../literal-widen-nested-structure.input.tsx | 2 +- .../literal-widen-null-undefined.input.tsx | 4 +- .../literal-widen-number.input.tsx | 10 +- .../literal-widen-object-properties.input.tsx | 2 +- .../literal-widen-string.input.tsx | 8 +- 15 files changed, 194 insertions(+), 82 deletions(-) diff --git a/packages/schema-generator/src/formatters/union-formatter.ts b/packages/schema-generator/src/formatters/union-formatter.ts index 7a91a21379..dad2e8f384 100644 --- a/packages/schema-generator/src/formatters/union-formatter.ts +++ b/packages/schema-generator/src/formatters/union-formatter.ts @@ -177,7 +177,9 @@ export class UnionFormatter implements TypeFormatter { if ("properties" in schema && isRecord(schema.properties)) { const props: Record = {}; for (const [key, value] of Object.entries(schema.properties)) { - props[key] = this.normalizeSchemaForComparison(value as SchemaDefinition); + props[key] = this.normalizeSchemaForComparison( + value as SchemaDefinition, + ); } result.properties = props; } @@ -231,7 +233,9 @@ export class UnionFormatter implements TypeFormatter { for (const key of Object.keys(first.properties)) { const propSchemas = schemas .map((s) => - isRecord(s) && isRecord(s.properties) ? s.properties[key] : undefined + isRecord(s) && isRecord(s.properties) + ? s.properties[key] + : undefined ) .filter((p): p is SchemaDefinition => p !== undefined); diff --git a/packages/schema-generator/src/schema-generator.ts b/packages/schema-generator/src/schema-generator.ts index e31afa3808..240a508c4b 100644 --- a/packages/schema-generator/src/schema-generator.ts +++ b/packages/schema-generator/src/schema-generator.ts @@ -46,7 +46,13 @@ export class SchemaGenerator implements ISchemaGenerator { typeNode?: ts.TypeNode, options?: { widenLiterals?: boolean }, ): SchemaDefinition { - return this.generateSchemaInternal(type, checker, typeNode, undefined, options); + return this.generateSchemaInternal( + type, + checker, + typeNode, + undefined, + options, + ); } /** diff --git a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md index f45ef3ef92..443f06589c 100644 --- a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md +++ b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md @@ -1,14 +1,17 @@ # Schema Injection - Implementation Notes & Known Issues -This document captures design decisions and concerns about the schema injection implementation, particularly around literal type widening. +This document captures design decisions and concerns about the schema injection +implementation, particularly around literal type widening. ## Known Issues & Concerns ### 1. Recursive Schema Merging Feels Brittle -**Location**: `packages/schema-generator/src/formatters/union-formatter.ts` (lines 115-262) +**Location**: `packages/schema-generator/src/formatters/union-formatter.ts` +(lines 115-262) -**Problem**: When `widenLiterals: true`, we need to merge structurally identical schemas that differ only in literal enum values. For example: +**Problem**: When `widenLiterals: true`, we need to merge structurally identical +schemas that differ only in literal enum values. For example: ```typescript // TypeScript infers this as a union of two object types: @@ -28,22 +31,34 @@ This document captures design decisions and concerns about the schema injection ``` **Current Implementation**: -- `mergeIdenticalSchemas()` - Groups schemas by normalized structure (ignoring enum values) -- `normalizeSchemaForComparison()` - Converts schemas to comparable form (enum → base type) -- `mergeSchemaGroup()` - Recursively merges schema groups by widening enums to base types + +- `mergeIdenticalSchemas()` - Groups schemas by normalized structure (ignoring + enum values) +- `normalizeSchemaForComparison()` - Converts schemas to comparable form (enum → + base type) +- `mergeSchemaGroup()` - Recursively merges schema groups by widening enums to + base types **Concerns**: -- **Brittle**: Relies on JSON.stringify() for structural comparison, which is fragile + +- **Brittle**: Relies on JSON.stringify() for structural comparison, which is + fragile - **Hacky**: Recursively walks and rebuilds schema objects to merge them -- **Incomplete**: Only handles properties, items, required, additionalProperties - might miss edge cases +- **Incomplete**: Only handles properties, items, required, + additionalProperties - might miss edge cases - **Performance**: Creates many intermediate objects and does O(n²) comparisons **Why This Approach**: -- TypeScript's type inference creates union types for array literals with different property values -- The top-level `widenLiteralType()` only widens the immediate type, not nested unions -- We need schema-level merging because the Type is already a union by the time it reaches the formatter + +- TypeScript's type inference creates union types for array literals with + different property values +- The top-level `widenLiteralType()` only widens the immediate type, not nested + unions +- We need schema-level merging because the Type is already a union by the time + it reaches the formatter **Possible Future Improvements**: + 1. Widen the Type earlier in the pipeline (before schema generation) 2. Use a more robust structural equality check (not JSON.stringify) 3. Define a formal schema normalization/merging algorithm @@ -53,11 +68,13 @@ This document captures design decisions and concerns about the schema injection ### 2. Undefined Schema Choice Is Uncertain -**Location**: `packages/schema-generator/src/formatters/primitive-formatter.ts` (lines 90-94) +**Location**: `packages/schema-generator/src/formatters/primitive-formatter.ts` +(lines 90-94) **Problem**: What JSON Schema should we generate for `cell(undefined)`? -**Current Behavior**: Returns `true` (JSON Schema boolean meaning "accept any value") +**Current Behavior**: Returns `true` (JSON Schema boolean meaning "accept any +value") ```typescript const c = cell(undefined); @@ -65,6 +82,7 @@ const c = cell(undefined); ``` **Why `true` Is Questionable**: + - `undefined` is not a valid JSON value (serializes to `null` or is omitted) - `true` means "no validation" - everything passes - Doesn't reflect what actually happens at runtime @@ -88,11 +106,13 @@ const c = cell(undefined); - Con: Still provides no validation **Decision**: Use `true` for now + - Rationale: Permissive approach won't cause runtime rejection issues - Trade-off: No validation benefit, but avoids breaking things - Future: May need to revisit based on real-world usage patterns -**Related**: TypeScript itself has ongoing confusion about `null` vs `undefined` semantics in JSON contexts. Our schema generation reflects this ambiguity. +**Related**: TypeScript itself has ongoing confusion about `null` vs `undefined` +semantics in JSON contexts. Our schema generation reflects this ambiguity. --- @@ -101,10 +121,12 @@ const c = cell(undefined); ### Literal Widening Strategy **When widening happens**: + - ✅ **Value inference path**: `cell(10)` → widens 10 to number - ❌ **Explicit type args path**: `cell<10>(10)` → preserves literal type 10 **How widening is triggered**: + 1. `schema-injection.ts` detects value inference (no explicit type arg) 2. Passes `{ widenLiterals: true }` option through `toSchema()` call 3. `schema-generator.ts` extracts option and passes to schema generator @@ -112,6 +134,7 @@ const c = cell(undefined); 5. Formatters check flag and widen literals when set **Affected types**: + - Number literals: `10` → `number` - String literals: `"hello"` → `string` - Boolean literals: `true`/`false` → `boolean` @@ -123,6 +146,7 @@ const c = cell(undefined); ## Test Coverage All literal widening scenarios tested in `test/fixtures/schema-injection/`: + - ✅ `literal-widen-number.input.tsx` - ✅ `literal-widen-string.input.tsx` - ✅ `literal-widen-boolean.input.tsx` @@ -130,29 +154,35 @@ All literal widening scenarios tested in `test/fixtures/schema-injection/`: - ✅ `literal-widen-array-elements.input.tsx` - ✅ `literal-widen-object-properties.input.tsx` - ✅ `literal-widen-nested-structure.input.tsx` (tests recursive merging) -- ✅ `literal-widen-explicit-type-args.input.tsx` (ensures literals preserved when explicit) +- ✅ `literal-widen-explicit-type-args.input.tsx` (ensures literals preserved + when explicit) - ✅ `literal-widen-mixed-values.input.tsx` -- ✅ `literal-widen-null-undefined.input.tsx` (documents undefined → `true` behavior) +- ✅ `literal-widen-null-undefined.input.tsx` (documents undefined → `true` + behavior) --- ## Future Work ### Priority: High + - [ ] Revisit undefined schema choice based on runtime behavior patterns - [ ] Add error handling for schema merging edge cases ### Priority: Medium + - [ ] Replace JSON.stringify comparison with proper structural equality - [ ] Optimize schema merging performance (reduce intermediate objects) -- [ ] Add comprehensive edge case tests (deeply nested unions, mixed union types) +- [ ] Add comprehensive edge case tests (deeply nested unions, mixed union + types) ### Priority: Low + - [ ] Consider widening at Type level instead of Schema level - [ ] Explore alternative union merging strategies - [ ] Document schema generation algorithm formally --- -**Last Updated**: 2025-01-21 -**Implementation**: PR #XXXX (schema injection with literal widening) +**Last Updated**: 2025-01-21 **Implementation**: PR #XXXX (schema injection with +literal widening) diff --git a/packages/ts-transformers/TEST_PLAN_schema_injection.md b/packages/ts-transformers/TEST_PLAN_schema_injection.md index a0a0c8aaf4..5f47571eed 100644 --- a/packages/ts-transformers/TEST_PLAN_schema_injection.md +++ b/packages/ts-transformers/TEST_PLAN_schema_injection.md @@ -3,8 +3,11 @@ ## Implementation Summary This branch adds automatic schema injection for: -1. **Cell factory methods**: `cell()`, `Cell.of()`, `OpaqueCell.of()`, `Stream.of()`, etc. -2. **Cell.for() methods**: `Cell.for()`, `OpaqueCell.for()`, etc. (wrapped with `.asSchema()`) + +1. **Cell factory methods**: `cell()`, `Cell.of()`, `OpaqueCell.of()`, + `Stream.of()`, etc. +2. **Cell.for() methods**: `Cell.for()`, `OpaqueCell.for()`, etc. (wrapped with + `.asSchema()`) 3. **wish()**: Schema passed as second argument 4. **generateObject()**: Schema added to options object 5. **Literal type widening**: Number/string/boolean literals → base types @@ -16,6 +19,7 @@ This branch adds automatic schema injection for: #### A. Type Argument Variations **Happy Path:** + - [x] Explicit type arg with matching literal: `Cell.of("hello")` - [ ] Explicit type arg with variable: `Cell.of(myVar)` - [ ] Explicit type arg with expression: `Cell.of(10 + 20)` @@ -27,13 +31,16 @@ This branch adds automatic schema injection for: - [ ] No type arg, infer from function call result: `cell(getValue())` **Edge Cases:** -- [ ] Type arg doesn't match value type: `Cell.of(123)` (should use type arg) + +- [ ] Type arg doesn't match value type: `Cell.of(123)` (should use type + arg) - [ ] Complex generic type: `Cell.of>(items)` - [ ] Type with multiple generic params: `Cell.of>(map)` #### B. All Cell-like Classes **Coverage:** + - [x] `Cell.of()` - [x] `OpaqueCell.of()` - [x] `Stream.of()` @@ -45,6 +52,7 @@ This branch adds automatic schema injection for: #### C. Value Type Variations **Primitives:** + - [x] Number literal: `cell(42)` - [x] String literal: `cell("test")` - [x] Boolean literal: `cell(true)` and `cell(false)` @@ -53,6 +61,7 @@ This branch adds automatic schema injection for: - [ ] Undefined: `cell(undefined)` **Collections:** + - [ ] Empty array: `cell([])` - [ ] Array of primitives: `cell([1, 2, 3])` - [ ] Array of objects: `cell([{id: 1}, {id: 2}])` @@ -64,6 +73,7 @@ This branch adds automatic schema injection for: - [ ] Array of mixed types: `cell([1, "two", true])` **Complex Types:** + - [ ] Union type explicit: `cell(42)` - [ ] Union type inferred: `const val: number | string = getValue(); cell(val)` - [ ] Optional type: `cell("hello")` @@ -77,37 +87,50 @@ This branch adds automatic schema injection for: #### D. Literal Widening Verification **Number Literals:** -- [ ] Single literal: `cell(10)` → `{ type: "number" }` not `{ type: "number", enum: [10] }` + +- [ ] Single literal: `cell(10)` → `{ type: "number" }` not + `{ type: "number", enum: [10] }` - [ ] Negative: `cell(-5)` → `{ type: "number" }` - [ ] Float: `cell(3.14)` → `{ type: "number" }` - [ ] Scientific notation: `cell(1e10)` → `{ type: "number" }` **String Literals:** + - [ ] Simple string: `cell("hello")` → `{ type: "string" }` not enum - [ ] Empty string: `cell("")` → `{ type: "string" }` - [ ] String with escapes: `cell("hello\nworld")` → `{ type: "string" }` **Boolean Literals:** -- [ ] True: `cell(true)` → `{ type: "boolean" }` not `{ type: "boolean", enum: [true] }` + +- [ ] True: `cell(true)` → `{ type: "boolean" }` not + `{ type: "boolean", enum: [true] }` - [ ] False: `cell(false)` → `{ type: "boolean" }` not enum **Array Element Widening:** -- [ ] Array of number literals: `cell([1, 2, 3])` → items should be `{ type: "number" }` -- [ ] Array of string literals: `cell(["a", "b"])` → items should be `{ type: "string" }` + +- [ ] Array of number literals: `cell([1, 2, 3])` → items should be + `{ type: "number" }` +- [ ] Array of string literals: `cell(["a", "b"])` → items should be + `{ type: "string" }` **Object Property Widening:** -- [ ] Object with literal properties: `cell({ x: 10, y: 20 })` → properties should be widened + +- [ ] Object with literal properties: `cell({ x: 10, y: 20 })` → properties + should be widened #### E. Double-Injection Prevention **Should NOT transform:** + - [ ] Already has 2 arguments: `cell(10, existingSchema)` → leave unchanged -- [ ] Already has schema in wrong position: `cell(schema, 10)` → should still not transform +- [ ] Already has schema in wrong position: `cell(schema, 10)` → should still + not transform - [ ] Has more than 2 arguments: `cell(10, schema, extra)` → leave unchanged #### F. Context Variations **Different scopes:** + - [ ] Top-level: `const c = cell(10);` - [ ] Inside function: `function f() { const c = cell(10); }` - [ ] Inside arrow function: `const f = () => { const c = cell(10); }` @@ -122,18 +145,25 @@ This branch adds automatic schema injection for: #### A. Type Argument Variations **Happy Path:** + - [x] Explicit type arg: `Cell.for("cause")` -- [x] No type arg, infer from variable annotation: `const c: Cell = Cell.for("cause")` -- [ ] No type arg, infer from parameter: `function f(c: Cell = Cell.for("cause")) {}` -- [ ] No type arg, infer from return type: `function f(): Cell { return Cell.for("cause"); }` +- [x] No type arg, infer from variable annotation: + `const c: Cell = Cell.for("cause")` +- [ ] No type arg, infer from parameter: + `function f(c: Cell = Cell.for("cause")) {}` +- [ ] No type arg, infer from return type: + `function f(): Cell { return Cell.for("cause"); }` **Edge Cases:** -- [ ] Type inference failure (no contextual type): `const c = Cell.for("cause")` → what happens? + +- [ ] Type inference failure (no contextual type): `const c = Cell.for("cause")` + → what happens? - [ ] Complex generic type: `const c: Cell> = Cell.for("cause")` #### B. All Cell-like Classes **Coverage:** + - [x] `Cell.for()` - [ ] `OpaqueCell.for()` - [ ] `Stream.for()` @@ -144,12 +174,16 @@ This branch adds automatic schema injection for: #### C. Wrapping Verification **Format:** -- [ ] Verify output is `.asSchema()` method call: `Cell.for("x").asSchema(schema)` -- [ ] Verify original arguments preserved: `Cell.for("cause", arg2).asSchema(schema)` + +- [ ] Verify output is `.asSchema()` method call: + `Cell.for("x").asSchema(schema)` +- [ ] Verify original arguments preserved: + `Cell.for("cause", arg2).asSchema(schema)` #### D. Double-Wrapping Prevention **Should NOT transform:** + - [ ] Already wrapped: `Cell.for("cause").asSchema(schema)` → leave unchanged - [ ] Parent is property access to asSchema: verify detection works @@ -160,13 +194,18 @@ This branch adds automatic schema injection for: #### A. Type Argument Variations **Happy Path:** + - [x] Explicit type arg: `wish("query")` -- [x] No type arg, infer from variable annotation: `const w: string = wish("query")` -- [ ] No type arg, infer from parameter: `function f(w: number = wish("query")) {}` -- [ ] No type arg, infer from return type: `function f(): string { return wish("query"); }` +- [x] No type arg, infer from variable annotation: + `const w: string = wish("query")` +- [ ] No type arg, infer from parameter: + `function f(w: number = wish("query")) {}` +- [ ] No type arg, infer from return type: + `function f(): string { return wish("query"); }` - [ ] Infer from WishResult wrapper: `const w: WishResult = wish("query")` **Edge Cases:** + - [ ] Generic type: `wish>("query")` - [ ] Union type: `wish("query")` - [ ] Complex nested type: `wish<{ users: User[], total: number }>("query")` @@ -174,6 +213,7 @@ This branch adds automatic schema injection for: #### B. Query Argument Variations **Different query formats:** + - [ ] String literal: `wish("simple query")` - [ ] Template literal: `wish(\`query with \${var}\`)` - [ ] Variable: `const q = "query"; wish(q)` @@ -182,8 +222,10 @@ This branch adds automatic schema injection for: #### C. Double-Injection Prevention **Should NOT transform:** + - [ ] Already has 2 arguments: `wish("query", schema)` → leave unchanged -- [ ] Has more than 2 arguments: `wish("query", schema, extra)` → leave unchanged +- [ ] Has more than 2 arguments: `wish("query", schema, extra)` → leave + unchanged --- @@ -192,18 +234,23 @@ This branch adds automatic schema injection for: #### A. Type Argument Variations **Happy Path:** -- [x] Explicit type arg with options: `generateObject({ model: "gpt-4" })` -- [x] No type arg, infer from variable annotation: `const g: { object: number } = generateObject({ model: "gpt-4" })` + +- [x] Explicit type arg with options: + `generateObject({ model: "gpt-4" })` +- [x] No type arg, infer from variable annotation: + `const g: { object: number } = generateObject({ model: "gpt-4" })` - [ ] No type arg, infer from return type - [ ] No type arg, infer from parameter type **Edge Cases:** + - [ ] Generic type: `generateObject>(...)` - [ ] Complex nested type: `generateObject<{ users: User[] }>(...)` #### B. Options Argument Variations **Options formats:** + - [x] Object literal: `generateObject({ model: "gpt-4" })` - [ ] Empty object: `generateObject({})` - [ ] No options: `generateObject()` @@ -212,14 +259,18 @@ This branch adds automatic schema injection for: - [ ] Expression: `generateObject(getOptions())` **Schema insertion:** + - [ ] Empty object → add schema: `{}` → `{ schema: ... }` -- [ ] Existing properties → add schema: `{ model: "x" }` → `{ model: "x", schema: ... }` +- [ ] Existing properties → add schema: `{ model: "x" }` → + `{ model: "x", schema: ... }` - [ ] Non-literal options → spread: `opts` → `{ ...opts, schema: ... }` #### C. Double-Injection Prevention **Should NOT transform:** -- [x] Already has schema in options: `generateObject({ model: "x", schema: existingSchema })` + +- [x] Already has schema in options: + `generateObject({ model: "x", schema: existingSchema })` - [ ] Schema with different name (schemaDefinition, etc.) → should still inject - [ ] Schema as computed property: `{ ["schema"]: existing }` → what happens? @@ -230,6 +281,7 @@ This branch adds automatic schema injection for: #### A. Multiple Functions Together **Combinations:** + - [ ] Multiple cells in one scope: `const a = cell(1); const b = cell(2);` - [ ] cell() + Cell.for(): Both get schemas correctly - [ ] wish() + cell(): Both transformed @@ -239,6 +291,7 @@ This branch adds automatic schema injection for: #### B. Nested in CommonTools Functions **Contexts:** + - [ ] cell() inside recipe: `recipe(() => { const c = cell(10); })` - [ ] cell() inside pattern: `pattern(() => { const c = cell(10); })` - [ ] cell() inside handler: `handler(() => { const c = cell(10); })` @@ -248,8 +301,10 @@ This branch adds automatic schema injection for: #### C. Closure Capture Interaction **Verify no conflicts:** + - [ ] cell() in closure that's captured: Does schema injection work? -- [ ] cell() capturing another cell: `const a = cell(1); const b = derive(a, () => cell(2))` +- [ ] cell() capturing another cell: + `const a = cell(1); const b = derive(a, () => cell(2))` --- @@ -258,6 +313,7 @@ This branch adds automatic schema injection for: #### A. Missing Type Information **Cases:** + - [ ] Type is `any`: `cell(value)` → skip? - [ ] Type is `unknown`: `cell(value)` → skip? - [ ] Type is `never`: `cell(value)` → skip? @@ -266,6 +322,7 @@ This branch adds automatic schema injection for: #### B. Already Has Schema **All formats:** + - [ ] `cell(value, schema)` → leave unchanged - [ ] `Cell.for("x").asSchema(schema)` → leave unchanged - [ ] `wish("query", schema)` → leave unchanged @@ -274,6 +331,7 @@ This branch adds automatic schema injection for: #### C. Non-CommonTools Functions **Should ignore:** + - [ ] Other library's `cell()`: `import { cell } from "other-lib";` - [ ] User-defined cell(): `function cell(x) { return x; }` - [ ] Similarly for wish, generateObject @@ -285,6 +343,7 @@ This branch adds automatic schema injection for: #### A. Advanced TypeScript Features **Complex types:** + - [ ] Conditional types: `cell(value)` - [ ] Mapped types: `cell<{ [K in keyof T]: T[K] }>(value)` - [ ] Template literal types: `cell<\`prefix_\${string}\`>(value)` @@ -295,13 +354,16 @@ This branch adds automatic schema injection for: #### B. Generic Type Parameters **In generic functions:** + - [ ] `function f(val: T) { return cell(val); }` → how is T handled? - [ ] `function f() { return cell(defaultValue); }` -- [ ] Constrained generics: `function f(val: T) { cell(val); }` +- [ ] Constrained generics: + `function f(val: T) { cell(val); }` #### C. Type Aliases and Interfaces **Indirection:** + - [ ] Type alias: `type X = number; cell(10)` - [ ] Interface: `interface I { x: number }; cell({ x: 10 })` - [ ] Nested type alias: `type X = Y; type Y = number; cell(10)` @@ -313,6 +375,7 @@ This branch adds automatic schema injection for: #### A. Schema Shape Correctness **Verify schemas match JSON Schema spec:** + - [ ] Primitives have correct `type` field - [ ] Objects have `properties` and `required` - [ ] Arrays have `items` @@ -322,6 +385,7 @@ This branch adds automatic schema injection for: #### B. Complex Schema Structures **Advanced schemas:** + - [ ] Recursive types: `type Tree = { value: number, children: Tree[] }` - [ ] Self-referential interfaces - [ ] Mutually recursive types @@ -334,6 +398,7 @@ This branch adds automatic schema injection for: #### A. Malformed Code **Should handle gracefully:** + - [ ] Syntax errors in surrounding code - [ ] Incomplete type information - [ ] Circular type references @@ -341,6 +406,7 @@ This branch adds automatic schema injection for: #### B. Performance **Large codebases:** + - [ ] File with 100+ cell() calls - [ ] Very large type definitions (1000+ properties) - [ ] Deeply nested generic types @@ -352,6 +418,7 @@ This branch adds automatic schema injection for: #### A. Formatting and Whitespace **Verify:** + - [ ] Original formatting preserved where possible - [ ] Line numbers stay consistent for error reporting - [ ] Comments preserved @@ -363,20 +430,24 @@ This branch adds automatic schema injection for: Suggested new test files: -1. `test/schema-injection-cell-factory.test.ts` - Cell/cell() comprehensive tests +1. `test/schema-injection-cell-factory.test.ts` - Cell/cell() comprehensive + tests 2. `test/schema-injection-cell-for.test.ts` - Cell.for() comprehensive tests 3. `test/schema-injection-wish.test.ts` - wish() comprehensive tests -4. `test/schema-injection-generate-object.test.ts` - generateObject() comprehensive tests -5. `test/schema-injection-literal-widening.test.ts` - Literal widening edge cases -6. `test/schema-injection-integration.test.ts` - Integration and combination tests +4. `test/schema-injection-generate-object.test.ts` - generateObject() + comprehensive tests +5. `test/schema-injection-literal-widening.test.ts` - Literal widening edge + cases +6. `test/schema-injection-integration.test.ts` - Integration and combination + tests 7. `test/schema-injection-negative.test.ts` - Negative cases and error handling ## Priority Levels **P0 (Must Have):** Marked with [x] in matrix - basic happy path already covered -**P1 (High Priority):** Unmarked items in sections 1-4 (core functionality) -**P2 (Medium Priority):** Sections 5-7 (integration, edge cases) -**P3 (Nice to Have):** Sections 8-10 (advanced features, performance) +**P1 (High Priority):** Unmarked items in sections 1-4 (core functionality) **P2 +(Medium Priority):** Sections 5-7 (integration, edge cases) **P3 (Nice to +Have):** Sections 8-10 (advanced features, performance) ## Test Implementation Strategy diff --git a/packages/ts-transformers/test/fixture-based.test.ts b/packages/ts-transformers/test/fixture-based.test.ts index b34528eda2..84f41abb59 100644 --- a/packages/ts-transformers/test/fixture-based.test.ts +++ b/packages/ts-transformers/test/fixture-based.test.ts @@ -61,7 +61,8 @@ const configs: FixtureConfig[] = [ { directory: "schema-injection", describe: "Schema Injection with Literal Widening", - formatTestName: (name) => `widens ${name.replace(/^literal-widen-/, "").replace(/-/g, " ")}`, + formatTestName: (name) => + `widens ${name.replace(/^literal-widen-/, "").replace(/-/g, " ")}`, }, ]; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx index 8aac989b39..9b9b6d5aeb 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.input.tsx @@ -2,9 +2,9 @@ import { cell } from "commontools"; export default function TestLiteralWidenArrayElements() { - const arr1 = cell([1, 2, 3]); - const arr2 = cell(["a", "b", "c"]); - const arr3 = cell([true, false]); + const _arr1 = cell([1, 2, 3]); + const _arr2 = cell(["a", "b", "c"]); + const _arr3 = cell([true, false]); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx index b06da1a093..e87f481c14 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.input.tsx @@ -2,9 +2,9 @@ import { cell } from "commontools"; export default function TestLiteralWidenBigInt() { - const bi1 = cell(123n); - const bi2 = cell(0n); - const bi3 = cell(-456n); + const _bi1 = cell(123n); + const _bi2 = cell(0n); + const _bi3 = cell(-456n); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx index 5657d6038a..659a726379 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.input.tsx @@ -2,8 +2,8 @@ import { cell } from "commontools"; export default function TestLiteralWidenBoolean() { - const b1 = cell(true); - const b2 = cell(false); + const _b1 = cell(true); + const _b2 = cell(false); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx index bf2ed7bc6a..c1e33d01e1 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.input.tsx @@ -2,9 +2,9 @@ import { Cell } from "commontools"; export default function TestLiteralWidenExplicitTypeArgs() { - const c1 = Cell.of(10); - const c2 = Cell.of("hello"); - const c3 = Cell.of(true); + const _c1 = Cell.of(10); + const _c2 = Cell.of("hello"); + const _c3 = Cell.of(true); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx index 1c4ef8d889..d13cae7092 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.input.tsx @@ -3,9 +3,9 @@ import { cell } from "commontools"; export default function TestLiteralWidenMixedValues() { const variable = 42; - const c1 = cell(10); - const c2 = cell(variable); - const c3 = cell(10 + 20); + const _c1 = cell(10); + const _c2 = cell(variable); + const _c3 = cell(10 + 20); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx index 2d00eaa666..e355a50b61 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.input.tsx @@ -2,7 +2,7 @@ import { cell } from "commontools"; export default function TestLiteralWidenNestedStructure() { - const nested = cell({ + const _nested = cell({ users: [ { id: 1, name: "Alice", active: true }, { id: 2, name: "Bob", active: false } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx index f93e0d13a2..a78458d38f 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.input.tsx @@ -2,8 +2,8 @@ import { cell } from "commontools"; export default function TestLiteralWidenNullUndefined() { - const c1 = cell(null); - const c2 = cell(undefined); + const _c1 = cell(null); + const _c2 = cell(undefined); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx index 622a13bcde..8b32086a3e 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.input.tsx @@ -2,11 +2,11 @@ import { cell } from "commontools"; export default function TestLiteralWidenNumber() { - const n1 = cell(10); - const n2 = cell(-5); - const n3 = cell(3.14); - const n4 = cell(1e10); - const n5 = cell(0); + const _n1 = cell(10); + const _n2 = cell(-5); + const _n3 = cell(3.14); + const _n4 = cell(1e10); + const _n5 = cell(0); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx index ebeb97d2bf..98ed4fefbf 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.input.tsx @@ -2,7 +2,7 @@ import { cell } from "commontools"; export default function TestLiteralWidenObjectProperties() { - const obj = cell({ x: 10, y: 20, name: "point" }); + const _obj = cell({ x: 10, y: 20, name: "point" }); return null; } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx index 9de2f8c7d5..137735796d 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.input.tsx @@ -2,10 +2,10 @@ import { cell } from "commontools"; export default function TestLiteralWidenString() { - const s1 = cell("hello"); - const s2 = cell(""); - const s3 = cell("hello\nworld"); - const s4 = cell("with spaces"); + const _s1 = cell("hello"); + const _s2 = cell(""); + const _s3 = cell("hello\nworld"); + const _s4 = cell("with spaces"); return null; } From f2ca45670618b9e8a5ba82c07a0c4cc8418881d4 Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Sat, 22 Nov 2025 00:05:31 -0800 Subject: [PATCH 07/10] add many new fixtures and update notes doc with current state --- .../src/formatters/union-formatter.ts | 14 +- .../ts-transformers/SCHEMA_INJECTION_NOTES.md | 147 +++++++++++++++++- .../test/fixture-based.test.ts | 26 +++- .../cell-like-classes.expected.tsx | 30 ++++ .../cell-like-classes.input.tsx | 23 +++ .../collections-array-of-objects.expected.tsx | 32 ++++ .../collections-array-of-objects.input.tsx | 13 ++ .../collections-empty.expected.tsx | 22 +++ .../collections-empty.input.tsx | 15 ++ .../collections-nested-objects.expected.tsx | 53 +++++++ .../collections-nested-objects.input.tsx | 19 +++ .../context-variations.expected.tsx | 60 +++++++ .../context-variations.input.tsx | 48 ++++++ ...ble-inject-already-has-schema.expected.tsx | 14 ++ ...double-inject-already-has-schema.input.tsx | 13 ++ .../double-inject-extra-args.expected.tsx | 15 ++ .../double-inject-extra-args.input.tsx | 14 ++ .../double-inject-wrong-position.expected.tsx | 13 ++ .../double-inject-wrong-position.input.tsx | 12 ++ .../literal-widen-array-elements.expected.tsx | 6 +- .../literal-widen-bigint.expected.tsx | 6 +- .../literal-widen-boolean.expected.tsx | 4 +- ...eral-widen-explicit-type-args.expected.tsx | 6 +- .../literal-widen-mixed-values.expected.tsx | 6 +- ...iteral-widen-nested-structure.expected.tsx | 2 +- .../literal-widen-null-undefined.expected.tsx | 4 +- .../literal-widen-number.expected.tsx | 10 +- ...teral-widen-object-properties.expected.tsx | 2 +- .../literal-widen-string.expected.tsx | 8 +- 29 files changed, 601 insertions(+), 36 deletions(-) create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-empty.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-empty.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/context-variations.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/context-variations.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.input.tsx diff --git a/packages/schema-generator/src/formatters/union-formatter.ts b/packages/schema-generator/src/formatters/union-formatter.ts index dad2e8f384..fdc3615427 100644 --- a/packages/schema-generator/src/formatters/union-formatter.ts +++ b/packages/schema-generator/src/formatters/union-formatter.ts @@ -237,7 +237,9 @@ export class UnionFormatter implements TypeFormatter { ? s.properties[key] : undefined ) - .filter((p): p is SchemaDefinition => p !== undefined); + .filter((p): p is Exclude => + p !== undefined + ); if (propSchemas.length > 0) { props[key] = this.mergeSchemaGroup(propSchemas); @@ -247,12 +249,16 @@ export class UnionFormatter implements TypeFormatter { } // Recursively merge items - if ("items" in first && first.items) { + if ("items" in first && first.items !== undefined) { const itemSchemas = schemas .map((s) => - isRecord(s) && "items" in s && s.items ? s.items : undefined + isRecord(s) && "items" in s && s.items !== undefined + ? s.items + : undefined ) - .filter((i): i is SchemaDefinition => i !== undefined); + .filter((i): i is Exclude => + i !== undefined + ); if (itemSchemas.length > 0) { result.items = this.mergeSchemaGroup(itemSchemas); diff --git a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md index 443f06589c..c7df76176f 100644 --- a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md +++ b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md @@ -116,6 +116,71 @@ semantics in JSON contexts. Our schema generation reflects this ambiguity. --- +### 3. Empty Collections Schema Generation Is Questionable + +**Location**: Schema generation for empty arrays and objects + +**Test Case**: `test/fixtures/schema-injection/collections-empty.input.tsx` + +**Problem**: What schemas should we generate for empty collections where +TypeScript can't infer contents? + +**Current Behavior**: + +```typescript +const arr = cell([]); +// Generates: cell([], { type: "array", items: false }) +// TypeScript infers: never[] (array that can contain nothing) + +const obj = cell({}); +// Generates: cell({}, { type: "object", properties: {} }) +// TypeScript infers: {} (object with no known properties) +``` + +**Why This Is Questionable**: + +- **Empty arrays**: `items: false` (JSON Schema boolean) means "no items + allowed" - very restrictive + - Accurately reflects `never[]` but may not match user intent + - If the array is meant to be populated later, this schema is wrong + +- **Empty objects**: `properties: {}` with no `required` allows any properties + - More permissive than the array case + - May or may not match user intent + +**Alternative Options**: + +1. **Skip schema injection** - Don't inject schemas for empty collections + - Pro: Acknowledges we have no useful type information + - Con: Inconsistent - some cells get schemas, others don't + - Con: User has to manually add schema if they want one + +2. **Use permissive schemas** - `{ type: "array" }` and `{ type: "object" }` + - Pro: Won't reject valid data at runtime + - Con: Provides minimal validation benefit + - Con: Doesn't reflect what TypeScript actually knows + +3. **Keep current behavior** - Generate schemas from inferred types (current) + - Pro: Consistent with "generate what TypeScript knows" + - Pro: `never[]` actually means "empty forever" + - Con: May surprise users who expect mutable arrays + +**Decision**: Keep current behavior (Option 3) + +- Rationale: Consistency with our principle of reflecting TypeScript's type + knowledge +- If users want permissive or different schemas, they should use explicit type + arguments: + - `cell([])` → generates + `{ type: "array", items: { type: "number" } }` + - `cell<{x?: number}>({})` → generates schema for that type +- The generated schemas accurately reflect what TypeScript infers + +**Related**: This is similar to Issue 2 (undefined) - we generate schemas based +on TypeScript's understanding, even when that might not match user expectations. + +--- + ## Design Decisions ### Literal Widening Strategy @@ -145,7 +210,9 @@ semantics in JSON contexts. Our schema generation reflects this ambiguity. ## Test Coverage -All literal widening scenarios tested in `test/fixtures/schema-injection/`: +### Completed Tests (18 fixtures in `test/fixtures/schema-injection/`) + +**Literal Type Widening:** - ✅ `literal-widen-number.input.tsx` - ✅ `literal-widen-string.input.tsx` @@ -160,6 +227,80 @@ All literal widening scenarios tested in `test/fixtures/schema-injection/`: - ✅ `literal-widen-null-undefined.input.tsx` (documents undefined → `true` behavior) +**Double-Injection Prevention:** + +- ✅ `double-inject-already-has-schema.input.tsx` - Cells with existing schemas + aren't transformed +- ✅ `double-inject-wrong-position.input.tsx` - Malformed code isn't made worse +- ✅ `double-inject-extra-args.input.tsx` - Cells with >2 arguments aren't + transformed + +**Context Variations:** + +- ✅ `context-variations.input.tsx` - Tests cell() in 6 scopes (top-level, + function, arrow function, class method, recipe, handler) + +**Cell-like Classes:** + +- ✅ `cell-like-classes.input.tsx` - Tests all cell variants (cell, + ComparableCell, ReadonlyCell, WriteonlyCell) + +**Collection Edge Cases:** + +- ✅ `collections-empty.input.tsx` - Empty arrays and objects (see Issue #3) +- ✅ `collections-nested-objects.input.tsx` - Deeply nested object literal + widening +- ✅ `collections-array-of-objects.input.tsx` - Arrays of objects with literal + properties + +### Remaining Tests to Add + +**Priority 2: Schema Merging Edge Cases** (addresses "brittle" code in Issue #1) + +- [ ] `collections-deeply-nested-unions` - 3+ levels of nested union merging + ```typescript + const data = cell([ + { user: { profile: { settings: { theme: "dark", notifications: true } } } }, + { + user: { profile: { settings: { theme: "light", notifications: false } } }, + }, + ]); + ``` +- [ ] `collections-mixed-union-types` - Unions with different structural types + ```typescript + const mixed = cell([ + { type: "user", name: "Alice", age: 30 }, + { type: "admin", name: "Bob", role: "superuser" }, + ]); + ``` +- [ ] `collections-array-length-variations` - Arrays with varying element counts +- [ ] `optional-properties` - Objects with optional properties +- [ ] `partial-types` - Test Partial and similar utility types + +**Priority 3: Additional Runtime Functions** + +- [ ] `recipe-variations` - Comprehensive recipe() testing (with/without schema) +- [ ] `handler-variations` - Comprehensive handler() testing (with/without + schema) +- [ ] `wish-function` - Test wish() schema injection +- [ ] `stream-class` - Test Stream class (remaining Cell-like class) + +**Priority 4: Complex TypeScript Features** + +- [ ] `generic-types` - Generic type parameters +- [ ] `intersection-types` - Type intersections (A & B) +- [ ] `tuple-types` - Tuple types vs arrays +- [ ] `enum-types` - TypeScript enums + +**Priority 5: Error Cases** + +- [ ] `circular-references` - Objects with circular references +- [ ] `invalid-json-types` - Non-JSON-serializable types (functions, symbols) + +**Recommendation**: Implement Priority 2 tests next, especially the schema +merging edge cases, as they directly test the code identified as brittle in +Issue #1. + --- ## Future Work @@ -184,5 +325,5 @@ All literal widening scenarios tested in `test/fixtures/schema-injection/`: --- -**Last Updated**: 2025-01-21 **Implementation**: PR #XXXX (schema injection with -literal widening) +**Last Updated**: 2025-01-22 **Implementation**: Schema injection with literal +widening (feat/more-schemas-injected branch) diff --git a/packages/ts-transformers/test/fixture-based.test.ts b/packages/ts-transformers/test/fixture-based.test.ts index 84f41abb59..09a09d3bea 100644 --- a/packages/ts-transformers/test/fixture-based.test.ts +++ b/packages/ts-transformers/test/fixture-based.test.ts @@ -61,8 +61,30 @@ const configs: FixtureConfig[] = [ { directory: "schema-injection", describe: "Schema Injection with Literal Widening", - formatTestName: (name) => - `widens ${name.replace(/^literal-widen-/, "").replace(/-/g, " ")}`, + formatTestName: (name) => { + if (name.startsWith("literal-widen-")) { + return `widens ${ + name.replace(/^literal-widen-/, "").replace(/-/g, " ") + }`; + } + if (name.startsWith("double-inject-")) { + return `prevents ${ + name.replace(/^double-inject-/, "").replace(/-/g, " ") + }`; + } + if (name.startsWith("context-")) { + return name.replace(/-/g, " "); + } + if (name.startsWith("cell-like-")) { + return name.replace(/-/g, " "); + } + if (name.startsWith("collections-")) { + return `handles ${ + name.replace(/^collections-/, "").replace(/-/g, " ") + }`; + } + return name.replace(/-/g, " "); + }, }, ]; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.expected.tsx new file mode 100644 index 0000000000..23c6603a4e --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.expected.tsx @@ -0,0 +1,30 @@ +import * as __ctHelpers from "commontools"; +import { cell, ComparableCell, ReadonlyCell, WriteonlyCell } from "commontools"; +export default function TestCellLikeClasses() { + // Standalone cell() function + const _standalone = cell(100, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + // ComparableCell.of() + const _comparable = ComparableCell.of(200, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + // ReadonlyCell.of() + const _readonly = ReadonlyCell.of(300, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + // WriteonlyCell.of() + const _writeonly = WriteonlyCell.of(400, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return { + standalone: _standalone, + comparable: _comparable, + readonly: _readonly, + writeonly: _writeonly, + }; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.input.tsx new file mode 100644 index 0000000000..dc9be8983c --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/cell-like-classes.input.tsx @@ -0,0 +1,23 @@ +/// +import { cell, ComparableCell, ReadonlyCell, WriteonlyCell } from "commontools"; + +export default function TestCellLikeClasses() { + // Standalone cell() function + const _standalone = cell(100); + + // ComparableCell.of() + const _comparable = ComparableCell.of(200); + + // ReadonlyCell.of() + const _readonly = ReadonlyCell.of(300); + + // WriteonlyCell.of() + const _writeonly = WriteonlyCell.of(400); + + return { + standalone: _standalone, + comparable: _comparable, + readonly: _readonly, + writeonly: _writeonly, + }; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.expected.tsx new file mode 100644 index 0000000000..a1ccb62661 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.expected.tsx @@ -0,0 +1,32 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestCollectionsArrayOfObjects() { + // Array of objects + const _arrayOfObjects = cell([ + { id: 1, name: "Alice", score: 95.5 }, + { id: 2, name: "Bob", score: 87.3 }, + { id: 3, name: "Charlie", score: 92.1 } + ], { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "number" + }, + name: { + type: "string" + }, + score: { + type: "number" + } + }, + required: ["id", "name", "score"] + } + } as const satisfies __ctHelpers.JSONSchema); + return _arrayOfObjects; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.input.tsx new file mode 100644 index 0000000000..7ec8b6658f --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-array-of-objects.input.tsx @@ -0,0 +1,13 @@ +/// +import { cell } from "commontools"; + +export default function TestCollectionsArrayOfObjects() { + // Array of objects + const _arrayOfObjects = cell([ + { id: 1, name: "Alice", score: 95.5 }, + { id: 2, name: "Bob", score: 87.3 }, + { id: 3, name: "Charlie", score: 92.1 } + ]); + + return _arrayOfObjects; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.expected.tsx new file mode 100644 index 0000000000..24296bdcf2 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.expected.tsx @@ -0,0 +1,22 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestCollectionsEmpty() { + // Empty array + const _emptyArray = cell([], { + type: "array", + items: false + } as const satisfies __ctHelpers.JSONSchema); + // Empty object + const _emptyObject = cell({}, { + type: "object", + properties: {} + } as const satisfies __ctHelpers.JSONSchema); + return { + emptyArray: _emptyArray, + emptyObject: _emptyObject, + }; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.input.tsx new file mode 100644 index 0000000000..9055944fa5 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-empty.input.tsx @@ -0,0 +1,15 @@ +/// +import { cell } from "commontools"; + +export default function TestCollectionsEmpty() { + // Empty array + const _emptyArray = cell([]); + + // Empty object + const _emptyObject = cell({}); + + return { + emptyArray: _emptyArray, + emptyObject: _emptyObject, + }; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.expected.tsx new file mode 100644 index 0000000000..53994e37c9 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.expected.tsx @@ -0,0 +1,53 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +export default function TestCollectionsNestedObjects() { + // Nested objects + const _nested = cell({ + user: { + name: "Alice", + age: 30, + address: { + street: "123 Main St", + city: "NYC" + } + }, + timestamp: 1234567890 + }, { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { + type: "string" + }, + age: { + type: "number" + }, + address: { + type: "object", + properties: { + street: { + type: "string" + }, + city: { + type: "string" + } + }, + required: ["street", "city"] + } + }, + required: ["name", "age", "address"] + }, + timestamp: { + type: "number" + } + }, + required: ["user", "timestamp"] + } as const satisfies __ctHelpers.JSONSchema); + return _nested; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.input.tsx new file mode 100644 index 0000000000..3d4654bc09 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/collections-nested-objects.input.tsx @@ -0,0 +1,19 @@ +/// +import { cell } from "commontools"; + +export default function TestCollectionsNestedObjects() { + // Nested objects + const _nested = cell({ + user: { + name: "Alice", + age: 30, + address: { + street: "123 Main St", + city: "NYC" + } + }, + timestamp: 1234567890 + }); + + return _nested; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/context-variations.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/context-variations.expected.tsx new file mode 100644 index 0000000000..40bf767bc2 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/context-variations.expected.tsx @@ -0,0 +1,60 @@ +import * as __ctHelpers from "commontools"; +import { cell, recipe, handler } from "commontools"; +// 1. Top-level +const _topLevel = cell(10, { + type: "number" +} as const satisfies __ctHelpers.JSONSchema); +// 2. Inside function +function regularFunction() { + const _inFunction = cell(20, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return _inFunction; +} +// 3. Inside arrow function +const arrowFunction = () => { + const _inArrow = cell(30, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return _inArrow; +}; +// 4. Inside class method +class TestClass { + method() { + const _inMethod = cell(40, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return _inMethod; + } +} +// 5. Inside recipe +const testRecipe = recipe(false as const satisfies __ctHelpers.JSONSchema, { + type: "number", + asCell: true +} as const satisfies __ctHelpers.JSONSchema, () => { + const _inRecipe = cell(50, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return _inRecipe; +}); +// 6. Inside handler +const testHandler = handler(false as const satisfies __ctHelpers.JSONSchema, false as const satisfies __ctHelpers.JSONSchema, () => { + const _inHandler = cell(60, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + return _inHandler; +}); +export default function TestContextVariations() { + return { + topLevel: _topLevel, + regularFunction, + arrowFunction, + TestClass, + testRecipe, + testHandler, + }; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/context-variations.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/context-variations.input.tsx new file mode 100644 index 0000000000..b1a41adca9 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/context-variations.input.tsx @@ -0,0 +1,48 @@ +/// +import { cell, recipe, handler } from "commontools"; + +// 1. Top-level +const _topLevel = cell(10); + +// 2. Inside function +function regularFunction() { + const _inFunction = cell(20); + return _inFunction; +} + +// 3. Inside arrow function +const arrowFunction = () => { + const _inArrow = cell(30); + return _inArrow; +}; + +// 4. Inside class method +class TestClass { + method() { + const _inMethod = cell(40); + return _inMethod; + } +} + +// 5. Inside recipe +const testRecipe = recipe(() => { + const _inRecipe = cell(50); + return _inRecipe; +}); + +// 6. Inside handler +const testHandler = handler(() => { + const _inHandler = cell(60); + return _inHandler; +}); + +export default function TestContextVariations() { + return { + topLevel: _topLevel, + regularFunction, + arrowFunction, + TestClass, + testRecipe, + testHandler, + }; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.expected.tsx new file mode 100644 index 0000000000..3bec26c8a0 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.expected.tsx @@ -0,0 +1,14 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +const existingSchema = { type: "number" } as const; +export default function TestDoubleInjectAlreadyHasSchema() { + // Should NOT transform - already has 2 arguments + const _c1 = cell(10, existingSchema); + const _c2 = cell("hello", { type: "string" }); + const _c3 = cell(true, { type: "boolean" } as const); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.input.tsx new file mode 100644 index 0000000000..6f879f01a4 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-already-has-schema.input.tsx @@ -0,0 +1,13 @@ +/// +import { cell } from "commontools"; + +const existingSchema = { type: "number" } as const; + +export default function TestDoubleInjectAlreadyHasSchema() { + // Should NOT transform - already has 2 arguments + const _c1 = cell(10, existingSchema); + const _c2 = cell("hello", { type: "string" }); + const _c3 = cell(true, { type: "boolean" } as const); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.expected.tsx new file mode 100644 index 0000000000..1ef1cd5b8e --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.expected.tsx @@ -0,0 +1,15 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +const schema = { type: "number" } as const; +const extra = "extra"; +export default function TestDoubleInjectExtraArgs() { + // Should NOT transform - already has more than 2 arguments + // This is malformed code, but we shouldn't touch it + const _c1 = cell(10, schema, extra); + const _c2 = cell(20, schema, extra, "another"); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.input.tsx new file mode 100644 index 0000000000..e78018bbe2 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-extra-args.input.tsx @@ -0,0 +1,14 @@ +/// +import { cell } from "commontools"; + +const schema = { type: "number" } as const; +const extra = "extra"; + +export default function TestDoubleInjectExtraArgs() { + // Should NOT transform - already has more than 2 arguments + // This is malformed code, but we shouldn't touch it + const _c1 = cell(10, schema, extra); + const _c2 = cell(20, schema, extra, "another"); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.expected.tsx new file mode 100644 index 0000000000..69ce02a440 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.expected.tsx @@ -0,0 +1,13 @@ +import * as __ctHelpers from "commontools"; +import { cell } from "commontools"; +const schema = { type: "number" } as const; +export default function TestDoubleInjectWrongPosition() { + // Should NOT transform - already has 2 arguments (even if in wrong order) + // This is malformed code, but we shouldn't make it worse by adding a third arg + const _c1 = cell(schema, 10); + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.input.tsx new file mode 100644 index 0000000000..dbf8d6f579 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/double-inject-wrong-position.input.tsx @@ -0,0 +1,12 @@ +/// +import { cell } from "commontools"; + +const schema = { type: "number" } as const; + +export default function TestDoubleInjectWrongPosition() { + // Should NOT transform - already has 2 arguments (even if in wrong order) + // This is malformed code, but we shouldn't make it worse by adding a third arg + const _c1 = cell(schema, 10); + + return null; +} diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx index effbd6df64..b1488f6694 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-array-elements.expected.tsx @@ -1,19 +1,19 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenArrayElements() { - const arr1 = cell([1, 2, 3], { + const _arr1 = cell([1, 2, 3], { type: "array", items: { type: "number" } } as const satisfies __ctHelpers.JSONSchema); - const arr2 = cell(["a", "b", "c"], { + const _arr2 = cell(["a", "b", "c"], { type: "array", items: { type: "string" } } as const satisfies __ctHelpers.JSONSchema); - const arr3 = cell([true, false], { + const _arr3 = cell([true, false], { type: "array", items: { type: "boolean" diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx index 1374902313..23b9964edb 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-bigint.expected.tsx @@ -1,13 +1,13 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenBigInt() { - const bi1 = cell(123n, { + const _bi1 = cell(123n, { type: "integer" } as const satisfies __ctHelpers.JSONSchema); - const bi2 = cell(0n, { + const _bi2 = cell(0n, { type: "integer" } as const satisfies __ctHelpers.JSONSchema); - const bi3 = cell(-456n, { + const _bi3 = cell(-456n, { type: "integer" } as const satisfies __ctHelpers.JSONSchema); return null; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx index ee595fd19f..8a1b370eea 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-boolean.expected.tsx @@ -1,10 +1,10 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenBoolean() { - const b1 = cell(true, { + const _b1 = cell(true, { type: "boolean" } as const satisfies __ctHelpers.JSONSchema); - const b2 = cell(false, { + const _b2 = cell(false, { type: "boolean" } as const satisfies __ctHelpers.JSONSchema); return null; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx index d6a8744b63..31b5bbc3cf 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-explicit-type-args.expected.tsx @@ -1,13 +1,13 @@ import * as __ctHelpers from "commontools"; import { Cell } from "commontools"; export default function TestLiteralWidenExplicitTypeArgs() { - const c1 = Cell.of(10, { + const _c1 = Cell.of(10, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const c2 = Cell.of("hello", { + const _c2 = Cell.of("hello", { type: "string" } as const satisfies __ctHelpers.JSONSchema); - const c3 = Cell.of(true, { + const _c3 = Cell.of(true, { type: "boolean" } as const satisfies __ctHelpers.JSONSchema); return null; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx index f41d2cf091..9b9632e8f7 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-mixed-values.expected.tsx @@ -2,13 +2,13 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenMixedValues() { const variable = 42; - const c1 = cell(10, { + const _c1 = cell(10, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const c2 = cell(variable, { + const _c2 = cell(variable, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const c3 = cell(10 + 20, { + const _c3 = cell(10 + 20, { type: "number" } as const satisfies __ctHelpers.JSONSchema); return null; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx index b7f806ff59..34b8b52b59 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-nested-structure.expected.tsx @@ -1,7 +1,7 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenNestedStructure() { - const nested = cell({ + const _nested = cell({ users: [ { id: 1, name: "Alice", active: true }, { id: 2, name: "Bob", active: false } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx index d3332cb8d7..50a5321cad 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-null-undefined.expected.tsx @@ -1,10 +1,10 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenNullUndefined() { - const c1 = cell(null, { + const _c1 = cell(null, { type: "null" } as const satisfies __ctHelpers.JSONSchema); - const c2 = cell(undefined, true as const satisfies __ctHelpers.JSONSchema); + const _c2 = cell(undefined, true as const satisfies __ctHelpers.JSONSchema); return null; } // @ts-ignore: Internals diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx index 877cecba6b..ee8fa75e37 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-number.expected.tsx @@ -1,19 +1,19 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenNumber() { - const n1 = cell(10, { + const _n1 = cell(10, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const n2 = cell(-5, { + const _n2 = cell(-5, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const n3 = cell(3.14, { + const _n3 = cell(3.14, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const n4 = cell(1e10, { + const _n4 = cell(1e10, { type: "number" } as const satisfies __ctHelpers.JSONSchema); - const n5 = cell(0, { + const _n5 = cell(0, { type: "number" } as const satisfies __ctHelpers.JSONSchema); return null; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx index 327b73726f..e642d8b4b0 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-object-properties.expected.tsx @@ -1,7 +1,7 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenObjectProperties() { - const obj = cell({ x: 10, y: 20, name: "point" }, { + const _obj = cell({ x: 10, y: 20, name: "point" }, { type: "object", properties: { x: { diff --git a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx index 87f2987136..bdf6c894d8 100644 --- a/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx +++ b/packages/ts-transformers/test/fixtures/schema-injection/literal-widen-string.expected.tsx @@ -1,16 +1,16 @@ import * as __ctHelpers from "commontools"; import { cell } from "commontools"; export default function TestLiteralWidenString() { - const s1 = cell("hello", { + const _s1 = cell("hello", { type: "string" } as const satisfies __ctHelpers.JSONSchema); - const s2 = cell("", { + const _s2 = cell("", { type: "string" } as const satisfies __ctHelpers.JSONSchema); - const s3 = cell("hello\nworld", { + const _s3 = cell("hello\nworld", { type: "string" } as const satisfies __ctHelpers.JSONSchema); - const s4 = cell("with spaces", { + const _s4 = cell("with spaces", { type: "string" } as const satisfies __ctHelpers.JSONSchema); return null; From 9e078352bab0c580ce4a6f627c2ce1ae78d1248f Mon Sep 17 00:00:00 2001 From: Gideon Wald Date: Sat, 22 Nov 2025 00:26:53 -0800 Subject: [PATCH 08/10] added a new method to generateSchema, update test expectations --- packages/schema-generator/test/plugin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/schema-generator/test/plugin.test.ts b/packages/schema-generator/test/plugin.test.ts index e1e6a9b61d..29da09cbd3 100644 --- a/packages/schema-generator/test/plugin.test.ts +++ b/packages/schema-generator/test/plugin.test.ts @@ -17,7 +17,7 @@ describe("Plugin Interface", () => { ); // Verify generateSchema has the right number of parameters - expect(transformer.generateSchema.length).toBe(3); + expect(transformer.generateSchema.length).toBe(4); }); it("transforms a simple object via plugin", async () => { From 1e905c56a74e9e215853deb3b19bf825c511ad58 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Tue, 25 Nov 2025 10:06:24 -0800 Subject: [PATCH 09/10] fix: add undefined as first arg for Cell.of() with no value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Cell.of() is called with a type argument but no value argument, the schema was incorrectly being placed as the first argument. The schema must always be the second argument, so now undefined is inserted as the first argument when no value is provided. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/transformers/schema-injection.ts | 8 +++- .../cell-of-no-value.expected.tsx | 42 +++++++++++++++++++ .../cell-of-no-value.input.tsx | 21 ++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.input.tsx diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts index 901276ed34..499af426a8 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -1042,10 +1042,16 @@ export class SchemaInjectionTransformer extends Transformer { typeRegistry.set(schemaCall, type); } + // Schema must always be the second argument. If no value was provided, + // add undefined as the first argument. + const newArgs = args.length === 0 + ? [factory.createIdentifier("undefined"), schemaCall] + : [...args, schemaCall]; + const updated = factory.createCallExpression( node.expression, node.typeArguments, - [...args, schemaCall], + newArgs, ); return ts.visitEachChild(updated, visit, transformation); } diff --git a/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.expected.tsx b/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.expected.tsx new file mode 100644 index 0000000000..898301f29e --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.expected.tsx @@ -0,0 +1,42 @@ +import * as __ctHelpers from "commontools"; +import { Cell, cell, ComparableCell } from "commontools"; +export default function TestCellOfNoValue() { + // Cell.of with type argument but no value - should become Cell.of(undefined, schema) + const _c1 = Cell.of(undefined, { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + const _c2 = Cell.of(undefined, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); + const _c3 = Cell.of(undefined, { + type: "boolean" + } as const satisfies __ctHelpers.JSONSchema); + // cell() with type argument but no value - should become cell(undefined, schema) + const _c4 = cell(undefined, { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); + // ComparableCell.of with type argument but no value + const _c5 = ComparableCell.of<{ + name: string; + }>(undefined, { + type: "object", + properties: { + name: { + type: "string" + } + }, + required: ["name"] + } as const satisfies __ctHelpers.JSONSchema); + // Mixed - some with value, some without + const _c6 = Cell.of("hello", { + type: "string" + } as const satisfies __ctHelpers.JSONSchema); // has value + const _c7 = Cell.of(undefined, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema); // no value + return null; +} +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.input.tsx b/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.input.tsx new file mode 100644 index 0000000000..8f63f64572 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-injection/cell-of-no-value.input.tsx @@ -0,0 +1,21 @@ +/// +import { Cell, cell, ComparableCell } from "commontools"; + +export default function TestCellOfNoValue() { + // Cell.of with type argument but no value - should become Cell.of(undefined, schema) + const _c1 = Cell.of(); + const _c2 = Cell.of(); + const _c3 = Cell.of(); + + // cell() with type argument but no value - should become cell(undefined, schema) + const _c4 = cell(); + + // ComparableCell.of with type argument but no value + const _c5 = ComparableCell.of<{ name: string }>(); + + // Mixed - some with value, some without + const _c6 = Cell.of("hello"); // has value + const _c7 = Cell.of(); // no value + + return null; +} From 09ea8b44f7ab6f0763aca2de7fa6a13d92dcfbbf Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Tue, 25 Nov 2025 10:38:34 -0800 Subject: [PATCH 10/10] fix: remove schema from resolved link when path field is undefined in schema When resolving a link with a remaining path, if `getSchemaAtPath` returns `undefined` (because the field doesn't exist in the schema), the resolved link should have no schema property rather than keeping the parent schema. Previously, the code spread `...nextLink` which would include the original schema, then conditionally added the new schema only if truthy. This meant an undefined schema from `getSchemaAtPath` would leave the original schema in place. The fix: 1. Destructures `schema` out of `nextLink` to prevent it from being spread 2. Uses `schema \!== undefined` instead of truthiness check to properly handle empty object schemas `{}` 3. Only adds `schema` back to the result if it's explicitly not undefined Example: An array schema `{type: "array", items: {type: "number"}}` linked through a path to `.length` should result in `schema: undefined` since "length" is not defined in array schemas. --- packages/runner/src/link-resolution.ts | 12 ++-- packages/runner/test/link-resolution.test.ts | 65 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/packages/runner/src/link-resolution.ts b/packages/runner/src/link-resolution.ts index 158d25a7d2..a7610cb8c0 100644 --- a/packages/runner/src/link-resolution.ts +++ b/packages/runner/src/link-resolution.ts @@ -150,19 +150,19 @@ export function resolveLink( if (nextLink) { const remainingPath = link.path.slice(lastValid.length); - let linkSchema = nextLink.schema; - if (linkSchema !== undefined && remainingPath.length > 0) { + let { schema, ...restLink } = nextLink; + if (schema !== undefined && remainingPath.length > 0) { const cfc = new ContextualFlowControl(); - linkSchema = cfc.getSchemaAtPath( - linkSchema, + schema = cfc.getSchemaAtPath( + schema, remainingPath, nextLink.rootSchema, ); } nextLink = { - ...nextLink, + ...restLink, path: [...nextLink.path, ...remainingPath], - ...(linkSchema ? { schema: linkSchema } : {}), + ...(schema !== undefined && { schema }), }; } } diff --git a/packages/runner/test/link-resolution.test.ts b/packages/runner/test/link-resolution.test.ts index 56f2fe18a3..498ac1dcfd 100644 --- a/packages/runner/test/link-resolution.test.ts +++ b/packages/runner/test/link-resolution.test.ts @@ -646,6 +646,71 @@ describe("link-resolution", () => { const resolved = resolveLink(tx, parsedLink); expect(resolved.schema).toEqual(schema2); }); + + it("should remove schema when remaining path field is not in schema", () => { + // This tests the corner case where: + // 1. An intermediate link has a schema + // 2. There's a remaining path to follow after the link + // 3. The field in the remaining path is NOT defined in the schema + // In this case, schema should become undefined (removed) rather than + // keeping the parent schema + // + // We use an array schema and access .length - the array schema defines + // items but not a "length" property, so getSchemaAtPath returns undefined. + const schema = { + type: "array", + items: { type: "number" }, + } as const satisfies JSONSchema; + + const targetCell = runtime.getCell( + space, + "undefined-schema-field-target", + schema, + tx, + ); + targetCell.set([1, 2, 3]); + + const sourceCell = runtime.getCell( + space, + "undefined-schema-field-source", + undefined, + tx, + ); + + // Create a link at "data" pointing to targetCell with schema included + sourceCell.setRaw({ + data: targetCell.getAsLink({ includeSchema: true }), + }); + tx.commit(); + tx = runtime.edit(); + + // First verify the link to targetCell has the schema + const dataLink = parseLink(sourceCell.key("data"), sourceCell)!; + const dataResolved = resolveLink(tx, dataLink); + expect(dataResolved.schema).toEqual(schema); + + // Now resolve a path that goes through the link to "length". + // The resolver will: + // 1. Try to resolve sourceCell/data/length + // 2. Find that "length" doesn't exist (NotFoundError) + // 3. Find the link at "data" (lastValid = ["data"]) + // 4. Calculate remainingPath = ["length"] + // 5. Call getSchemaAtPath(schema, ["length"]) which returns undefined + // because array schema has no "length" property defined + // 6. The fix ensures we DON'T spread the original schema back in + const linkThroughToLength = { + ...sourceCell.getAsNormalizedFullLink(), + path: ["data", "length"], + schema: undefined, // Start without schema to test inheritance + }; + const resolved = resolveLink(tx, linkThroughToLength); + + // The resolved link should NOT have a schema since "length" is not + // defined in the array schema + expect(resolved.schema).toBeUndefined(); + // Verify we can still read the value + expect(tx.readValueOrThrow(resolved)).toBe(3); + }); }); describe("overwrite field removal", () => {