From 513dd8989ffe9e081b5c02f9e486f56a2a36a624 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Fri, 2 Oct 2026 22:44:56 -0400 Subject: [PATCH 1/8] fix(dart): parse Picoschema parenthetical types per spec The Dart Picoschema converter treated `field(array)`, `field(object)` and `field(enum)` qualifiers as descriptions, so `tags(array): string` became a string schema. Some schemas also skipped conversion entirely because `isPicoschema` only recognized bare primitive values. The converter now follows the JS reference implementation: - `(array|object|enum[, description])` qualifiers, `(*)` wildcards and optional-is-nullable semantics match the spec. - Top-level JSON Schema (`type`, bare `properties`) is passed through. - Named schemas resolve via `schemas` and then `DotpromptOptions.schemaResolver` (new `Picoschema.parse`). - Invalid input throws `PicoschemaException` (free-text parentheses, `name(*)`, `string[]`, `a | b`, type aliases, unknown schema names). The spec runner now checks `output` and named `schemas`, so spec/picoschema.yaml is enforced (9 of 18 cases failed before). ISSUE: https://github.com/genkit-ai/genkit-dart/issues/562 CHANGELOG: - [x] Fix Picoschema `(array)`, `(object)`, `(enum)` and `(*)` handling - [x] Add `Picoschema.parse` with async `schemaResolver` support - [x] Make Picoschema strict, matching other runtimes --- dart/dotprompt/CHANGELOG.md | 31 +- dart/dotprompt/PARITY.md | 10 +- dart/dotprompt/README.md | 12 +- dart/dotprompt/lib/src/dotprompt.dart | 30 +- dart/dotprompt/lib/src/picoschema.dart | 515 ++++++++++------------- dart/dotprompt/pubspec.yaml | 2 +- dart/dotprompt/test/picoschema_test.dart | 498 +++++++++++++++++++--- dart/dotprompt/test/spec_test.dart | 17 + docs/api/dart/index.md | 22 +- 9 files changed, 741 insertions(+), 396 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index 86223511a..0cf242ad0 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -2,10 +2,39 @@ All notable changes to dotprompt-dart will be documented in this file. -## [Unreleased] +## [1.1.0] + +### Fixed + +- Picoschema now follows the spec and the JavaScript reference implementation + ([genkit-dart#562](https://github.com/genkit-ai/genkit-dart/issues/562)): + - `field(array[, desc]): type`, `field(object[, desc]):` and + `field(enum[, desc]): [...]` produce arrays, objects and enums. Previously + the parenthesized qualifier was treated as a description. + - `(*)` wildcards and every Picoschema form are always converted. Previously + some schemas skipped conversion and were passed through raw. + - Top-level JSON Schema (`type: string`, a bare `properties` map) is passed + through instead of being parsed as Picoschema. + - Named schemas are resolved via `DotpromptOptions.schemaResolver` as well as + `schemas`/`defineSchema`. +- The spec test runner now checks `output` and named `schemas`, so + `spec/picoschema.yaml` is actually enforced. + +### Changed + +- Picoschema is strict, like the other runtimes. These now throw + `PicoschemaException`: + - free-text parentheses such as `email(the email): string` (use + `email: string, the email`); + - parenthetical types other than `array`, `object` and `enum`, e.g. + `wild(*)`; + - non-standard types (`string[]`, `a | b`, aliases like `int`/`str`); + - unknown named schemas. Previously these became `{"$ref": name}`. ### Added +- `Picoschema.parse(schema, {schemas, schemaResolver})`, an async variant of + `toJsonSchema` that resolves named schemas through a `SchemaResolver`. - `renderMetadata` and `compile` now accept an optional `additionalMetadata` argument that is merged on top of the prompt's parsed frontmatter (scalar fields override, `config` map is shallow-merged with additional winning on diff --git a/dart/dotprompt/PARITY.md b/dart/dotprompt/PARITY.md index f5dc286d2..c1ba4785f 100644 --- a/dart/dotprompt/PARITY.md +++ b/dart/dotprompt/PARITY.md @@ -124,11 +124,13 @@ This document tracks feature parity between the Dart and JavaScript (canonical) | Type scalars (string, integer, etc.) | ✅ | ✅ | | | Optional fields (`?` suffix) | ✅ | ✅ | | | Descriptions (`, description`) | ✅ | ✅ | | -| Nested objects | ✅ | ✅ | | -| Array types (`type[]` suffix) | ✅ | ✅ | | -| Enum types | ✅ | ✅ | | +| Nested objects (plain and `(object[, desc])`) | ✅ | ✅ | | +| Arrays (`(array[, desc])`) | ✅ | ✅ | | +| Enums (`(enum[, desc])`) | ✅ | ✅ | | +| Wildcards (`(*)`) | ✅ | ✅ | | +| JSON Schema passthrough | ✅ | ✅ | | | Named schema references | ✅ | ✅ | | -| Async schema resolution | ✅ | ✅ | | +| Async schema resolution | ✅ | ✅ | `Picoschema.parse` / `DotpromptOptions.schemaResolver` | ## Templating Engine diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 772e2edca..1707945c2 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -34,7 +34,7 @@ void main() async { // Parse and render a prompt final result = await dotprompt.render(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest config: temperature: 0.7 --- @@ -55,18 +55,18 @@ Hello {{name}}! You are a {{role}}. ```dart final parsed = dotprompt.parse(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest input: schema: name: string - age: integer? + age?: integer default: name: User --- Hello {{name}}! '''); -print(parsed.model); // "gemini-pro" +print(parsed.model); // "googleai/gemini-flash-latest" print(parsed.input?.schema); // Schema definition ``` @@ -145,9 +145,9 @@ Please analyze this image: ```dart final dotprompt = Dotprompt(DotpromptOptions( - defaultModel: 'gemini-pro', + defaultModel: 'googleai/gemini-flash-latest', modelConfigs: { - 'gemini-pro': {'temperature': 0.7}, + 'googleai/gemini-flash-latest': {'temperature': 0.7}, }, partials: {'...': '...'}, tools: {'...': ToolDefinition(...)}, diff --git a/dart/dotprompt/lib/src/dotprompt.dart b/dart/dotprompt/lib/src/dotprompt.dart index d08798887..4aef53460 100644 --- a/dart/dotprompt/lib/src/dotprompt.dart +++ b/dart/dotprompt/lib/src/dotprompt.dart @@ -302,27 +302,22 @@ class Dotprompt { } } - // Process schemas (convert Picoschema to JSON Schema) + // Convert Picoschema to JSON Schema. JSON Schema input is passed through by + // the converter itself. var input = effectiveInput; var output = effectiveOutput; - if (input?.schema != null && Picoschema.isPicoschema(input!.schema!)) { - final jsonSchema = Picoschema.toJsonSchema( - input.schema, - schemas: _schemas, - ); + final inputSchema = input?.schema; + if (input != null && inputSchema != null) { input = InputConfig( - schema: jsonSchema, + schema: await _resolveSchema(inputSchema), defaultValues: input.defaultValues, ); } - if (output?.schema != null && Picoschema.isPicoschema(output!.schema!)) { - final jsonSchema = Picoschema.toJsonSchema( - output.schema, - schemas: _schemas, - ); - output = OutputConfig(format: output.format, schema: jsonSchema); + final outputSchema = output?.schema; + if (output != null && outputSchema != null) { + output = OutputConfig(format: output.format, schema: await _resolveSchema(outputSchema)); } return PromptMetadata( @@ -337,6 +332,15 @@ class Dotprompt { ); } + /// Converts a frontmatter schema to JSON Schema, resolving named schemas from + /// [defineSchema]/[DotpromptOptions.schemas] first and then + /// [DotpromptOptions.schemaResolver]. + Future> _resolveSchema(Map schema) => Picoschema.parse( + schema, + schemas: _schemas, + schemaResolver: _options.schemaResolver, + ); + /// Renders a template with the given data. Future _renderInternal( ParsedPrompt parsed, diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index 6dc0b40fd..e683e45fc 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -16,371 +16,294 @@ /// Picoschema to JSON Schema converter. /// -/// Picoschema is a compact, human-readable schema format that compiles to -/// standard JSON Schema. It's designed to be easy to write in YAML frontmatter. -/// -/// ## Picoschema Syntax +/// Picoschema is a compact, YAML-friendly schema format that compiles to JSON +/// Schema. This implementation follows the JavaScript reference +/// implementation and the spec in `spec/picoschema.yaml`. See +/// https://google.github.io/dotprompt/reference/picoschema/. /// /// ```yaml -/// # Simple types -/// name: string -/// age: integer -/// score: number -/// active: boolean -/// -/// # Arrays -/// tags: string[] -/// -/// # Optional fields (with ?) -/// nickname?: string -/// -/// # Descriptions (in parentheses) -/// email(User's email address): string -/// -/// # Nested objects -/// address: -/// street: string -/// city: string -/// zip: string -/// -/// # Enums -/// status: approved | pending | rejected +/// title: string, the article title # scalar with description +/// subtitle?: string # optional (and nullable) +/// status?(enum, approval status): [PENDING, APPROVED] +/// tags(array, relevant tags): string # array of scalars +/// authors(array): # array of objects +/// name: string +/// email?: string +/// metadata?(object, extra info): # nested object +/// updatedAt?: string, ISO timestamp of last update +/// labels(object): +/// (*): string # wildcard -> additionalProperties +/// address: Address # named schema reference /// ``` /// -/// ## Compiled JSON Schema -/// -/// The above compiles to: -/// ```json -/// { -/// "type": "object", -/// "properties": { -/// "name": {"type": "string"}, -/// "age": {"type": "integer"}, -/// "score": {"type": "number"}, -/// "active": {"type": "boolean"}, -/// "tags": {"type": "array", "items": {"type": "string"}}, -/// "nickname": {"type": "string"}, -/// "email": {"type": "string", "description": "User's email address"}, -/// "address": { -/// "type": "object", -/// "properties": {...}, -/// "required": ["street", "city", "zip"] -/// }, -/// "status": {"type": "string", "enum": ["approved", "pending", "rejected"]} -/// }, -/// "required": ["name", "age", "score", "active", "tags", "email", "address", "status"] -/// } -/// ``` +/// Scalar types are `string`, `number`, `integer`, `boolean`, `null` and +/// `any`. Any other type name is looked up as a named schema. The only +/// parenthetical types are `array`, `object` and `enum`; anything else in +/// parentheses is an error. library; import "error.dart"; +import "store.dart" show SchemaResolver; -/// Converts a Picoschema definition to JSON Schema. -/// -/// Picoschema is a compact schema format designed for ease of use in YAML. -/// -/// ## Example +/// Looks up a named schema, throwing if it is unknown. +typedef _SchemaLookup = Map Function(String name); + +/// Converts Picoschema definitions to JSON Schema. /// /// ```dart -/// final picoschema = { -/// 'name': 'string', -/// 'age': 'integer', -/// 'tags': 'string[]', -/// }; -/// -/// final jsonSchema = Picoschema.toJsonSchema(picoschema); -/// // Returns: +/// final schema = Picoschema.toJsonSchema({ +/// 'name': 'string, the name', +/// 'steps(array)': {'number': 'integer', 'instruction': 'string'}, +/// }); /// // { /// // "type": "object", /// // "properties": { -/// // "name": {"type": "string"}, -/// // "age": {"type": "integer"}, -/// // "tags": {"type": "array", "items": {"type": "string"}} +/// // "name": {"type": "string", "description": "the name"}, +/// // "steps": { +/// // "type": "array", +/// // "items": {"type": "object", "properties": {...}, ...} +/// // } /// // }, -/// // "required": ["name", "age", "tags"] +/// // "additionalProperties": false, +/// // "required": ["name", "steps"] /// // } /// ``` class Picoschema { - /// Private constructor to prevent instantiation. Picoschema._(); - /// Primitive type mappings from Picoschema to JSON Schema. - static const Map _primitiveTypes = { - "string": "string", - "str": "string", - "number": "number", - "num": "number", - "float": "number", - "double": "number", - "integer": "integer", - "int": "integer", - "boolean": "boolean", - "bool": "boolean", - "null": "null", - "any": "object", - "object": "object", - }; + static const Set _scalarTypes = {"any", "boolean", "integer", "null", "number", "string"}; - /// Regex for parsing field names with optional description and optionality. - /// Matches: fieldName(description)? or fieldName?(description)? - static final RegExp _fieldPattern = RegExp( - r"^([a-zA-Z_][a-zA-Z0-9_]*)(\?)?(?:\(([^)]+)\))?$", - ); + /// Top-level `type` values that mark a schema as already being JSON Schema. + static const Set _jsonSchemaTypes = {..._scalarTypes, "object", "array"}; - /// Regex for parsing array types (e.g., "string[]"). - static final RegExp _arrayPattern = RegExp(r"^(.+)\[\]$"); + static const String _wildcardKey = "(*)"; - /// Regex for parsing enum types (e.g., "foo | bar | baz"). - static final RegExp _enumPattern = RegExp(r"^([^|]+(?:\s*\|\s*[^|]+)+)$"); + /// Splits `name?(type, description)` into `name?` and the parenthetical contents. + static final RegExp _parentheticalKey = RegExp(r"^([^()]*)\((.*)\)$"); - /// Converts a Picoschema definition to JSON Schema. + /// Converts [picoschema] to JSON Schema synchronously. /// - /// The input can be: - /// - A string (primitive type, array type, or enum) - /// - A map (object schema) + /// Named schema references are looked up in [schemas] only. Use [parse] to + /// also consult an async [SchemaResolver]. /// - /// The optional [schemas] parameter provides a map of named schemas that - /// can be referenced by type strings (e.g., `Foo` to reference a `Foo` schema). + /// Values that are already JSON Schema (see [isPicoschema]) are returned + /// unchanged. A `null` input yields `{"type": "object"}`. /// - /// Returns a JSON Schema object. - /// - /// Throws [PicoschemaException] if the schema is invalid. + /// Throws [PicoschemaException] if the schema is invalid or references an + /// unknown named schema. static Map toJsonSchema( - dynamic picoschema, { - Map? schemas, - }) { - if (picoschema == null) { - return {"type": "object"}; - } - - if (picoschema is String) { - return _parseTypeString(picoschema, schemas: schemas); - } - - if (picoschema is Map) { - final schemaMap = picoschema.cast(); - // Handle synthetic $type key (string schema wrapped in a map) - if (schemaMap.containsKey(r"$type")) { - return _parseTypeString( - schemaMap[r"$type"] as String, - schemas: schemas, - ); + Object? picoschema, { + Map>? schemas, + }) => + _convert( + picoschema, + (name) => schemas?[name] ?? (throw _unknownSchema(name, hasSchemaSource: schemas != null)), + ); + + /// Converts [picoschema] to JSON Schema, resolving named schemas from + /// [schemas] first and then [schemaResolver] (same order as the JS + /// implementation). + /// + /// ```dart + /// final schema = await Picoschema.parse( + /// {'address': 'Address, where to ship'}, + /// schemaResolver: (name) async => lookupSchema(name), + /// ); + /// ``` + static Future> parse( + Object? picoschema, { + Map>? schemas, + SchemaResolver? schemaResolver, + }) async { + final resolved = >{...?schemas}; + // The converter is sync, so names needing the async resolver are discovered + // by converting, catching the first miss, resolving it and retrying. Each + // retry resolves one more distinct name and Picoschema documents are small, + // so this is cheaper to maintain than a separate reference-collecting walk. + while (true) { + try { + return _convert(picoschema, (name) => resolved[name] ?? (throw _UnresolvedSchema(name))); + } on _UnresolvedSchema catch (e) { + final schema = await schemaResolver?.call(e.name); + if (schema == null) { + throw _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null); + } + resolved[e.name] = schema; } - return _parseObjectSchema(schemaMap, schemas: schemas); } - - throw PicoschemaException( - "Invalid picoschema type: ${picoschema.runtimeType}", - ); } - /// Parses a type string into a JSON Schema. + /// Whether [schema] should be converted as Picoschema. /// - /// Handles formats like: - /// - `string` - simple type - /// - `string, the description` - type with description - /// - `string[]` - array type - /// - `foo | bar | baz` - enum type - static Map _parseTypeString( - String typeStr, { - Map? schemas, - }) { - final trimmed = typeStr.trim(); + /// Returns false when [schema] is already JSON Schema: it has a top-level + /// `type` that is a JSON Schema type, a `properties` map, or a `$schema` or + /// `$ref` key. [toJsonSchema] and [parse] apply the same check, so calling + /// this first is optional. + static bool isPicoschema(Map schema) => schema.containsKey(r"$type") || !_isJsonSchema(schema); + + static bool _isJsonSchema(Map schema) { + final type = schema["type"]; + return (type is String && _jsonSchemaTypes.contains(type)) || + schema["properties"] is Map || + schema.containsKey(r"$schema") || + schema.containsKey(r"$ref"); + } - // Check for type with description (type, description) - final commaIndex = trimmed.indexOf(","); - if (commaIndex > 0) { - final typePart = trimmed.substring(0, commaIndex).trim(); - final descPart = trimmed.substring(commaIndex + 1).trim(); - final typeSchema = _parseTypeString(typePart, schemas: schemas); - if (descPart.isNotEmpty) { - typeSchema["description"] = descPart; - } - return typeSchema; + static Map _convert(Object? schema, _SchemaLookup lookup) { + if (schema == null) { + return {"type": "object"}; } - - // Check for array type - final arrayMatch = _arrayPattern.firstMatch(trimmed); - if (arrayMatch != null) { - final itemType = arrayMatch.group(1)!.trim(); - return { - "type": "array", - "items": _parseTypeString(itemType, schemas: schemas), - }; + if (schema is String) { + return _parseTypeString(schema, lookup); } + if (schema is Map) { + final map = schema.cast(); + // Frontmatter like `schema: string` arrives wrapped as `{$type: "string"}` + // (see InputConfig/OutputConfig). + final wrapped = map[r"$type"]; + if (wrapped is String) { + return _parseTypeString(wrapped, lookup); + } + if (_isJsonSchema(map)) { + // A bare `properties` map is JSON Schema with an implied object type. + return map["type"] == null && map["properties"] is Map ? {...map, "type": "object"} : map; + } + return _parseObject(map, lookup); + } + throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $schema"); + } - // Check for enum type - final enumMatch = _enumPattern.firstMatch(trimmed); - if (enumMatch != null) { - final values = trimmed.split("|").map((s) => s.trim()).toList(); - return {"type": "string", "enum": values}; + /// Parses `type[, description]`, where `type` is a scalar or a named schema. + static Map _parseTypeString(String input, _SchemaLookup lookup) { + final (type, description) = _extractDescription(input); + final schema = switch (type) { + // JS returns `{type: "any"}` for a top-level `any`, which is not valid + // JSON Schema; `{}` (what JS returns for nested fields) is used everywhere. + "any" => {}, + _ when _scalarTypes.contains(type) => {"type": type}, + // Copy so descriptions and nullability never leak into registered schemas. + _ => {...lookup(type)}, + }; + if (description != null) { + schema["description"] = description; } + return schema; + } - // Check for primitive type - final normalizedType = trimmed.toLowerCase(); - if (normalizedType == "any") { - // 'any' type returns empty schema (allows any value) - return {}; + /// Parses the value side of an object field. + static Map _parseValue(Object? value, String key, _SchemaLookup lookup) { + if (value is String) { + return _parseTypeString(value, lookup); } - if (_primitiveTypes.containsKey(normalizedType)) { - return {"type": _primitiveTypes[normalizedType]}; + if (value is Map) { + return _parseObject(value.cast(), lookup); } - - // Check for named schema reference - if (schemas != null && schemas.containsKey(trimmed)) { - return Map.from(schemas[trimmed] as Map); + // `field:` with no value is an empty object, as in JS. + if (value == null) { + return _parseObject(const {}, lookup); } - - // Unknown type - treat as a named schema reference (return it for later resolution) - return {r"$ref": trimmed}; + throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $value (in '$key')"); } - /// Parses an object schema definition. - /// - /// Handles: - /// - Regular fields: `fieldName: type` - /// - Optional fields: `fieldName?: type` (adds null to type union) - /// - Descriptions: `fieldName(description): type` - /// - Wildcard: `(*): type` (becomes additionalProperties) - static Map _parseObjectSchema( - Map schema, { - Map? schemas, - }) { + static Map _parseObject(Map obj, _SchemaLookup lookup) { final properties = {}; final required = []; - Map? additionalProperties; + Object additionalProperties = false; - // Extended field pattern that also matches (*) - final wildcardPattern = RegExp(r"^\(\*\)(?:\(([^)]+)\))?$"); - - for (final entry in schema.entries) { - // Check for wildcard field (*) - final wildcardMatch = wildcardPattern.firstMatch(entry.key); - if (wildcardMatch != null) { - final description = wildcardMatch.group(1); - Map wildcardSchema; - if (entry.value is String) { - wildcardSchema = _parseTypeString( - entry.value as String, - schemas: schemas, - ); - } else if (entry.value is Map) { - wildcardSchema = _parseObjectSchema( - (entry.value as Map).cast(), - schemas: schemas, - ); - } else { - wildcardSchema = {}; - } - if (description != null) { - wildcardSchema["description"] = description; - } - additionalProperties = wildcardSchema; + for (final MapEntry(:key, :value) in obj.entries) { + if (key == _wildcardKey) { + additionalProperties = _parseValue(value, key, lookup); continue; } - final fieldMatch = _fieldPattern.firstMatch(entry.key); - if (fieldMatch == null) { - throw PicoschemaException("Invalid field name: ${entry.key}"); + final match = _parentheticalKey.firstMatch(key); + if (match == null && (key.contains("(") || key.contains(")"))) { + throw PicoschemaException("Picoschema: invalid property name '$key'"); } - - final fieldName = fieldMatch.group(1)!; - final isOptional = fieldMatch.group(2) == "?"; - final description = fieldMatch.group(3); - - // Parse the field value - Map fieldSchema; - if (entry.value is String) { - fieldSchema = _parseTypeString(entry.value as String, schemas: schemas); - } else if (entry.value is Map) { - fieldSchema = _parseObjectSchema( - (entry.value as Map).cast(), - schemas: schemas, - ); - } else if (entry.value is List) { - // Enum as list of values - fieldSchema = {"enum": entry.value}; - } else if (entry.value == null) { - fieldSchema = {"type": "object"}; - } else { - throw PicoschemaException( - "Invalid field value for '$fieldName': ${entry.value.runtimeType}", - ); + final name = (match?.group(1) ?? key).trim(); + final isOptional = name.endsWith("?"); + final propertyName = isOptional ? name.substring(0, name.length - 1) : name; + if (propertyName.isEmpty) { + throw PicoschemaException("Picoschema: invalid property name '$key'"); } - - // Add description if present in field name - if (description != null) { - fieldSchema["description"] = description; + if (!isOptional) { + required.add(propertyName); } - // Handle optional fields - add null to type union - if (isOptional) { - final existingType = fieldSchema["type"]; - final existingEnum = fieldSchema["enum"]; - if (existingType != null) { - if (existingType is String) { - fieldSchema["type"] = [existingType, "null"]; - } else if (existingType is List && !existingType.contains("null")) { - fieldSchema["type"] = [...existingType, "null"]; - } - } else if (existingEnum != null && existingEnum is List) { - if (!existingEnum.contains(null)) { - fieldSchema["enum"] = [...existingEnum, null]; - } - } + final parenthetical = match?.group(2); + if (parenthetical == null) { + final prop = _parseValue(value, key, lookup); + properties[propertyName] = isOptional ? _nullable(prop) : prop; + continue; } - properties[fieldName] = fieldSchema; - - // Track required fields (non-optional) - if (!isOptional) { - required.add(fieldName); + final (type, description) = _extractDescription(parenthetical); + final prop = switch (type) { + "array" => { + "type": isOptional ? ["array", "null"] : "array", + "items": _parseValue(value, key, lookup), + }, + "object" when isOptional => _nullable(_parseValue(value, key, lookup)), + "object" => _parseValue(value, key, lookup), + "enum" => _enumSchema(value, key, isOptional: isOptional), + _ => throw PicoschemaException( + "Picoschema: parenthetical types must be 'object', 'array' or 'enum', got: '$type' (in '$key')", + ), + }; + if (description != null) { + prop["description"] = description; } + properties[propertyName] = prop; } - final result = { + return { "type": "object", "properties": properties, - "additionalProperties": additionalProperties ?? false, + "additionalProperties": additionalProperties, + if (required.isNotEmpty) "required": required, }; + } - if (required.isNotEmpty) { - result["required"] = required; + static Map _enumSchema(Object? value, String key, {required bool isOptional}) { + if (value is! List) { + throw PicoschemaException("Picoschema: enum values must be a list (in '$key')"); } - - return result; + return { + "enum": [...value, if (isOptional && !value.contains(null)) null], + }; } - /// Checks if the given schema appears to be a Picoschema (vs. JSON Schema). - /// - /// Returns true if the schema looks like Picoschema and should be converted. - static bool isPicoschema(Map schema) { - // JSON Schema typically has "$schema" or "$ref" at the top level - if (schema.containsKey(r"$schema") || schema.containsKey(r"$ref")) { - return false; + /// Optional fields are also nullable. Only a single string `type` is widened, + /// matching the other runtimes. + static Map _nullable(Map schema) { + final type = schema["type"]; + if (type is String) { + schema["type"] = [type, "null"]; } + return schema; + } - // Check for our synthetic $type key (string schema wrapped in a map) - if (schema.containsKey(r"$type")) { - return true; + /// Splits `type, description` on the first comma. The description is null + /// when there is no comma or nothing after it. + static (String, String?) _extractDescription(String input) { + final comma = input.indexOf(","); + if (comma < 0) { + return (input.trim(), null); } + final description = input.substring(comma + 1).trim(); + return (input.substring(0, comma).trim(), description.isEmpty ? null : description); + } - // If it has type=object with properties, it's likely already JSON Schema - if (schema["type"] == "object" && schema.containsKey("properties")) { - return false; - } + static PicoschemaException _unknownSchema(String name, {required bool hasSchemaSource}) => PicoschemaException( + hasSchemaSource + ? "Picoschema: could not find schema with name '$name'" + : "Picoschema: unsupported scalar type '$name'.", + ); +} - // If any value is a simple type string, it's Picoschema - for (final value in schema.values) { - if (value is String) { - final normalized = value.toLowerCase(); - if (_primitiveTypes.containsKey(normalized) || _arrayPattern.hasMatch(value) || _enumPattern.hasMatch(value)) { - return true; - } - } - } +/// Signals a named schema that [Picoschema.parse] still has to resolve. +class _UnresolvedSchema implements Exception { + const _UnresolvedSchema(this.name); - // Default: assume it's JSON Schema to avoid corrupting valid schemas - return false; - } + final String name; } diff --git a/dart/dotprompt/pubspec.yaml b/dart/dotprompt/pubspec.yaml index 80a346e68..1b96fb3b9 100644 --- a/dart/dotprompt/pubspec.yaml +++ b/dart/dotprompt/pubspec.yaml @@ -19,7 +19,7 @@ description: >- Dart implementation of Dotprompt, an executable prompt template file format for Generative AI. This library provides parsing, rendering, and management of .prompt files with YAML frontmatter and Handlebars templating. -version: 1.0.1 +version: 1.1.0 license: Apache-2.0 homepage: https://github.com/google/dotprompt repository: https://github.com/google/dotprompt diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index de3e4ae1b..88d82ab4f 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -15,155 +15,513 @@ // SPDX-License-Identifier: Apache-2.0 /// Unit tests for the Picoschema class. +/// +/// Mirrors `js/src/picoschema.test.ts`; cross-runtime behavior is also covered +/// by `spec/picoschema.yaml` via `spec_test.dart`. library; import "package:dotprompt/dotprompt.dart"; -import "package:dotprompt/src/picoschema.dart"; import "package:test/test.dart"; +Map _props(Map schema) => schema["properties"] as Map; + +Matcher _throwsPicoschema(Pattern message) => + throwsA(isA().having((e) => e.message, "message", contains(message))); + void main() { - group("Picoschema", () { - group("toJsonSchema", () { - test("converts simple string type", () { - final result = Picoschema.toJsonSchema("string"); - expect(result, equals({"type": "string"})); + group("Picoschema.toJsonSchema", () { + group("scalars", () { + for (final type in ["string", "number", "integer", "boolean", "null"]) { + test("converts $type", () { + expect(Picoschema.toJsonSchema(type), equals({"type": type})); + }); + } + + test("converts any to an empty schema", () { + expect(Picoschema.toJsonSchema("any"), equals({})); + expect(Picoschema.toJsonSchema("any, anything"), equals({"description": "anything"})); + }); + + test("extracts description after the first comma", () { + expect( + Picoschema.toJsonSchema("number, the description, with commas "), + equals({"type": "number", "description": "the description, with commas"}), + ); }); - test("converts integer type", () { - final result = Picoschema.toJsonSchema("integer"); - expect(result, equals({"type": "integer"})); + test("handles null input", () { + expect(Picoschema.toJsonSchema(null), equals({"type": "object"})); }); - test("converts number type", () { - final result = Picoschema.toJsonSchema("number"); - expect(result, equals({"type": "number"})); + test("throws on unknown types without schemas", () { + expect(() => Picoschema.toJsonSchema("UndefinedType"), _throwsPicoschema("unsupported scalar type")); }); - test("converts boolean type", () { - final result = Picoschema.toJsonSchema("boolean"); - expect(result, equals({"type": "boolean"})); + test("throws on non-standard type syntax", () { + for (final type in ["string[]", "a | b", "str", "int", "String"]) { + expect(() => Picoschema.toJsonSchema({"field": type}), throwsA(isA()), reason: type); + } }); - test("converts array type", () { - final result = Picoschema.toJsonSchema("string[]"); + test("throws on invalid schema values", () { + expect(() => Picoschema.toJsonSchema(123), _throwsPicoschema("only consists of objects and strings")); expect( - result, + () => Picoschema.toJsonSchema({ + "field": [1, 2], + }), + _throwsPicoschema("only consists of objects and strings"), + ); + }); + }); + + group("JSON Schema passthrough", () { + test("returns schemas with a JSON Schema type unchanged", () { + final schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + }, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); + expect(Picoschema.toJsonSchema({"type": "string"}), equals({"type": "string"})); + }); + + test("adds type object when only properties is present", () { + expect( + Picoschema.toJsonSchema({ + "properties": { + "name": {"type": "string"}, + }, + }), equals({ - "type": "array", - "items": {"type": "string"}, + "type": "object", + "properties": { + "name": {"type": "string"}, + }, }), ); }); + }); - test("converts nested array type", () { - final result = Picoschema.toJsonSchema("integer[][]"); + group("objects", () { + test("converts fields and marks them required", () { expect( - result, + Picoschema.toJsonSchema({"name": "string", "age": "integer, in years"}), equals({ - "type": "array", - "items": { - "type": "array", - "items": {"type": "integer"}, + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "description": "in years"}, }, + "additionalProperties": false, + "required": ["name", "age"], }), ); }); - test("converts enum type", () { - final result = Picoschema.toJsonSchema("foo | bar | baz"); + test("makes optional fields nullable and not required", () { + final result = Picoschema.toJsonSchema({"name": "string", "nickname?": "string"}); expect( - result, + _props(result)["nickname"], equals({ - "type": "string", - "enum": ["foo", "bar", "baz"], + "type": ["string", "null"], }), ); + expect(result["required"], equals(["name"])); + }); + + test("omits required when every field is optional", () { + expect(Picoschema.toJsonSchema({"a?": "string"}).containsKey("required"), isFalse); }); - test("converts object schema", () { + test("converts nested objects without a qualifier", () { final result = Picoschema.toJsonSchema({ - "name": "string", - "age": "integer", + "user": {"name": "string"}, }); expect( - result, + _props(result)["user"], equals({ "type": "object", "properties": { "name": {"type": "string"}, - "age": {"type": "integer"}, }, "additionalProperties": false, - "required": ["name", "age"], + "required": ["name"], }), ); }); - test("handles optional fields", () { + test("converts (object) with description, optional", () { final result = Picoschema.toJsonSchema({ - "name": "string", - "nickname?": "string", + "obj?(object, a nested object)": {"x": "integer"}, }); - expect(result["required"], equals(["name"])); - expect((result["properties"] as Map).containsKey("nickname"), isTrue); + expect( + _props(result)["obj"], + equals({ + "type": ["object", "null"], + "description": "a nested object", + "properties": { + "x": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["x"], + }), + ); }); + }); - test("handles field descriptions", () { + group("arrays", () { + test("converts (array) of scalars", () { + expect( + Picoschema.toJsonSchema({"names(array)": "string"}), + equals({ + "type": "object", + "properties": { + "names": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "additionalProperties": false, + "required": ["names"], + }), + ); + }); + + test("puts the qualifier description on the array and the value description on items", () { + final result = Picoschema.toJsonSchema({"tags(array, list of tags)": "string, the tag"}); + expect( + _props(result)["tags"], + equals({ + "type": "array", + "description": "list of tags", + "items": {"type": "string", "description": "the tag"}, + }), + ); + }); + + test("makes optional arrays nullable", () { + final result = Picoschema.toJsonSchema({"items?(array, list of items)": "string"}); + expect( + _props(result)["items"], + equals({ + "type": ["array", "null"], + "description": "list of items", + "items": {"type": "string"}, + }), + ); + expect(result.containsKey("required"), isFalse); + }); + + test("converts arrays of objects and nested arrays", () { final result = Picoschema.toJsonSchema({ - "email(User's email address)": "string", + "items(array)": {"props(array)": "string"}, }); + final items = _props(result)["items"] as Map; + expect(items["type"], equals("array")); + final element = items["items"] as Map; + expect(element["type"], equals("object")); expect( - (result["properties"] as Map)["email"], - equals({"type": "string", "description": "User's email address"}), + _props(element)["props"], + equals({ + "type": "array", + "items": {"type": "string"}, + }), ); }); + }); - test("converts nested objects", () { + group("enums", () { + test("converts (enum)", () { final result = Picoschema.toJsonSchema({ - "user": {"name": "string", "email": "string"}, + "status(enum, the status)": ["A", "B"], }); expect( - ((result["properties"] as Map)["user"] as Map)["type"], - equals("object"), + _props(result)["status"], + equals({ + "enum": ["A", "B"], + "description": "the status", + }), ); + }); + + test("adds null to optional enums once", () { expect( - (((result["properties"] as Map)["user"] as Map)["properties"] as Map)["name"], - equals({"type": "string"}), + _props( + Picoschema.toJsonSchema({ + "c?(enum)": ["A"], + }), + )["c"], + equals({ + "enum": ["A", null], + }), + ); + expect( + _props( + Picoschema.toJsonSchema({ + "c?(enum)": ["A", null], + }), + )["c"], + equals({ + "enum": ["A", null], + }), ); }); - test("handles null input", () { - final result = Picoschema.toJsonSchema(null); - expect(result, equals({"type": "object"})); + test("throws when enum values are not a list", () { + expect(() => Picoschema.toJsonSchema({"c(enum)": "A"}), _throwsPicoschema("enum values must be a list")); }); }); - group("isPicoschema", () { - test("returns true for Picoschema", () { - expect(Picoschema.isPicoschema({"name": "string"}), isTrue); + group("wildcards", () { + test("maps (*) to additionalProperties", () { + expect( + Picoschema.toJsonSchema({"other": "string", "(*)": "any, whatever you want"}), + equals({ + "type": "object", + "properties": { + "other": {"type": "string"}, + }, + "additionalProperties": {"description": "whatever you want"}, + "required": ["other"], + }), + ); }); - test(r"returns false for JSON Schema with $schema", () { + test("supports wildcard-only objects", () { expect( - Picoschema.isPicoschema({ - r"$schema": "http://json-schema.org/draft-07/schema#", + Picoschema.toJsonSchema({"(*)": "number, lucky number"}), + equals({ "type": "object", + "properties": {}, + "additionalProperties": {"type": "number", "description": "lucky number"}, }), - isFalse, ); }); + }); - test("returns false for JSON Schema with type object and properties", () { + group("invalid parentheticals", () { + test("rejects free-text descriptions in parentheses", () { expect( - Picoschema.isPicoschema({ - "type": "object", - "properties": { - "name": {"type": "string"}, - }, + () => Picoschema.toJsonSchema({"email(User's email address)": "string"}), + _throwsPicoschema("parenthetical types must be 'object', 'array' or 'enum', got: 'User's email address'"), + ); + }); + + test("rejects name(*)", () { + expect(() => Picoschema.toJsonSchema({"wild(*)": "string"}), _throwsPicoschema("got: '*'")); + }); + + test("rejects malformed keys", () { + expect(() => Picoschema.toJsonSchema({"bad(array": "string"}), _throwsPicoschema("invalid property name")); + expect(() => Picoschema.toJsonSchema({"(array)": "string"}), _throwsPicoschema("invalid property name")); + }); + }); + + group("named schemas", () { + final schemas = { + "Foo": {"type": "number", "description": "a foo"}, + }; + + test("resolves from schemas and overrides the description", () { + expect(Picoschema.toJsonSchema("Foo", schemas: schemas), equals({"type": "number", "description": "a foo"})); + expect( + Picoschema.toJsonSchema("Foo, an overridden foo", schemas: schemas), + equals({"type": "number", "description": "an overridden foo"}), + ); + }); + + test("makes optional references nullable without mutating the registered schema", () { + final result = Picoschema.toJsonSchema({"foo?": "Foo"}, schemas: schemas); + expect( + _props(result)["foo"], + equals({ + "type": ["number", "null"], + "description": "a foo", }), - isFalse, ); + expect(schemas["Foo"], equals({"type": "number", "description": "a foo"})); }); + + test("throws when a named schema is missing", () { + expect(() => Picoschema.toJsonSchema("Bar", schemas: schemas), _throwsPicoschema("could not find schema")); + }); + }); + }); + + group("Picoschema.parse", () { + test("resolves named schemas via the async resolver", () async { + final requested = []; + final result = await Picoschema.parse( + {"a": "AsyncType, first", "b?": "AsyncType", "c": "Other"}, + schemaResolver: (name) async { + requested.add(name); + return switch (name) { + "AsyncType" => {"type": "number"}, + "Other" => {"type": "string"}, + _ => null, + }; + }, + ); + expect( + _props(result), + equals({ + "a": {"type": "number", "description": "first"}, + "b": { + "type": ["number", "null"], + }, + "c": {"type": "string"}, + }), + ); + expect(requested, equals(["AsyncType", "Other"])); + }); + + test("prefers schemas over the resolver", () async { + final result = await Picoschema.parse( + "Foo", + schemas: { + "Foo": {"type": "integer"}, + }, + schemaResolver: (_) async => fail("resolver should not be called"), + ); + expect(result, equals({"type": "integer"})); + }); + + test("throws when the resolver returns null", () async { + await expectLater( + Picoschema.parse("Missing", schemaResolver: (_) async => null), + _throwsPicoschema("could not find schema with name 'Missing'"), + ); + }); + + test("throws for unknown types without any schema source", () async { + await expectLater(Picoschema.parse("Missing"), _throwsPicoschema("unsupported scalar type")); + }); + }); + + group("Picoschema.isPicoschema", () { + test("returns true for Picoschema", () { + expect(Picoschema.isPicoschema({"name": "string"}), isTrue); + expect(Picoschema.isPicoschema({"name": "string, the name"}), isTrue); + expect( + Picoschema.isPicoschema({ + "obj(object)": {"x": "integer"}, + }), + isTrue, + ); + expect( + Picoschema.isPicoschema({ + "status(enum)": ["A"], + }), + isTrue, + ); + expect(Picoschema.isPicoschema({"(*)": "string"}), isTrue); + expect(Picoschema.isPicoschema({r"$type": "string"}), isTrue); + }); + + test("returns false for JSON Schema", () { + expect(Picoschema.isPicoschema({"type": "string"}), isFalse); + expect( + Picoschema.isPicoschema({ + "properties": { + "a": {"type": "string"}, + }, + }), + isFalse, + ); + expect(Picoschema.isPicoschema({r"$schema": "http://json-schema.org/draft-07/schema#"}), isFalse); + expect(Picoschema.isPicoschema({r"$ref": "#/defs/Foo"}), isFalse); + }); + }); + + // https://github.com/genkit-ai/genkit-dart/issues/562 + group("issue #562: parenthetical qualifiers via Dotprompt", () { + test("renders (array), (object), (enum) and (*) per spec", () async { + final metadata = await Dotprompt().renderMetadata(""" +--- +output: + schema: + tags(array): string + tags3(array, the tags): string + obj(object): + x: integer + status(enum): [A, B] + steps(array): + number: integer + instruction: string + (*): string +--- +hi +"""); + expect( + metadata.output?.schema, + equals({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + }, + "tags3": { + "type": "array", + "description": "the tags", + "items": {"type": "string"}, + }, + "obj": { + "type": "object", + "properties": { + "x": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["x"], + }, + "status": { + "enum": ["A", "B"], + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": {"type": "integer"}, + "instruction": {"type": "string"}, + }, + "additionalProperties": false, + "required": ["number", "instruction"], + }, + }, + }, + "additionalProperties": {"type": "string"}, + "required": ["tags", "tags3", "obj", "status", "steps"], + }), + ); + }); + + test("resolves named schemas through DotpromptOptions.schemaResolver", () async { + final dotprompt = Dotprompt( + DotpromptOptions( + schemaResolver: (name) async => name == "Address" ? {"type": "object", "description": "an address"} : null, + ), + ); + final metadata = await dotprompt.renderMetadata(""" +--- +input: + schema: + shipTo: Address +--- +hi +"""); + expect( + _props(metadata.input!.schema!)["shipTo"], + equals({"type": "object", "description": "an address"}), + ); + }); + + test("rejects name(*)", () async { + await expectLater( + Dotprompt().renderMetadata("---\noutput:\n schema:\n wild(*): string\n---\nhi"), + throwsA(isA()), + ); }); }); } diff --git a/dart/dotprompt/test/spec_test.dart b/dart/dotprompt/test/spec_test.dart index bd417a45d..8cdd747b7 100644 --- a/dart/dotprompt/test/spec_test.dart +++ b/dart/dotprompt/test/spec_test.dart @@ -95,6 +95,9 @@ void main() { final tests = specMap["tests"] as List?; final partials = specMap["partials"] as Map?; final resolverPartials = specMap["resolverPartials"] as Map?; + final schemas = (specMap["schemas"] as Map?)?.map( + (name, schema) => MapEntry(name, schema as Map), + ); if (tests == null) continue; @@ -116,6 +119,7 @@ void main() { final dotpromptOptions = DotpromptOptions( partials: {...?partials?.cast()}, partialResolver: resolverPartials != null ? (name) async => resolverPartials[name] as String? : null, + schemas: schemas, ); final dotprompt = Dotprompt(dotpromptOptions); @@ -250,6 +254,19 @@ void main() { ); } } + + if (expected.containsKey("output")) { + final expectedOutput = expected["output"] as Map; + final actualOutput = result.output; + expect(actualOutput, isNotNull); + for (final entry in expectedOutput.entries) { + expect( + _deepEquals(actualOutput![entry.key], entry.value), + isTrue, + reason: "Output key '${entry.key}' mismatch: got ${actualOutput[entry.key]}", + ); + } + } }); } }); diff --git a/docs/api/dart/index.md b/docs/api/dart/index.md index bf796fc68..d67d8e70b 100644 --- a/docs/api/dart/index.md +++ b/docs/api/dart/index.md @@ -21,7 +21,7 @@ void main() async { final result = await dotprompt.render(''' --- -model: gemini-pro +model: googleai/gemini-flash-latest input: schema: name: string @@ -250,17 +250,29 @@ abstract interface class PromptStore { ## Picoschema -Convert Picoschema to JSON Schema. +Convert Picoschema to JSON Schema. `Dotprompt` does this automatically for +`input.schema` and `output.schema`; see the +[Picoschema reference](../../extending/picoschema.md) for the syntax. ```dart -import 'package:dotprompt/src/picoschema.dart'; +import 'package:dotprompt/dotprompt.dart'; final schema = { 'name': 'string', - 'age?': 'integer, The person\'s age', + 'age?': 'integer, the person\'s age', + 'tags(array, relevant tags)': 'string', + 'status(enum)': ['ACTIVE', 'INACTIVE'], + 'address': 'Address', }; -final jsonSchema = Picoschema.toJsonSchema(schema); +// Sync: named schemas come from the `schemas` map. +final jsonSchema = Picoschema.toJsonSchema(schema, schemas: {'Address': addressSchema}); + +// Async: `schemas` first, then `schemaResolver`. +final resolved = await Picoschema.parse( + schema, + schemaResolver: (name) async => lookupSchema(name), +); ``` ## Built-in Helpers From a608d56691adeba00a1bd4bcd5e6c5ac2c939287 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sat, 3 Oct 2026 16:14:34 -0400 Subject: [PATCH 2/8] docs(dart): clarify registered schemas are JSON Schema; fix install version --- dart/dotprompt/CHANGELOG.md | 3 +++ dart/dotprompt/README.md | 2 +- dart/dotprompt/lib/dotprompt.dart | 2 +- dart/dotprompt/lib/src/dotprompt.dart | 16 +++++++++++++--- dart/dotprompt/lib/src/models/parsed_prompt.dart | 4 ++-- .../lib/src/models/prompt_metadata.dart | 2 +- .../lib/src/models/rendered_prompt.dart | 2 +- dart/dotprompt/lib/src/parse.dart | 6 +++--- dart/dotprompt/lib/src/picoschema.dart | 5 +++-- dart/dotprompt/lib/src/store.dart | 3 ++- docs/api/dart/index.md | 2 +- 11 files changed, 31 insertions(+), 16 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index 0cf242ad0..6c6203c38 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -30,6 +30,9 @@ All notable changes to dotprompt-dart will be documented in this file. `wild(*)`; - non-standard types (`string[]`, `a | b`, aliases like `int`/`str`); - unknown named schemas. Previously these became `{"$ref": name}`. +- `DotpromptOptions.schemas` and `defineSchema` are documented as taking JSON + Schema (as in the other runtimes), not Picoschema. Registered schemas are + inserted as-is; convert Picoschema with `Picoschema.toJsonSchema` first. ### Added diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 1707945c2..390e586f1 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^1.0.0 + dotprompt: ^1.1.0 ``` ## Quick Start diff --git a/dart/dotprompt/lib/dotprompt.dart b/dart/dotprompt/lib/dotprompt.dart index 1888fd30c..8dc283026 100644 --- a/dart/dotprompt/lib/dotprompt.dart +++ b/dart/dotprompt/lib/dotprompt.dart @@ -36,7 +36,7 @@ /// final dotprompt = Dotprompt(); /// final template = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''; diff --git a/dart/dotprompt/lib/src/dotprompt.dart b/dart/dotprompt/lib/src/dotprompt.dart index 4aef53460..13fcfe06b 100644 --- a/dart/dotprompt/lib/src/dotprompt.dart +++ b/dart/dotprompt/lib/src/dotprompt.dart @@ -36,7 +36,7 @@ /// // Parse a template /// final parsed = dotprompt.parse(''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''); @@ -98,7 +98,10 @@ class DotpromptOptions { /// Pre-registered tool definitions. final Map? tools; - /// Pre-registered schemas (Picoschema or JSON Schema). + /// Pre-registered JSON Schemas, referenced by name from Picoschema. + /// + /// Values must already be JSON Schema; they are inserted as-is (same as the + /// other runtimes). Convert Picoschema first with [Picoschema.toJsonSchema]. final Map>? schemas; /// Resolver for loading partial templates dynamically. @@ -155,7 +158,14 @@ class Dotprompt { _tools[definition.name] = definition; } - /// Defines a schema (Picoschema or JSON Schema). + /// Registers a named JSON Schema that Picoschema can reference by name. + /// + /// [schema] must already be JSON Schema; it is inserted as-is. To register a + /// schema written in Picoschema, convert it first: + /// + /// ```dart + /// dotprompt.defineSchema('Address', Picoschema.toJsonSchema({'street': 'string', 'zip': 'integer'})); + /// ``` void defineSchema(String name, Map schema) { _schemas[name] = schema; } diff --git a/dart/dotprompt/lib/src/models/parsed_prompt.dart b/dart/dotprompt/lib/src/models/parsed_prompt.dart index d156b0060..71d23804d 100644 --- a/dart/dotprompt/lib/src/models/parsed_prompt.dart +++ b/dart/dotprompt/lib/src/models/parsed_prompt.dart @@ -34,7 +34,7 @@ import "prompt_metadata.dart"; /// ```dart /// final source = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// --- @@ -43,7 +43,7 @@ import "prompt_metadata.dart"; /// /// final parsed = Parser.parseDocument(source); /// print(parsed.template); // "Hello {{name}}!" -/// print(parsed.model); // "gemini-pro" +/// print(parsed.model); // "googleai/gemini-flash-latest" /// ``` @immutable class ParsedPrompt { diff --git a/dart/dotprompt/lib/src/models/prompt_metadata.dart b/dart/dotprompt/lib/src/models/prompt_metadata.dart index e64b52e41..b33207b79 100644 --- a/dart/dotprompt/lib/src/models/prompt_metadata.dart +++ b/dart/dotprompt/lib/src/models/prompt_metadata.dart @@ -45,7 +45,7 @@ import "parsed_prompt.dart"; /// /// ```yaml /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// maxOutputTokens: 1024 diff --git a/dart/dotprompt/lib/src/models/rendered_prompt.dart b/dart/dotprompt/lib/src/models/rendered_prompt.dart index 351356e67..e7b9910e7 100644 --- a/dart/dotprompt/lib/src/models/rendered_prompt.dart +++ b/dart/dotprompt/lib/src/models/rendered_prompt.dart @@ -35,7 +35,7 @@ import "../types.dart"; /// final dotprompt = Dotprompt(); /// final result = dotprompt.render(template, data); /// -/// print(result.config['model']); // "gemini-pro" +/// print(result.config['model']); // "googleai/gemini-flash-latest" /// for (final message in result.messages) { /// print('${message.role}: ${message.content}'); /// } diff --git a/dart/dotprompt/lib/src/parse.dart b/dart/dotprompt/lib/src/parse.dart index a1016f238..fc09cd016 100644 --- a/dart/dotprompt/lib/src/parse.dart +++ b/dart/dotprompt/lib/src/parse.dart @@ -26,7 +26,7 @@ /// /// ``` /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// config: /// temperature: 0.7 /// --- @@ -60,13 +60,13 @@ final RegExp _frontmatterPattern = RegExp( /// ```dart /// final source = ''' /// --- -/// model: gemini-pro +/// model: googleai/gemini-flash-latest /// --- /// Hello {{name}}! /// '''; /// /// final parsed = Parser.parseDocument(source); -/// print(parsed.model); // "gemini-pro" +/// print(parsed.model); // "googleai/gemini-flash-latest" /// print(parsed.template); // "Hello {{name}}!" /// ``` class Parser { diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index e683e45fc..106265a87 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -84,7 +84,8 @@ class Picoschema { /// Converts [picoschema] to JSON Schema synchronously. /// /// Named schema references are looked up in [schemas] only. Use [parse] to - /// also consult an async [SchemaResolver]. + /// also consult an async [SchemaResolver]. Registered schemas must already + /// be JSON Schema; they are inserted as-is, matching the other runtimes. /// /// Values that are already JSON Schema (see [isPicoschema]) are returned /// unchanged. A `null` input yields `{"type": "object"}`. @@ -102,7 +103,7 @@ class Picoschema { /// Converts [picoschema] to JSON Schema, resolving named schemas from /// [schemas] first and then [schemaResolver] (same order as the JS - /// implementation). + /// implementation). Both must provide JSON Schema; it is inserted as-is. /// /// ```dart /// final schema = await Picoschema.parse( diff --git a/dart/dotprompt/lib/src/store.dart b/dart/dotprompt/lib/src/store.dart index e4ef6c5ee..50181fed1 100644 --- a/dart/dotprompt/lib/src/store.dart +++ b/dart/dotprompt/lib/src/store.dart @@ -155,5 +155,6 @@ typedef DotpromptPartialResolver = Future Function(String name); /// Function type for resolving tool definitions. typedef ToolResolver = Future?> Function(String name); -/// Function type for resolving schemas. +/// Function type for resolving named schemas. Returns JSON Schema, or null if +/// the name is unknown. typedef SchemaResolver = Future?> Function(String name); diff --git a/docs/api/dart/index.md b/docs/api/dart/index.md index d67d8e70b..11bcd282e 100644 --- a/docs/api/dart/index.md +++ b/docs/api/dart/index.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^0.0.1 + dotprompt: ^1.1.0 ``` ## Quick Start From 989bf92073d134305bda17ac189d510b252eab7c Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sat, 3 Oct 2026 20:29:19 -0400 Subject: [PATCH 3/8] fix(dart): pass through JSON Schema without a single type string Treat top-level anyOf/oneOf/allOf/enum lists, list-valued type, items and $defs as JSON Schema so they are not misparsed as Picoschema (e.g. via additionalMetadata). Keywords are matched by value shape so Picoschema fields named items/enum still parse. --- dart/dotprompt/CHANGELOG.md | 7 ++- dart/dotprompt/lib/src/picoschema.dart | 21 ++++++- dart/dotprompt/test/picoschema_test.dart | 76 +++++++++++++++++++++++- 3 files changed, 97 insertions(+), 7 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index 6c6203c38..ad5d069f7 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to dotprompt-dart will be documented in this file. -## [1.1.0] +## [1.1.0] - 2026-10-03 ### Fixed @@ -13,8 +13,9 @@ All notable changes to dotprompt-dart will be documented in this file. the parenthesized qualifier was treated as a description. - `(*)` wildcards and every Picoschema form are always converted. Previously some schemas skipped conversion and were passed through raw. - - Top-level JSON Schema (`type: string`, a bare `properties` map) is passed - through instead of being parsed as Picoschema. + - Top-level JSON Schema (`type: string`, a bare `properties` map, `anyOf`, + `enum`, `type: [string, "null"]`, etc.) is passed through instead of being + parsed as Picoschema. - Named schemas are resolved via `DotpromptOptions.schemaResolver` as well as `schemas`/`defineSchema`. - The spec test runner now checks `output` and named `schemas`, so diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index 106265a87..4f36c5cb2 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -137,15 +137,30 @@ class Picoschema { /// Whether [schema] should be converted as Picoschema. /// /// Returns false when [schema] is already JSON Schema: it has a top-level - /// `type` that is a JSON Schema type, a `properties` map, or a `$schema` or - /// `$ref` key. [toJsonSchema] and [parse] apply the same check, so calling - /// this first is optional. + /// `type` that is a JSON Schema type (or a list of them), a `properties` + /// map, or a structural keyword such as `$ref`, `items`, `anyOf` or `enum`. + /// [toJsonSchema] and [parse] apply the same check, so calling this first is + /// optional. static bool isPicoschema(Map schema) => schema.containsKey(r"$type") || !_isJsonSchema(schema); + /// Top-level keywords whose value is a list in JSON Schema. + static const Set _jsonSchemaListKeywords = {"anyOf", "oneOf", "allOf", "enum"}; + + // Broader than JS, which only checks `type` and `properties` and misparses + // the rest. Keywords are matched by value shape, not just key, because + // `items: string` or `enum: string` are valid Picoschema fields. A + // Picoschema field value is never a list (only `(enum)` keys take lists), + // so list-valued keywords are unambiguous. static bool _isJsonSchema(Map schema) { final type = schema["type"]; + final items = schema["items"]; return (type is String && _jsonSchemaTypes.contains(type)) || + (type is List && type.isNotEmpty && type.every(_jsonSchemaTypes.contains)) || schema["properties"] is Map || + items is Map || + items is List || + schema[r"$defs"] is Map || + _jsonSchemaListKeywords.any((k) => schema[k] is List) || schema.containsKey(r"$schema") || schema.containsKey(r"$ref"); } diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index 88d82ab4f..cbc60b7c8 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -101,6 +101,61 @@ void main() { }), ); }); + + test("returns schemas without a single type string unchanged", () { + final schemas = >[ + { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + { + "allOf": [ + {"type": "object"}, + ], + }, + { + "enum": ["a", "b"], + }, + { + "type": ["string", "null"], + }, + { + "items": {"type": "string"}, + }, + { + r"$defs": { + "A": {"type": "string"}, + }, + r"$ref": r"#/$defs/A", + }, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("still parses Picoschema fields named like JSON Schema keywords", () { + expect( + Picoschema.toJsonSchema({"items": "string", "enum": "integer"}), + equals({ + "type": "object", + "properties": { + "items": {"type": "string"}, + "enum": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["items", "enum"], + }), + ); + }); }); group("objects", () { @@ -431,11 +486,30 @@ void main() { ); expect(Picoschema.isPicoschema({r"$schema": "http://json-schema.org/draft-07/schema#"}), isFalse); expect(Picoschema.isPicoschema({r"$ref": "#/defs/Foo"}), isFalse); + expect( + Picoschema.isPicoschema({ + "anyOf": [ + {"type": "string"}, + ], + }), + isFalse, + ); + }); + + test("passes JSON Schema from additionalMetadata through Dotprompt", () async { + final schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + }; + final metadata = await Dotprompt().renderMetadata("hi", PromptMetadata(output: OutputConfig(schema: schema))); + expect(metadata.output!.schema, equals(schema)); }); }); // https://github.com/genkit-ai/genkit-dart/issues/562 - group("issue #562: parenthetical qualifiers via Dotprompt", () { + group("genkit-dart#562: parenthetical qualifiers via Dotprompt", () { test("renders (array), (object), (enum) and (*) per spec", () async { final metadata = await Dotprompt().renderMetadata(""" --- From cf537f8397a73a72fe3d67057744dfe2875490eb Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sat, 3 Oct 2026 21:18:17 -0400 Subject: [PATCH 4/8] fix(dart): never pass Picoschema through as JSON Schema Tighten JSON Schema detection so Picoschema fields that coincide with JSON Schema keywords are converted instead of passed through raw: - `items`/`$defs` with a map value no longer imply JSON Schema. - A scalar `type` only implies JSON Schema when all siblings are JSON Schema keywords whose values are not Picoschema type strings, so `{type: string, payload: string}` is a Picoschema object. - `properties` only implies JSON Schema when its values are JSON Schemas. - Picoschema key syntax (`a?`, `a(array)`, `(*)`) always means Picoschema. Also: - `input: Name` / `output: Name` resolves the named schema instead of producing a raw `{$ref: Name}`. - Duplicate property names (`a` and `a?`) throw. - `x?: null` yields `{type: null}` instead of `[null, null]`. --- dart/dotprompt/CHANGELOG.md | 9 +- .../lib/src/models/prompt_metadata.dart | 17 +- dart/dotprompt/lib/src/picoschema.dart | 89 ++++++-- dart/dotprompt/test/picoschema_test.dart | 214 +++++++++++++++++- 4 files changed, 302 insertions(+), 27 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index ad5d069f7..ac47d4a4d 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -16,8 +16,14 @@ All notable changes to dotprompt-dart will be documented in this file. - Top-level JSON Schema (`type: string`, a bare `properties` map, `anyOf`, `enum`, `type: [string, "null"]`, etc.) is passed through instead of being parsed as Picoschema. + - Picoschema fields named like JSON Schema keywords (`type: string`, + `properties:`, `items:`) are converted instead of being mistaken for JSON + Schema and passed through raw. + - `x?: null` produces `{type: null}` instead of `{type: [null, null]}`. - Named schemas are resolved via `DotpromptOptions.schemaResolver` as well as `schemas`/`defineSchema`. + - The `input: Name` / `output: Name` shorthand resolves the named schema. + Previously it became a raw `{"$ref": name}`. - The spec test runner now checks `output` and named `schemas`, so `spec/picoschema.yaml` is actually enforced. @@ -30,7 +36,8 @@ All notable changes to dotprompt-dart will be documented in this file. - parenthetical types other than `array`, `object` and `enum`, e.g. `wild(*)`; - non-standard types (`string[]`, `a | b`, aliases like `int`/`str`); - - unknown named schemas. Previously these became `{"$ref": name}`. + - unknown named schemas. Previously these became `{"$ref": name}`; + - duplicate property names such as `a` and `a?` in the same object. - `DotpromptOptions.schemas` and `defineSchema` are documented as taking JSON Schema (as in the other runtimes), not Picoschema. Registered schemas are inserted as-is; convert Picoschema with `Picoschema.toJsonSchema` first. diff --git a/dart/dotprompt/lib/src/models/prompt_metadata.dart b/dart/dotprompt/lib/src/models/prompt_metadata.dart index b33207b79..06a1e3372 100644 --- a/dart/dotprompt/lib/src/models/prompt_metadata.dart +++ b/dart/dotprompt/lib/src/models/prompt_metadata.dart @@ -200,11 +200,13 @@ class InputConfig { /// Creates an [InputConfig] from a value that can be a String or Map. /// - /// If the value is a String, it's treated as a schema name reference. + /// If the value is a String (`input: MySchema`), it's treated as a Picoschema + /// type string, so named schemas are resolved during metadata resolution. factory InputConfig.fromValue(dynamic value) { if (value is String) { - // String value is a schema name reference - return InputConfig(schema: {r"$ref": value}); + // Same wrapping as `schema: MySchema` in fromJson, so the name is + // resolved (or rejected) instead of passing through as a raw `$ref`. + return InputConfig(schema: {r"$type": value}); } else if (value is Map) { return InputConfig.fromJson(value); } @@ -259,11 +261,14 @@ class OutputConfig { /// Creates an [OutputConfig] from a value that can be a String or Map. /// - /// If the value is a String, it's treated as a schema name reference. + /// If the value is a String (`output: MySchema`), it's treated as a + /// Picoschema type string, so named schemas are resolved during metadata + /// resolution. factory OutputConfig.fromValue(dynamic value) { if (value is String) { - // String value is a schema name reference - return OutputConfig(schema: {r"$ref": value}); + // Same wrapping as `schema: MySchema` in fromJson, so the name is + // resolved (or rejected) instead of passing through as a raw `$ref`. + return OutputConfig(schema: {r"$type": value}); } else if (value is Map) { return OutputConfig.fromJson(value); } diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index 4f36c5cb2..57886fbe1 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -136,35 +136,85 @@ class Picoschema { /// Whether [schema] should be converted as Picoschema. /// - /// Returns false when [schema] is already JSON Schema: it has a top-level - /// `type` that is a JSON Schema type (or a list of them), a `properties` - /// map, or a structural keyword such as `$ref`, `items`, `anyOf` or `enum`. - /// [toJsonSchema] and [parse] apply the same check, so calling this first is - /// optional. + /// Returns false when [schema] is already JSON Schema, i.e. it has one of: + /// - a top-level `type` that is a JSON Schema type, with every other key a + /// JSON Schema keyword whose value is not a Picoschema type string; + /// - a list of JSON Schema types as `type`; + /// - a `properties` map whose values are all JSON Schemas (booleans, or maps + /// keyed only by JSON Schema keywords); + /// - a list-valued `anyOf`/`oneOf`/`allOf`/`enum`; + /// - a `$schema` or `$ref` key. + /// + /// Keys using Picoschema syntax (`name?`, `name(array)`, `(*)`) always mean + /// Picoschema. [toJsonSchema] and [parse] apply the same check, so calling + /// this first is optional. static bool isPicoschema(Map schema) => schema.containsKey(r"$type") || !_isJsonSchema(schema); /// Top-level keywords whose value is a list in JSON Schema. static const Set _jsonSchemaListKeywords = {"anyOf", "oneOf", "allOf", "enum"}; - // Broader than JS, which only checks `type` and `properties` and misparses - // the rest. Keywords are matched by value shape, not just key, because - // `items: string` or `enum: string` are valid Picoschema fields. A - // Picoschema field value is never a list (only `(enum)` keys take lists), - // so list-valued keywords are unambiguous. + /// JSON Schema (2020-12 plus common legacy/OpenAPI) keywords. `$`- and + /// `x-`-prefixed keys are accepted separately. + static const Set _jsonSchemaKeywords = { + // Applicators. + "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "dependentSchemas", "prefixItems", "items", + "contains", "properties", "patternProperties", "additionalProperties", "propertyNames", + "unevaluatedItems", "unevaluatedProperties", + // Validation. + "type", "enum", "const", "multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", + "maxLength", "minLength", "pattern", "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", + "maxProperties", "minProperties", "required", "dependentRequired", + // Annotations, format and content. + "title", "description", "default", "deprecated", "readOnly", "writeOnly", "examples", "format", + "contentEncoding", "contentMediaType", "contentSchema", + // Legacy drafts and OpenAPI/Gemini extensions. + "definitions", "dependencies", "additionalItems", "nullable", "example", "propertyOrdering", + }; + + // Broader and stricter than JS, which only checks `type` and `properties`. + // The goal is that Picoschema is never passed through raw: + // - Picoschema key syntax and list-valued keywords are unambiguous (a + // Picoschema field value is never a list; only `(enum)` keys take lists). + // - A scalar `type` alone is not enough: `{type: string, payload: string}` + // is a Picoschema object with a field named `type`. Siblings must be JSON + // Schema keywords, and values like `title: string` read as Picoschema + // fields. The cost is that a JSON Schema whose title/default is literally + // a type name (e.g. `default: string`) is parsed as Picoschema. + // - Map-valued keywords like `items` or `$defs` are not used on their own, + // because `items: {sku: string}` is a normal Picoschema nested object. static bool _isJsonSchema(Map schema) { + if (schema.keys.any(_hasPicoschemaKeySyntax)) { + return false; + } final type = schema["type"]; - final items = schema["items"]; - return (type is String && _jsonSchemaTypes.contains(type)) || + final properties = schema["properties"]; + return (type is String && _jsonSchemaTypes.contains(type) && schema.entries.every(_isJsonSchemaEntry)) || (type is List && type.isNotEmpty && type.every(_jsonSchemaTypes.contains)) || - schema["properties"] is Map || - items is Map || - items is List || - schema[r"$defs"] is Map || + (properties is Map && properties.values.every(_isSubschema)) || _jsonSchemaListKeywords.any((k) => schema[k] is List) || schema.containsKey(r"$schema") || schema.containsKey(r"$ref"); } + static bool _hasPicoschemaKeySyntax(String key) => key.endsWith("?") || key.contains("(") || key.contains(")"); + + static bool _isJsonSchemaKeyword(Object? key) => + key is String && (_jsonSchemaKeywords.contains(key) || key.startsWith(r"$") || key.startsWith("x-")); + + /// Whether a `properties` value is a JSON Schema: a boolean or a map keyed + /// only by JSON Schema keywords (`{}` included). Rejects Picoschema like + /// `properties: {color: string}` or `properties: {address: {street: string}}`. + static bool _isSubschema(Object? value) => value is bool || (value is Map && value.keys.every(_isJsonSchemaKeyword)); + + /// Whether a top-level entry next to a scalar `type` reads as JSON Schema. + static bool _isJsonSchemaEntry(MapEntry entry) { + final MapEntry(:key, :value) = entry; + if (key == "type") { + return true; + } + return _isJsonSchemaKeyword(key) && !(value is String && _scalarTypes.contains(_extractDescription(value).$1)); + } + static Map _convert(Object? schema, _SchemaLookup lookup) { if (schema == null) { return {"type": "object"}; @@ -242,6 +292,11 @@ class Picoschema { if (propertyName.isEmpty) { throw PicoschemaException("Picoschema: invalid property name '$key'"); } + // `a` and `a?` (or `a(array)`) map to the same property. JS lets the last + // key win and leaves `required` inconsistent; reject it instead. + if (properties.containsKey(propertyName)) { + throw PicoschemaException("Picoschema: duplicate property '$propertyName' (in '$key')"); + } if (!isOptional) { required.add(propertyName); } @@ -293,7 +348,7 @@ class Picoschema { /// matching the other runtimes. static Map _nullable(Map schema) { final type = schema["type"]; - if (type is String) { + if (type is String && type != "null") { schema["type"] = [type, "null"]; } return schema; diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index cbc60b7c8..b9bc2e345 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -127,9 +127,6 @@ void main() { { "type": ["string", "null"], }, - { - "items": {"type": "string"}, - }, { r"$defs": { "A": {"type": "string"}, @@ -156,6 +153,149 @@ void main() { }), ); }); + + test("parses map-valued fields named like JSON Schema keywords as nested objects", () { + final expected = { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "qty": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["sku", "qty"], + }; + for (final key in ["items", r"$defs"]) { + expect( + Picoschema.toJsonSchema({ + key: {"sku": "string", "qty": "integer"}, + }), + equals({ + "type": "object", + "properties": {key: expected}, + "additionalProperties": false, + "required": [key], + }), + reason: key, + ); + } + }); + + test("passes through JSON Schema with annotations and extension keys", () { + final schemas = >[ + { + "type": "object", + "title": "Person", + "description": "a person", + "properties": { + "name": {"type": "string", "x-order": 1}, + "extra": {}, + "anything": true, + }, + "required": ["name"], + "examples": [ + {"name": "Ann"}, + ], + "x-internal": true, + }, + {"type": "string", "format": "email", "minLength": 3, "nullable": true}, + {"type": "string", "description": "a person, really"}, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + }); + + // Picoschema fields whose names coincide with JSON Schema keywords must be + // converted, never passed through raw. + group("fields named like JSON Schema keywords", () { + test("treats a scalar-valued type field as Picoschema", () { + for (final schema in >[ + {"type": "string", "payload": "string"}, + {"type": "string", "title": "string"}, + {"type": "string", "description": "string, the description"}, + ]) { + final result = Picoschema.toJsonSchema(schema); + expect(result["type"], equals("object"), reason: "$schema"); + expect(_props(result).keys, equals(schema.keys), reason: "$schema"); + expect(_props(result)["type"], isA>(), reason: "$schema"); + } + }); + + test("fails loudly instead of passing through a non-scalar type field", () { + // `object` is not a Picoschema scalar, so this is invalid Picoschema + // rather than JSON Schema with a stray `name` keyword. + expect( + () => Picoschema.toJsonSchema({"type": "object", "name": "string"}), + _throwsPicoschema("unsupported scalar type 'object'"), + ); + }); + + test("treats Picoschema key syntax next to type as Picoschema", () { + expect( + Picoschema.toJsonSchema({"type": "string", "tags(array)": "string", "note?": "string"}), + equals({ + "type": "object", + "properties": { + "type": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"}, + }, + "note": { + "type": ["string", "null"], + }, + }, + "additionalProperties": false, + "required": ["type", "tags"], + }), + ); + }); + + test("treats a properties field with Picoschema values as Picoschema", () { + for (final properties in >[ + {"color": "string"}, + { + "address": {"street": "string"}, + }, + ]) { + final result = Picoschema.toJsonSchema({"properties": properties, "name": "string"}); + expect(_props(result).keys, equals(["properties", "name"]), reason: "$properties"); + expect(_props(result)["properties"], containsPair("type", "object"), reason: "$properties"); + } + }); + + test("converts type and properties fields from frontmatter", () async { + final metadata = await Dotprompt().renderMetadata(""" +--- +output: + schema: + type: string, event kind + properties: + color: string +--- +hi +"""); + expect( + metadata.output?.schema, + equals({ + "type": "object", + "properties": { + "type": {"type": "string", "description": "event kind"}, + "properties": { + "type": "object", + "properties": { + "color": {"type": "string"}, + }, + "additionalProperties": false, + "required": ["color"], + }, + }, + "additionalProperties": false, + "required": ["type", "properties"], + }), + ); + }); }); group("objects", () { @@ -189,6 +329,23 @@ void main() { expect(Picoschema.toJsonSchema({"a?": "string"}).containsKey("required"), isFalse); }); + test("does not duplicate null for optional null fields", () { + expect(_props(Picoschema.toJsonSchema({"x?": "null"}))["x"], equals({"type": "null"})); + }); + + test("rejects duplicate property names", () { + for (final schema in >[ + {"a": "string", "a?": "number"}, + {"a": "string", "a(array)": "number"}, + { + "a?(enum)": ["X"], + "a": "string", + }, + ]) { + expect(() => Picoschema.toJsonSchema(schema), _throwsPicoschema("duplicate property 'a'"), reason: "$schema"); + } + }); + test("converts nested objects without a qualifier", () { final result = Picoschema.toJsonSchema({ "user": {"name": "string"}, @@ -571,6 +728,38 @@ hi ); }); + test("converts a top-level field named items instead of passing it through", () async { + final metadata = await Dotprompt().renderMetadata(""" +--- +output: + schema: + items: + sku: string + qty: integer +--- +hi +"""); + expect( + metadata.output?.schema, + equals({ + "type": "object", + "properties": { + "items": { + "type": "object", + "properties": { + "sku": {"type": "string"}, + "qty": {"type": "integer"}, + }, + "additionalProperties": false, + "required": ["sku", "qty"], + }, + }, + "additionalProperties": false, + "required": ["items"], + }), + ); + }); + test("resolves named schemas through DotpromptOptions.schemaResolver", () async { final dotprompt = Dotprompt( DotpromptOptions( @@ -591,6 +780,25 @@ hi ); }); + test("resolves the input: Name / output: Name shorthand", () async { + final metadata = await Dotprompt( + const DotpromptOptions( + schemas: { + "Person": {"type": "object", "description": "a person"}, + }, + ), + ).renderMetadata("---\ninput: Person\noutput: Person\n---\nhi"); + expect(metadata.input?.schema, equals({"type": "object", "description": "a person"})); + expect(metadata.output?.schema, equals({"type": "object", "description": "a person"})); + }); + + test("rejects an unknown name in the output: Name shorthand", () async { + await expectLater( + Dotprompt().renderMetadata("---\noutput: Missing\n---\nhi"), + _throwsPicoschema("could not find schema with name 'Missing'"), + ); + }); + test("rejects name(*)", () async { await expectLater( Dotprompt().renderMetadata("---\noutput:\n schema:\n wild(*): string\n---\nhi"), From e9b203417a69cad5839ee660bd827b0d835e3f93 Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sun, 4 Oct 2026 08:53:15 -0400 Subject: [PATCH 5/8] chore(dart): release Picoschema fix as 2.0.0 The strict Picoschema parsing is breaking (old Dart-only syntax and unknown named schemas now throw), so release it as a major. genkit-dart depends on ^1.0.0 and converts prompt schemas before defineSchema runs, so a minor release would break it on upgrade. Also: CHANGELOG migration table and full list of breaking changes, 1.0.0 entry for additionalMetadata, README Picoschema section. --- dart/dotprompt/CHANGELOG.md | 55 ++++++++++++++++++++++++++----------- dart/dotprompt/README.md | 38 ++++++++++++++++++++++++- dart/dotprompt/pubspec.yaml | 2 +- docs/api/dart/index.md | 2 +- 4 files changed, 78 insertions(+), 19 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index ac47d4a4d..a165f442a 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -2,7 +2,40 @@ All notable changes to dotprompt-dart will be documented in this file. -## [1.1.0] - 2026-10-03 +## [2.0.0] - 2026-10-05 + +Picoschema now follows the spec and behaves like the other runtimes. Schemas +that relied on the old Dart-only syntax, or on unknown names becoming `$ref`, +need updating. + +### Breaking changes + +- Picoschema is strict. These now throw `PicoschemaException`: + + | Before (1.x) | Now | + | ---------------------------------- | ------------------------------------- | + | `email(the email): string` | `email: string, the email` | + | `tags: string[]` | `tags(array): string` | + | `status: a \| b` | `status(enum): [a, b]` | + | `wild(*): string` | `(*): string` | + | `n: int` (also `str`, `bool`, ...) | `n: integer` | + | `a` and `a?` in the same object | pick one | + +- Unknown named schemas throw instead of becoming `{"$ref": name}`. Names are + looked up in `schemas`/`defineSchema`, then `DotpromptOptions.schemaResolver` + (`Dotprompt` and `Picoschema.parse` only; `toJsonSchema` is sync and only + sees `schemas`). Register schemas before converting prompts that use them. +- The `input: Name` / `output: Name` shorthand is parsed as + `{"$type": Name}` (same as `schema: Name`) instead of `{"$ref": Name}`, so + `renderMetadata` resolves it. +- `Picoschema.toJsonSchema`'s `schemas` parameter is now + `Map>?` (was `Map?`). +- `Picoschema.isPicoschema` returns true for anything that is not recognized + as JSON Schema (it used to require a bare scalar value). Calling it before + `toJsonSchema` is no longer needed. +- `DotpromptOptions.schemas` and `defineSchema` take JSON Schema (as in the + other runtimes), not Picoschema. Registered schemas are inserted as-is; + convert Picoschema with `Picoschema.toJsonSchema` first. ### Fixed @@ -27,25 +60,15 @@ All notable changes to dotprompt-dart will be documented in this file. - The spec test runner now checks `output` and named `schemas`, so `spec/picoschema.yaml` is actually enforced. -### Changed - -- Picoschema is strict, like the other runtimes. These now throw - `PicoschemaException`: - - free-text parentheses such as `email(the email): string` (use - `email: string, the email`); - - parenthetical types other than `array`, `object` and `enum`, e.g. - `wild(*)`; - - non-standard types (`string[]`, `a | b`, aliases like `int`/`str`); - - unknown named schemas. Previously these became `{"$ref": name}`; - - duplicate property names such as `a` and `a?` in the same object. -- `DotpromptOptions.schemas` and `defineSchema` are documented as taking JSON - Schema (as in the other runtimes), not Picoschema. Registered schemas are - inserted as-is; convert Picoschema with `Picoschema.toJsonSchema` first. - ### Added - `Picoschema.parse(schema, {schemas, schemaResolver})`, an async variant of `toJsonSchema` that resolves named schemas through a `SchemaResolver`. + +## [1.0.0] - 2026-08-25 + +### Added + - `renderMetadata` and `compile` now accept an optional `additionalMetadata` argument that is merged on top of the prompt's parsed frontmatter (scalar fields override, `config` map is shallow-merged with additional winning on diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 390e586f1..911a21f0a 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^1.1.0 + dotprompt: ^2.0.0 ``` ## Quick Start @@ -129,6 +129,42 @@ Please analyze this image: ''', DataArgument()); ``` +### Picoschema + +`input.schema` and `output.schema` accept +[Picoschema](https://google.github.io/dotprompt/reference/picoschema/) and are +converted to JSON Schema by `renderMetadata`/`render`: + +```yaml +output: + schema: + title: string, the article title + subtitle?: string + tags(array, relevant tags): string + status(enum): [DRAFT, PUBLISHED] + author: Author # named schema + labels(object): + (*): string # additionalProperties +``` + +Named schemas are JSON Schema, registered up front or resolved on demand: + +```dart +final dotprompt = Dotprompt(DotpromptOptions( + schemas: {'Author': authorJsonSchema}, + schemaResolver: (name) async => lookupJsonSchema(name), +)); + +// Or convert directly. +final jsonSchema = await Picoschema.parse( + {'author': 'Author', 'tags(array)': 'string'}, + schemaResolver: (name) async => lookupJsonSchema(name), +); +``` + +Upgrading from 1.x? Picoschema is now strict (`string[]`, `a | b` and +free-text parentheses throw). See the [CHANGELOG](CHANGELOG.md#200---2026-10-03). + ## API Reference ### Core Classes diff --git a/dart/dotprompt/pubspec.yaml b/dart/dotprompt/pubspec.yaml index 1b96fb3b9..7e663f7d3 100644 --- a/dart/dotprompt/pubspec.yaml +++ b/dart/dotprompt/pubspec.yaml @@ -19,7 +19,7 @@ description: >- Dart implementation of Dotprompt, an executable prompt template file format for Generative AI. This library provides parsing, rendering, and management of .prompt files with YAML frontmatter and Handlebars templating. -version: 1.1.0 +version: 2.0.0 license: Apache-2.0 homepage: https://github.com/google/dotprompt repository: https://github.com/google/dotprompt diff --git a/docs/api/dart/index.md b/docs/api/dart/index.md index 11bcd282e..d1ddb0bf7 100644 --- a/docs/api/dart/index.md +++ b/docs/api/dart/index.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^1.1.0 + dotprompt: ^2.0.0 ``` ## Quick Start From 5f83482679462d486105a0c0f5667ddb00bba57b Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sun, 4 Oct 2026 09:35:59 -0400 Subject: [PATCH 6/8] fix(dart): tighten Picoschema/JSON Schema detection and copy results JSON Schema detection now checks structure recursively (keywords, valid `type`, subschemas in properties/items/anyOf/...), so Picoschema nested in JSON Schema throws instead of passing through raw. Type-name values are only treated as Picoschema fields next to a top-level scalar `type`, skipping $/x- keys, so `{type: object, description: "string, ..."}` and `{$ref: ..., description: string}` pass through as in JS. Also: - Errors for schemas that look like JSON Schema but parse as Picoschema carry a hint. - `{$type: X}` is only unwrapped when it is the sole key, so a `$type` field no longer drops sibling fields. - Passed-through JSON Schema and named schemas are deep copies. - `a ?: string` produces property `a`, not `a `. --- dart/dotprompt/CHANGELOG.md | 12 ++ dart/dotprompt/lib/src/picoschema.dart | 202 ++++++++++++++++------ dart/dotprompt/test/picoschema_test.dart | 203 +++++++++++++++++++++++ 3 files changed, 369 insertions(+), 48 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index a165f442a..bc57b0fcc 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -52,7 +52,19 @@ need updating. - Picoschema fields named like JSON Schema keywords (`type: string`, `properties:`, `items:`) are converted instead of being mistaken for JSON Schema and passed through raw. + - JSON Schema detection checks structure recursively, so Picoschema nested + in JSON Schema (`{type: object, properties: {a: string}}`) throws instead + of being passed through raw. Only a top-level scalar `type` is ambiguous + (`{type: string, title: string}` is a Picoschema object); values below the + top level and `$`/`x-` keys are never read as Picoschema fields. Errors for + schemas that look like JSON Schema but are parsed as Picoschema (e.g. + `{description: ...}` with no `type`) say so. + - Passed-through JSON Schema and resolved named schemas are deep copies, so + editing the result never changes the input or registered schemas. + - A frontmatter schema with a `$type` field next to other fields is parsed + as a Picoschema object instead of being collapsed to the `$type` value. - `x?: null` produces `{type: null}` instead of `{type: [null, null]}`. + - `a ?: string` produces a property named `a`, not `a `. - Named schemas are resolved via `DotpromptOptions.schemaResolver` as well as `schemas`/`defineSchema`. - The `input: Name` / `output: Name` shorthand resolves the named schema. diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index 57886fbe1..d625497c6 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -127,7 +127,10 @@ class Picoschema { } on _UnresolvedSchema catch (e) { final schema = await schemaResolver?.call(e.name); if (schema == null) { - throw _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null); + throw _withJsonSchemaHint( + picoschema, + _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null), + ); } resolved[e.name] = schema; } @@ -136,23 +139,53 @@ class Picoschema { /// Whether [schema] should be converted as Picoschema. /// - /// Returns false when [schema] is already JSON Schema, i.e. it has one of: - /// - a top-level `type` that is a JSON Schema type, with every other key a - /// JSON Schema keyword whose value is not a Picoschema type string; - /// - a list of JSON Schema types as `type`; - /// - a `properties` map whose values are all JSON Schemas (booleans, or maps - /// keyed only by JSON Schema keywords); - /// - a list-valued `anyOf`/`oneOf`/`allOf`/`enum`; - /// - a `$schema` or `$ref` key. + /// Returns false when [schema] is already JSON Schema, i.e. it has a + /// `type`, a `properties` map, a list-valued `anyOf`/`oneOf`/`allOf`/`enum`, + /// or a `$schema`/`$ref` key, and is structurally JSON Schema all the way + /// down: every key is a JSON Schema keyword, `type` is a JSON Schema type, + /// and subschemas (`properties` values, `items`, `anyOf`, ...) are JSON + /// Schema too. + /// + /// One ambiguity is resolved towards Picoschema: with a top-level scalar + /// `type`, any other non-`$`/`x-` key whose value is a scalar type string + /// (`{type: string, title: string}`) makes it a Picoschema object. /// /// Keys using Picoschema syntax (`name?`, `name(array)`, `(*)`) always mean /// Picoschema. [toJsonSchema] and [parse] apply the same check, so calling /// this first is optional. - static bool isPicoschema(Map schema) => schema.containsKey(r"$type") || !_isJsonSchema(schema); + static bool isPicoschema(Map schema) => _wrappedTypeString(schema) != null || !_isJsonSchema(schema); /// Top-level keywords whose value is a list in JSON Schema. static const Set _jsonSchemaListKeywords = {"anyOf", "oneOf", "allOf", "enum"}; + /// Keywords whose value is a single subschema (`items` may also be a list). + static const Set _subschemaKeywords = { + "items", + "not", + "if", + "then", + "else", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "additionalItems", + "contentSchema", + }; + + /// Keywords whose value is a list of subschemas. + static const Set _subschemaListKeywords = {"allOf", "anyOf", "oneOf", "prefixItems"}; + + /// Keywords whose value maps names to subschemas. + static const Set _subschemaMapKeywords = { + "properties", + "patternProperties", + r"$defs", + "definitions", + "dependentSchemas", + }; + /// JSON Schema (2020-12 plus common legacy/OpenAPI) keywords. `$`- and /// `x-`-prefixed keys are accepted separately. static const Set _jsonSchemaKeywords = { @@ -173,46 +206,114 @@ class Picoschema { // Broader and stricter than JS, which only checks `type` and `properties`. // The goal is that Picoschema is never passed through raw: - // - Picoschema key syntax and list-valued keywords are unambiguous (a - // Picoschema field value is never a list; only `(enum)` keys take lists). - // - A scalar `type` alone is not enough: `{type: string, payload: string}` - // is a Picoschema object with a field named `type`. Siblings must be JSON - // Schema keywords, and values like `title: string` read as Picoschema - // fields. The cost is that a JSON Schema whose title/default is literally - // a type name (e.g. `default: string`) is parsed as Picoschema. - // - Map-valued keywords like `items` or `$defs` are not used on their own, - // because `items: {sku: string}` is a normal Picoschema nested object. + // - Picoschema key syntax always means Picoschema. + // - Structure is checked recursively: every key must be a JSON Schema + // keyword, `type` must be a JSON Schema type, and subschemas (`properties` + // values, `items`, `anyOf`, ...) must be JSON Schema too. So Picoschema + // nested in JSON Schema (`{type: object, properties: {a: string}}`) is + // rejected instead of passed through. + // - Only a top-level scalar `type` is ambiguous: `{type: string, title: + // string}` is also a Picoschema object with fields `type` and `title`, and + // is read that way. The cost is that a JSON Schema whose title/default is + // literally a type name (`default: string`) is parsed as Picoschema; the + // error then carries a hint (see [_withJsonSchemaHint]). `type: object` + // is never a valid Picoschema field, and `$`/`x-` keys are never read as + // Picoschema fields. + // - Map-valued keywords like `items` or `$defs` are not markers, because + // `items: {sku: string}` is a normal Picoschema nested object. static bool _isJsonSchema(Map schema) { - if (schema.keys.any(_hasPicoschemaKeySyntax)) { + if (schema.keys.any(_hasPicoschemaKeySyntax) || !_hasJsonSchemaMarker(schema) || !_isSubschema(schema)) { return false; } final type = schema["type"]; - final properties = schema["properties"]; - return (type is String && _jsonSchemaTypes.contains(type) && schema.entries.every(_isJsonSchemaEntry)) || - (type is List && type.isNotEmpty && type.every(_jsonSchemaTypes.contains)) || - (properties is Map && properties.values.every(_isSubschema)) || - _jsonSchemaListKeywords.any((k) => schema[k] is List) || - schema.containsKey(r"$schema") || - schema.containsKey(r"$ref"); + if (type is String && _scalarTypes.contains(type)) { + return !schema.entries.any( + (e) => + e.key != "type" && !_isExtensionKey(e.key) && e.value is String && _isScalarTypeString(e.value as String), + ); + } + return true; } + static bool _hasJsonSchemaMarker(Map schema) => + schema.containsKey("type") || + schema["properties"] is Map || + _jsonSchemaListKeywords.any((k) => schema[k] is List) || + schema.containsKey(r"$schema") || + schema.containsKey(r"$ref"); + static bool _hasPicoschemaKeySyntax(String key) => key.endsWith("?") || key.contains("(") || key.contains(")"); + static bool _isExtensionKey(String key) => key.startsWith(r"$") || key.startsWith("x-"); + static bool _isJsonSchemaKeyword(Object? key) => - key is String && (_jsonSchemaKeywords.contains(key) || key.startsWith(r"$") || key.startsWith("x-")); + key is String && (_jsonSchemaKeywords.contains(key) || _isExtensionKey(key)); + + /// Whether [value] is `type[, description]` with a Picoschema scalar type. + static bool _isScalarTypeString(String value) => _scalarTypes.contains(_extractDescription(value).$1); + + /// Whether [value] is structurally a JSON Schema: a boolean, or a map (`{}` + /// included) whose entries all pass [_isJsonSchemaEntry]. Rejects Picoschema + /// like `{color: string}` or `{address: {street: string}}`. + static bool _isSubschema(Object? value) => + value is bool || (value is Map && value.entries.every((e) => _isJsonSchemaEntry(e.key, e.value))); + + /// Whether a single schema entry is structurally JSON Schema: a JSON Schema + /// keyword, a valid `type`, and JSON Schema subschemas. Other values (titles, + /// defaults, ...) are not inspected. + static bool _isJsonSchemaEntry(Object? key, Object? value) { + if (!_isJsonSchemaKeyword(key)) { + return false; + } + if (key == "type") { + return (value is String && _jsonSchemaTypes.contains(value)) || + (value is List && value.isNotEmpty && value.every(_jsonSchemaTypes.contains)); + } + if (_subschemaKeywords.contains(key)) { + return _isSubschema(value) || (key == "items" && value is List && value.every(_isSubschema)); + } + if (_subschemaListKeywords.contains(key)) { + return value is List && value.every(_isSubschema); + } + if (_subschemaMapKeywords.contains(key)) { + return value is Map && value.values.every(_isSubschema); + } + return true; + } + + /// The type string of frontmatter like `schema: string`, which + /// InputConfig/OutputConfig wrap as `{$type: "string"}`. Only a map whose + /// sole key is `$type` counts, so a Picoschema object that happens to have a + /// `$type` field is still parsed as an object. + static String? _wrappedTypeString(Map schema) { + final wrapped = schema[r"$type"]; + return schema.length == 1 && wrapped is String ? wrapped : null; + } - /// Whether a `properties` value is a JSON Schema: a boolean or a map keyed - /// only by JSON Schema keywords (`{}` included). Rejects Picoschema like - /// `properties: {color: string}` or `properties: {address: {street: string}}`. - static bool _isSubschema(Object? value) => value is bool || (value is Map && value.keys.every(_isJsonSchemaKeyword)); + static Map _deepCopy(Map map) => + map.map((key, value) => MapEntry(key, _deepCopyValue(value))); - /// Whether a top-level entry next to a scalar `type` reads as JSON Schema. - static bool _isJsonSchemaEntry(MapEntry entry) { - final MapEntry(:key, :value) = entry; - if (key == "type") { - return true; + static Object? _deepCopyValue(Object? value) => switch (value) { + final Map m => _deepCopy(m.cast()), + final List l => [for (final e in l) _deepCopyValue(e)], + _ => value, + }; + + /// Appends a hint when [schema] uses only JSON Schema keywords but failed to + /// parse as Picoschema, e.g. `{description: free form}` (no `type`) or + /// `{type: object, properties: {a: string}}` (Picoschema inside JSON Schema). + static PicoschemaException _withJsonSchemaHint(Object? schema, PicoschemaException e) { + if (schema is! Map || + schema.isEmpty || + _wrappedTypeString(schema) != null || + !schema.keys.every(_isJsonSchemaKeyword)) { + return e; } - return _isJsonSchemaKeyword(key) && !(value is String && _scalarTypes.contains(_extractDescription(value).$1)); + return PicoschemaException( + "${e.message} (the schema was parsed as Picoschema because it is not recognized as JSON Schema; " + "see Picoschema.isPicoschema)", + e, + ); } static Map _convert(Object? schema, _SchemaLookup lookup) { @@ -224,17 +325,20 @@ class Picoschema { } if (schema is Map) { final map = schema.cast(); - // Frontmatter like `schema: string` arrives wrapped as `{$type: "string"}` - // (see InputConfig/OutputConfig). - final wrapped = map[r"$type"]; - if (wrapped is String) { + final wrapped = _wrappedTypeString(map); + if (wrapped != null) { return _parseTypeString(wrapped, lookup); } if (_isJsonSchema(map)) { - // A bare `properties` map is JSON Schema with an implied object type. - return map["type"] == null && map["properties"] is Map ? {...map, "type": "object"} : map; + // Copied like named schemas, so callers never share nested maps with + // the input. A bare `properties` map implies an object type. + return {..._deepCopy(map), if (map["type"] == null && map["properties"] is Map) "type": "object"}; + } + try { + return _parseObject(map, lookup); + } on PicoschemaException catch (e) { + throw _withJsonSchemaHint(map, e); } - return _parseObject(map, lookup); } throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $schema"); } @@ -247,8 +351,9 @@ class Picoschema { // JSON Schema; `{}` (what JS returns for nested fields) is used everywhere. "any" => {}, _ when _scalarTypes.contains(type) => {"type": type}, - // Copy so descriptions and nullability never leak into registered schemas. - _ => {...lookup(type)}, + // Deep copy so neither this converter nor callers editing the result can + // change registered schemas. + _ => _deepCopy(lookup(type)), }; if (description != null) { schema["description"] = description; @@ -288,7 +393,8 @@ class Picoschema { } final name = (match?.group(1) ?? key).trim(); final isOptional = name.endsWith("?"); - final propertyName = isOptional ? name.substring(0, name.length - 1) : name; + // Trimmed again so `a ?` is `a`, not `a ` (JS keeps the space). + final propertyName = (isOptional ? name.substring(0, name.length - 1) : name).trim(); if (propertyName.isEmpty) { throw PicoschemaException("Picoschema: invalid property name '$key'"); } diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index b9bc2e345..09d06b6fc 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -204,6 +204,153 @@ void main() { expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); } }); + + test("passes through nested JSON Schema subschemas", () { + final schemas = >[ + { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + }, + "nick": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + }, + "meta": { + "type": "object", + "additionalProperties": {"type": "number"}, + }, + }, + "required": ["tags"], + }, + { + "type": "array", + "items": [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("returns a deep copy, not the input map", () { + final schema = { + "type": "object", + "properties": { + "a": {"type": "string"}, + }, + }; + final result = Picoschema.toJsonSchema(schema); + result["description"] = "changed"; + (_props(result)["a"] as Map)["description"] = "changed"; + expect( + schema, + equals({ + "type": "object", + "properties": { + "a": {"type": "string"}, + }, + }), + ); + }); + + test("does not inspect values other than a top-level scalar type's siblings", () { + // Type-name strings below the top level, or next to a non-scalar + // `type`, are ordinary JSON Schema values. + final schemas = >[ + { + "type": "object", + "description": "string, the user", + "properties": { + "name": {"type": "string"}, + }, + }, + { + "type": "object", + "properties": { + "a": {"type": "string", "description": "string", "default": "number"}, + }, + }, + { + "type": "array", + "title": "string", + "items": {"type": "string", "title": "integer"}, + }, + {r"$ref": "#/defs/A", "description": "string"}, + ]; + for (final schema in schemas) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test(r"never reads $- and x- keys as Picoschema fields", () { + for (final schema in >[ + {"type": "string", r"$comment": "string"}, + {"type": "string", "x-kind": "string"}, + ]) { + expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); + } + }); + + test("rejects Picoschema nested inside JSON Schema instead of passing it through", () { + final schemas = >[ + { + "type": "object", + "properties": {"a": "string"}, + }, + { + "type": "object", + "properties": { + "a": { + "type": "object", + "properties": {"b": "string"}, + }, + }, + }, + { + "type": "array", + "items": {"sku": "string"}, + }, + ]; + for (final schema in schemas) { + expect( + () => Picoschema.toJsonSchema(schema), + _throwsPicoschema("not recognized as JSON Schema"), + reason: "$schema", + ); + } + }); + + test("hints at JSON Schema when a schema of only JSON Schema keywords fails to parse", () { + // No `type`, so these are Picoschema objects with fields named + // `description`/`format`, whose values are not valid types. + for (final schema in >[ + {"description": "free form"}, + {"format": "date-time"}, + ]) { + expect( + () => Picoschema.toJsonSchema(schema), + _throwsPicoschema("not recognized as JSON Schema"), + reason: "$schema", + ); + } + }); + + test("does not add the JSON Schema hint for Picoschema fields", () { + expect( + () => Picoschema.toJsonSchema({"type": "object", "name": "string"}), + throwsA( + isA().having((e) => e.message, "message", isNot(contains("JSON Schema"))), + ), + ); + }); }); // Picoschema fields whose names coincide with JSON Schema keywords must be @@ -265,6 +412,22 @@ void main() { } }); + test(r"treats a $type field next to other fields as a Picoschema field", () async { + // `schema: string` is wrapped as {$type: string}; only that exact shape + // is unwrapped, so other fields are never dropped. + final metadata = await Dotprompt().renderMetadata(r""" +--- +output: + schema: + $type: string + name: string +--- +hi +"""); + expect(_props(metadata.output!.schema!).keys, equals([r"$type", "name"])); + expect(Picoschema.isPicoschema({r"$type": "string", "name": "string"}), isTrue); + }); + test("converts type and properties fields from frontmatter", () async { final metadata = await Dotprompt().renderMetadata(""" --- @@ -346,6 +509,25 @@ hi } }); + test("trims whitespace before the optional marker", () { + expect( + Picoschema.toJsonSchema({"a ?": "string", "b ?(array)": "string"}), + equals({ + "type": "object", + "properties": { + "a": { + "type": ["string", "null"], + }, + "b": { + "type": ["array", "null"], + "items": {"type": "string"}, + }, + }, + "additionalProperties": false, + }), + ); + }); + test("converts nested objects without a qualifier", () { final result = Picoschema.toJsonSchema({ "user": {"name": "string"}, @@ -543,6 +725,20 @@ hi ); }); + test("returns deep copies of registered schemas", () { + final registered = >{ + "Obj": { + "type": "object", + "properties": { + "x": {"type": "string"}, + }, + }, + }; + final result = Picoschema.toJsonSchema({"o": "Obj"}, schemas: registered); + _props(_props(result)["o"] as Map).remove("x"); + expect(_props(registered["Obj"]!).keys, equals(["x"])); + }); + test("makes optional references nullable without mutating the registered schema", () { final result = Picoschema.toJsonSchema({"foo?": "Foo"}, schemas: schemas); expect( @@ -609,6 +805,13 @@ hi test("throws for unknown types without any schema source", () async { await expectLater(Picoschema.parse("Missing"), _throwsPicoschema("unsupported scalar type")); }); + + test("adds the JSON Schema hint for unresolved names", () async { + await expectLater( + Picoschema.parse({"description": "Foo"}, schemaResolver: (_) async => null), + _throwsPicoschema("not recognized as JSON Schema"), + ); + }); }); group("Picoschema.isPicoschema", () { From af38bd6d8c77fe31847a6c14eaa968740540dd6c Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sun, 4 Oct 2026 10:01:39 -0400 Subject: [PATCH 7/8] fix(dart): align JSON Schema detection with JS/Python Replace the recursive keyword-based JSON Schema detection with the shallow top-level check used by the other runtimes: a `type` naming a JSON Schema type or a `properties` map, plus list-valued `type`/`anyOf`/`oneOf`/`allOf`/ `enum`, `$schema` and `$ref`. The recursive check rejected valid JSON Schema containing keywords outside its list (e.g. OpenAPI `discriminator`), parsing it as Picoschema and throwing. Removes the keyword vocabulary, sibling-value heuristics and the "not recognized as JSON Schema" error hint. Also documents JSON Schema passthrough in the README and fixes the CHANGELOG anchor. --- dart/dotprompt/CHANGELOG.md | 18 +- dart/dotprompt/README.md | 15 +- dart/dotprompt/lib/src/picoschema.dart | 180 +++----------------- dart/dotprompt/test/picoschema_test.dart | 199 ++++------------------- 4 files changed, 66 insertions(+), 346 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index bc57b0fcc..ffa443e17 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -30,9 +30,11 @@ need updating. `renderMetadata` resolves it. - `Picoschema.toJsonSchema`'s `schemas` parameter is now `Map>?` (was `Map?`). -- `Picoschema.isPicoschema` returns true for anything that is not recognized - as JSON Schema (it used to require a bare scalar value). Calling it before - `toJsonSchema` is no longer needed. +- `Picoschema.isPicoschema` returns true for anything that is not JSON Schema + (it used to require a bare scalar value). JSON Schema is detected as in the + other runtimes: a top-level `type` naming a JSON Schema type or a + `properties` map, plus list-valued `type`/`anyOf`/`oneOf`/`allOf`/`enum`, + `$schema` and `$ref`. Calling it before `toJsonSchema` is no longer needed. - `DotpromptOptions.schemas` and `defineSchema` take JSON Schema (as in the other runtimes), not Picoschema. Registered schemas are inserted as-is; convert Picoschema with `Picoschema.toJsonSchema` first. @@ -49,16 +51,6 @@ need updating. - Top-level JSON Schema (`type: string`, a bare `properties` map, `anyOf`, `enum`, `type: [string, "null"]`, etc.) is passed through instead of being parsed as Picoschema. - - Picoschema fields named like JSON Schema keywords (`type: string`, - `properties:`, `items:`) are converted instead of being mistaken for JSON - Schema and passed through raw. - - JSON Schema detection checks structure recursively, so Picoschema nested - in JSON Schema (`{type: object, properties: {a: string}}`) throws instead - of being passed through raw. Only a top-level scalar `type` is ambiguous - (`{type: string, title: string}` is a Picoschema object); values below the - top level and `$`/`x-` keys are never read as Picoschema fields. Errors for - schemas that look like JSON Schema but are parsed as Picoschema (e.g. - `{description: ...}` with no `type`) say so. - Passed-through JSON Schema and resolved named schemas are deep copies, so editing the result never changes the input or registered schemas. - A frontmatter schema with a `$type` field next to other fields is parsed diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 911a21f0a..228c4a3e1 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -162,8 +162,21 @@ final jsonSchema = await Picoschema.parse( ); ``` +A schema that is already JSON Schema is passed through as-is. As in the other +runtimes, that means a top-level `type` naming a JSON Schema type or a +`properties` map (plus `anyOf`/`oneOf`/`allOf`/`enum` lists, `$schema` and +`$ref`): + +```yaml +output: + schema: + type: object + properties: + title: {type: string} +``` + Upgrading from 1.x? Picoschema is now strict (`string[]`, `a | b` and -free-text parentheses throw). See the [CHANGELOG](CHANGELOG.md#200---2026-10-03). +free-text parentheses throw). See the [CHANGELOG](CHANGELOG.md#200---2026-10-05). ## API Reference diff --git a/dart/dotprompt/lib/src/picoschema.dart b/dart/dotprompt/lib/src/picoschema.dart index d625497c6..3cb1f9218 100644 --- a/dart/dotprompt/lib/src/picoschema.dart +++ b/dart/dotprompt/lib/src/picoschema.dart @@ -127,158 +127,37 @@ class Picoschema { } on _UnresolvedSchema catch (e) { final schema = await schemaResolver?.call(e.name); if (schema == null) { - throw _withJsonSchemaHint( - picoschema, - _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null), - ); + throw _unknownSchema(e.name, hasSchemaSource: schemas != null || schemaResolver != null); } resolved[e.name] = schema; } } } - /// Whether [schema] should be converted as Picoschema. + /// Whether [schema] should be converted as Picoschema, i.e. it is not + /// already JSON Schema. [toJsonSchema] and [parse] apply the same check, so + /// calling this first is optional. /// - /// Returns false when [schema] is already JSON Schema, i.e. it has a - /// `type`, a `properties` map, a list-valued `anyOf`/`oneOf`/`allOf`/`enum`, - /// or a `$schema`/`$ref` key, and is structurally JSON Schema all the way - /// down: every key is a JSON Schema keyword, `type` is a JSON Schema type, - /// and subschemas (`properties` values, `items`, `anyOf`, ...) are JSON - /// Schema too. - /// - /// One ambiguity is resolved towards Picoschema: with a top-level scalar - /// `type`, any other non-`$`/`x-` key whose value is a scalar type string - /// (`{type: string, title: string}`) makes it a Picoschema object. - /// - /// Keys using Picoschema syntax (`name?`, `name(array)`, `(*)`) always mean - /// Picoschema. [toJsonSchema] and [parse] apply the same check, so calling - /// this first is optional. + /// JSON Schema is detected like in the JS and Python runtimes: a top-level + /// `type` naming a JSON Schema type, or a `properties` map. A list-valued + /// `type` or `anyOf`/`oneOf`/`allOf`/`enum`, `$schema` and `$ref` also count, + /// so JSON Schema without a single `type` string is not misparsed. static bool isPicoschema(Map schema) => _wrappedTypeString(schema) != null || !_isJsonSchema(schema); - /// Top-level keywords whose value is a list in JSON Schema. static const Set _jsonSchemaListKeywords = {"anyOf", "oneOf", "allOf", "enum"}; - /// Keywords whose value is a single subschema (`items` may also be a list). - static const Set _subschemaKeywords = { - "items", - "not", - "if", - "then", - "else", - "contains", - "additionalProperties", - "unevaluatedProperties", - "unevaluatedItems", - "propertyNames", - "additionalItems", - "contentSchema", - }; - - /// Keywords whose value is a list of subschemas. - static const Set _subschemaListKeywords = {"allOf", "anyOf", "oneOf", "prefixItems"}; - - /// Keywords whose value maps names to subschemas. - static const Set _subschemaMapKeywords = { - "properties", - "patternProperties", - r"$defs", - "definitions", - "dependentSchemas", - }; - - /// JSON Schema (2020-12 plus common legacy/OpenAPI) keywords. `$`- and - /// `x-`-prefixed keys are accepted separately. - static const Set _jsonSchemaKeywords = { - // Applicators. - "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "dependentSchemas", "prefixItems", "items", - "contains", "properties", "patternProperties", "additionalProperties", "propertyNames", - "unevaluatedItems", "unevaluatedProperties", - // Validation. - "type", "enum", "const", "multipleOf", "maximum", "exclusiveMaximum", "minimum", "exclusiveMinimum", - "maxLength", "minLength", "pattern", "maxItems", "minItems", "uniqueItems", "maxContains", "minContains", - "maxProperties", "minProperties", "required", "dependentRequired", - // Annotations, format and content. - "title", "description", "default", "deprecated", "readOnly", "writeOnly", "examples", "format", - "contentEncoding", "contentMediaType", "contentSchema", - // Legacy drafts and OpenAPI/Gemini extensions. - "definitions", "dependencies", "additionalItems", "nullable", "example", "propertyOrdering", - }; - - // Broader and stricter than JS, which only checks `type` and `properties`. - // The goal is that Picoschema is never passed through raw: - // - Picoschema key syntax always means Picoschema. - // - Structure is checked recursively: every key must be a JSON Schema - // keyword, `type` must be a JSON Schema type, and subschemas (`properties` - // values, `items`, `anyOf`, ...) must be JSON Schema too. So Picoschema - // nested in JSON Schema (`{type: object, properties: {a: string}}`) is - // rejected instead of passed through. - // - Only a top-level scalar `type` is ambiguous: `{type: string, title: - // string}` is also a Picoschema object with fields `type` and `title`, and - // is read that way. The cost is that a JSON Schema whose title/default is - // literally a type name (`default: string`) is parsed as Picoschema; the - // error then carries a hint (see [_withJsonSchemaHint]). `type: object` - // is never a valid Picoschema field, and `$`/`x-` keys are never read as - // Picoschema fields. - // - Map-valued keywords like `items` or `$defs` are not markers, because - // `items: {sku: string}` is a normal Picoschema nested object. + // Deliberately shallow, like JS/Python: nothing below the top level is + // inspected. So `{type: object, properties: {a: string}}` is passed through + // as-is, and a Picoschema field named `type` with a JSON Schema type value + // (`{type: string, name: string}`) makes the whole map JSON Schema. static bool _isJsonSchema(Map schema) { - if (schema.keys.any(_hasPicoschemaKeySyntax) || !_hasJsonSchemaMarker(schema) || !_isSubschema(schema)) { - return false; - } final type = schema["type"]; - if (type is String && _scalarTypes.contains(type)) { - return !schema.entries.any( - (e) => - e.key != "type" && !_isExtensionKey(e.key) && e.value is String && _isScalarTypeString(e.value as String), - ); - } - return true; - } - - static bool _hasJsonSchemaMarker(Map schema) => - schema.containsKey("type") || - schema["properties"] is Map || - _jsonSchemaListKeywords.any((k) => schema[k] is List) || - schema.containsKey(r"$schema") || - schema.containsKey(r"$ref"); - - static bool _hasPicoschemaKeySyntax(String key) => key.endsWith("?") || key.contains("(") || key.contains(")"); - - static bool _isExtensionKey(String key) => key.startsWith(r"$") || key.startsWith("x-"); - - static bool _isJsonSchemaKeyword(Object? key) => - key is String && (_jsonSchemaKeywords.contains(key) || _isExtensionKey(key)); - - /// Whether [value] is `type[, description]` with a Picoschema scalar type. - static bool _isScalarTypeString(String value) => _scalarTypes.contains(_extractDescription(value).$1); - - /// Whether [value] is structurally a JSON Schema: a boolean, or a map (`{}` - /// included) whose entries all pass [_isJsonSchemaEntry]. Rejects Picoschema - /// like `{color: string}` or `{address: {street: string}}`. - static bool _isSubschema(Object? value) => - value is bool || (value is Map && value.entries.every((e) => _isJsonSchemaEntry(e.key, e.value))); - - /// Whether a single schema entry is structurally JSON Schema: a JSON Schema - /// keyword, a valid `type`, and JSON Schema subschemas. Other values (titles, - /// defaults, ...) are not inspected. - static bool _isJsonSchemaEntry(Object? key, Object? value) { - if (!_isJsonSchemaKeyword(key)) { - return false; - } - if (key == "type") { - return (value is String && _jsonSchemaTypes.contains(value)) || - (value is List && value.isNotEmpty && value.every(_jsonSchemaTypes.contains)); - } - if (_subschemaKeywords.contains(key)) { - return _isSubschema(value) || (key == "items" && value is List && value.every(_isSubschema)); - } - if (_subschemaListKeywords.contains(key)) { - return value is List && value.every(_isSubschema); - } - if (_subschemaMapKeywords.contains(key)) { - return value is Map && value.values.every(_isSubschema); - } - return true; + return (type is String && _jsonSchemaTypes.contains(type)) || + (type is List && type.isNotEmpty && type.every(_jsonSchemaTypes.contains)) || + schema["properties"] is Map || + _jsonSchemaListKeywords.any((k) => schema[k] is List) || + schema.containsKey(r"$schema") || + schema.containsKey(r"$ref"); } /// The type string of frontmatter like `schema: string`, which @@ -299,23 +178,6 @@ class Picoschema { _ => value, }; - /// Appends a hint when [schema] uses only JSON Schema keywords but failed to - /// parse as Picoschema, e.g. `{description: free form}` (no `type`) or - /// `{type: object, properties: {a: string}}` (Picoschema inside JSON Schema). - static PicoschemaException _withJsonSchemaHint(Object? schema, PicoschemaException e) { - if (schema is! Map || - schema.isEmpty || - _wrappedTypeString(schema) != null || - !schema.keys.every(_isJsonSchemaKeyword)) { - return e; - } - return PicoschemaException( - "${e.message} (the schema was parsed as Picoschema because it is not recognized as JSON Schema; " - "see Picoschema.isPicoschema)", - e, - ); - } - static Map _convert(Object? schema, _SchemaLookup lookup) { if (schema == null) { return {"type": "object"}; @@ -334,11 +196,7 @@ class Picoschema { // the input. A bare `properties` map implies an object type. return {..._deepCopy(map), if (map["type"] == null && map["properties"] is Map) "type": "object"}; } - try { - return _parseObject(map, lookup); - } on PicoschemaException catch (e) { - throw _withJsonSchemaHint(map, e); - } + return _parseObject(map, lookup); } throw PicoschemaException("Picoschema: only consists of objects and strings. Got: $schema"); } diff --git a/dart/dotprompt/test/picoschema_test.dart b/dart/dotprompt/test/picoschema_test.dart index 09d06b6fc..5096d02fb 100644 --- a/dart/dotprompt/test/picoschema_test.dart +++ b/dart/dotprompt/test/picoschema_test.dart @@ -261,157 +261,53 @@ void main() { ); }); - test("does not inspect values other than a top-level scalar type's siblings", () { - // Type-name strings below the top level, or next to a non-scalar - // `type`, are ordinary JSON Schema values. - final schemas = >[ - { - "type": "object", - "description": "string, the user", - "properties": { - "name": {"type": "string"}, - }, - }, - { - "type": "object", - "properties": { - "a": {"type": "string", "description": "string", "default": "number"}, - }, - }, - { - "type": "array", - "title": "string", - "items": {"type": "string", "title": "integer"}, - }, - {r"$ref": "#/defs/A", "description": "string"}, - ]; - for (final schema in schemas) { - expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); - } - }); - - test(r"never reads $- and x- keys as Picoschema fields", () { - for (final schema in >[ - {"type": "string", r"$comment": "string"}, - {"type": "string", "x-kind": "string"}, - ]) { - expect(Picoschema.toJsonSchema(schema), equals(schema), reason: "$schema"); - } - }); - - test("rejects Picoschema nested inside JSON Schema instead of passing it through", () { - final schemas = >[ - { - "type": "object", - "properties": {"a": "string"}, - }, - { - "type": "object", - "properties": { - "a": { - "type": "object", - "properties": {"b": "string"}, - }, - }, - }, - { - "type": "array", - "items": {"sku": "string"}, + test("passes through JSON Schema with keywords outside the core vocabulary", () { + // OpenAPI and vendor keywords must not cause a fallback to Picoschema. + final schema = { + "type": "object", + "properties": { + "kind": {"type": "string"}, }, - ]; - for (final schema in schemas) { - expect( - () => Picoschema.toJsonSchema(schema), - _throwsPicoschema("not recognized as JSON Schema"), - reason: "$schema", - ); - } - }); - - test("hints at JSON Schema when a schema of only JSON Schema keywords fails to parse", () { - // No `type`, so these are Picoschema objects with fields named - // `description`/`format`, whose values are not valid types. - for (final schema in >[ - {"description": "free form"}, - {"format": "date-time"}, - ]) { - expect( - () => Picoschema.toJsonSchema(schema), - _throwsPicoschema("not recognized as JSON Schema"), - reason: "$schema", - ); - } + "discriminator": {"propertyName": "kind"}, + "externalDocs": {"url": "https://example.com"}, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); }); - test("does not add the JSON Schema hint for Picoschema fields", () { - expect( - () => Picoschema.toJsonSchema({"type": "object", "name": "string"}), - throwsA( - isA().having((e) => e.message, "message", isNot(contains("JSON Schema"))), - ), - ); + test("does not inspect anything below the top level", () { + // Same as JS/Python: once the top level looks like JSON Schema, the + // whole map is passed through as-is. + final schema = { + "type": "object", + "properties": {"a": "string"}, + }; + expect(Picoschema.toJsonSchema(schema), equals(schema)); }); }); - // Picoschema fields whose names coincide with JSON Schema keywords must be - // converted, never passed through raw. group("fields named like JSON Schema keywords", () { - test("treats a scalar-valued type field as Picoschema", () { - for (final schema in >[ - {"type": "string", "payload": "string"}, - {"type": "string", "title": "string"}, - {"type": "string", "description": "string, the description"}, - ]) { - final result = Picoschema.toJsonSchema(schema); - expect(result["type"], equals("object"), reason: "$schema"); - expect(_props(result).keys, equals(schema.keys), reason: "$schema"); - expect(_props(result)["type"], isA>(), reason: "$schema"); - } - }); - - test("fails loudly instead of passing through a non-scalar type field", () { - // `object` is not a Picoschema scalar, so this is invalid Picoschema - // rather than JSON Schema with a stray `name` keyword. - expect( - () => Picoschema.toJsonSchema({"type": "object", "name": "string"}), - _throwsPicoschema("unsupported scalar type 'object'"), - ); + test("treats a top-level type with a JSON Schema type value as JSON Schema", () { + // Ambiguous with a Picoschema field named `type`; resolved the same way + // as in JS/Python. + final schema = {"type": "string", "title": "string"}; + expect(Picoschema.toJsonSchema(schema), equals(schema)); }); - test("treats Picoschema key syntax next to type as Picoschema", () { + test("parses a type field without a JSON Schema type value as Picoschema", () { expect( - Picoschema.toJsonSchema({"type": "string", "tags(array)": "string", "note?": "string"}), + Picoschema.toJsonSchema({"type": "string, event kind", "name": "string"}), equals({ "type": "object", "properties": { - "type": {"type": "string"}, - "tags": { - "type": "array", - "items": {"type": "string"}, - }, - "note": { - "type": ["string", "null"], - }, + "type": {"type": "string", "description": "event kind"}, + "name": {"type": "string"}, }, "additionalProperties": false, - "required": ["type", "tags"], + "required": ["type", "name"], }), ); }); - test("treats a properties field with Picoschema values as Picoschema", () { - for (final properties in >[ - {"color": "string"}, - { - "address": {"street": "string"}, - }, - ]) { - final result = Picoschema.toJsonSchema({"properties": properties, "name": "string"}); - expect(_props(result).keys, equals(["properties", "name"]), reason: "$properties"); - expect(_props(result)["properties"], containsPair("type", "object"), reason: "$properties"); - } - }); - test(r"treats a $type field next to other fields as a Picoschema field", () async { // `schema: string` is wrapped as {$type: string}; only that exact shape // is unwrapped, so other fields are never dropped. @@ -427,38 +323,6 @@ hi expect(_props(metadata.output!.schema!).keys, equals([r"$type", "name"])); expect(Picoschema.isPicoschema({r"$type": "string", "name": "string"}), isTrue); }); - - test("converts type and properties fields from frontmatter", () async { - final metadata = await Dotprompt().renderMetadata(""" ---- -output: - schema: - type: string, event kind - properties: - color: string ---- -hi -"""); - expect( - metadata.output?.schema, - equals({ - "type": "object", - "properties": { - "type": {"type": "string", "description": "event kind"}, - "properties": { - "type": "object", - "properties": { - "color": {"type": "string"}, - }, - "additionalProperties": false, - "required": ["color"], - }, - }, - "additionalProperties": false, - "required": ["type", "properties"], - }), - ); - }); }); group("objects", () { @@ -805,13 +669,6 @@ hi test("throws for unknown types without any schema source", () async { await expectLater(Picoschema.parse("Missing"), _throwsPicoschema("unsupported scalar type")); }); - - test("adds the JSON Schema hint for unresolved names", () async { - await expectLater( - Picoschema.parse({"description": "Foo"}, schemaResolver: (_) async => null), - _throwsPicoschema("not recognized as JSON Schema"), - ); - }); }); group("Picoschema.isPicoschema", () { From 7bd2cb9a55a5fe51ae6597fb6e09ed012b23f86e Mon Sep 17 00:00:00 2001 From: Pavel Jbanov Date: Sun, 4 Oct 2026 13:19:38 -0400 Subject: [PATCH 8/8] chore(dart): defer version bump to release PR --- dart/dotprompt/CHANGELOG.md | 2 +- dart/dotprompt/README.md | 5 +---- dart/dotprompt/pubspec.yaml | 2 +- docs/api/dart/index.md | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/dart/dotprompt/CHANGELOG.md b/dart/dotprompt/CHANGELOG.md index ffa443e17..c9da01d6e 100644 --- a/dart/dotprompt/CHANGELOG.md +++ b/dart/dotprompt/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to dotprompt-dart will be documented in this file. -## [2.0.0] - 2026-10-05 +## [Unreleased] Picoschema now follows the spec and behaves like the other runtimes. Schemas that relied on the old Dart-only syntax, or on unknown names becoming `$ref`, diff --git a/dart/dotprompt/README.md b/dart/dotprompt/README.md index 228c4a3e1..545bf6fa0 100644 --- a/dart/dotprompt/README.md +++ b/dart/dotprompt/README.md @@ -20,7 +20,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^2.0.0 + dotprompt: ^1.0.0 ``` ## Quick Start @@ -175,9 +175,6 @@ output: title: {type: string} ``` -Upgrading from 1.x? Picoschema is now strict (`string[]`, `a | b` and -free-text parentheses throw). See the [CHANGELOG](CHANGELOG.md#200---2026-10-05). - ## API Reference ### Core Classes diff --git a/dart/dotprompt/pubspec.yaml b/dart/dotprompt/pubspec.yaml index 7e663f7d3..80a346e68 100644 --- a/dart/dotprompt/pubspec.yaml +++ b/dart/dotprompt/pubspec.yaml @@ -19,7 +19,7 @@ description: >- Dart implementation of Dotprompt, an executable prompt template file format for Generative AI. This library provides parsing, rendering, and management of .prompt files with YAML frontmatter and Handlebars templating. -version: 2.0.0 +version: 1.0.1 license: Apache-2.0 homepage: https://github.com/google/dotprompt repository: https://github.com/google/dotprompt diff --git a/docs/api/dart/index.md b/docs/api/dart/index.md index d1ddb0bf7..43d0d2fcd 100644 --- a/docs/api/dart/index.md +++ b/docs/api/dart/index.md @@ -8,7 +8,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - dotprompt: ^2.0.0 + dotprompt: ^1.0.0 ``` ## Quick Start