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", () => { 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..fdc3615427 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,181 @@ 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 Exclude => + p !== undefined + ); + + if (propSchemas.length > 0) { + props[key] = this.mergeSchemaGroup(propSchemas); + } + } + result.properties = props; + } + + // Recursively merge items + if ("items" in first && first.items !== undefined) { + const itemSchemas = schemas + .map((s) => + isRecord(s) && "items" in s && s.items !== undefined + ? s.items + : undefined + ) + .filter((i): i is Exclude => + 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..240a508c4b 100644 --- a/packages/schema-generator/src/schema-generator.ts +++ b/packages/schema-generator/src/schema-generator.ts @@ -44,8 +44,15 @@ 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 +86,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 +109,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/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 () => { diff --git a/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md new file mode 100644 index 0000000000..c7df76176f --- /dev/null +++ b/packages/ts-transformers/SCHEMA_INJECTION_NOTES.md @@ -0,0 +1,329 @@ +# 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. + +--- + +### 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 + +**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 + +### Completed Tests (18 fixtures in `test/fixtures/schema-injection/`) + +**Literal Type Widening:** + +- ✅ `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) + +**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 + +### 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-22 **Implementation**: Schema injection with literal +widening (feat/more-schemas-injected branch) 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..5f47571eed --- /dev/null +++ b/packages/ts-transformers/TEST_PLAN_schema_injection.md @@ -0,0 +1,457 @@ +# 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/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..83bfdbfa8d 100644 --- a/packages/ts-transformers/src/ast/mod.ts +++ b/packages/ts-transformers/src/ast/mod.ts @@ -20,10 +20,12 @@ export { } from "./utils.ts"; export { getTypeReferenceArgument, + inferContextualType, inferParameterType, inferReturnType, isAnyOrUnknownType, 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 c3b19a8414..007ac10208 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 @@ -453,3 +498,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-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 ce8b7bc7db..499af426a8 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -2,11 +2,14 @@ import ts from "typescript"; import { detectCallKind, + inferContextualType, inferParameterType, inferReturnType, isAnyOrUnknownType, isFunctionLikeExpression, typeToSchemaTypeNode, + unwrapOpaqueLikeType, + widenLiteralType, } from "../ast/mod.ts"; import { TransformationContext, @@ -158,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, ); } @@ -175,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 @@ -974,6 +994,271 @@ 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; + + // 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 - preserve literal types + typeNode = typeArgs[0]; + if (typeNode && typeRegistry) { + type = typeRegistry.get(typeNode); + } + } else if (args.length > 0) { + // Infer from value argument - widen literal types + const valueArg = args[0]; + if (valueArg) { + const valueType = checker.getTypeAtLocation(valueArg); + if (valueType && !isAnyOrUnknownType(valueType)) { + // Widen literal types (e.g., 10 → number) for more flexible schemas + type = widenLiteralType(valueType, checker); + typeNode = typeToSchemaTypeNode(type, checker, sourceFile); + } + } + } + + if (typeNode) { + const schemaCall = createSchemaCallWithRegistryTransfer( + context, + typeNode, + typeRegistry, + isValueInference ? { widenLiterals: true } : undefined, + ); + + // If we inferred the type (no explicit type arg), register it + if (isValueInference && type && typeRegistry) { + 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, + newArgs, + ); + 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/fixture-based.test.ts b/packages/ts-transformers/test/fixture-based.test.ts index f67cb9d98a..09a09d3bea 100644 --- a/packages/ts-transformers/test/fixture-based.test.ts +++ b/packages/ts-transformers/test/fixture-based.test.ts @@ -58,6 +58,34 @@ const configs: FixtureConfig[] = [ { pattern: /^lift-/, name: "Generic closures" }, ], }, + { + directory: "schema-injection", + describe: "Schema Injection with Literal Widening", + 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, " "); + }, + }, ]; const staticCache = new StaticCacheFS(); 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/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-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/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]: (
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/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; +} 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 new file mode 100644 index 0000000000..b1488f6694 --- /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..9b9b6d5aeb --- /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..23b9964edb --- /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..e87f481c14 --- /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..8a1b370eea --- /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..659a726379 --- /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..31b5bbc3cf --- /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..c1e33d01e1 --- /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..9b9632e8f7 --- /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..d13cae7092 --- /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..34b8b52b59 --- /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..e355a50b61 --- /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..50a5321cad --- /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..a78458d38f --- /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..ee8fa75e37 --- /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..8b32086a3e --- /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..e642d8b4b0 --- /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..98ed4fefbf --- /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..bdf6c894d8 --- /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..137735796d --- /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; +} 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..c733b34e66 --- /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" } 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)', + ), + ); +});