diff --git a/CHANGELOG.md b/CHANGELOG.md index e071fd01..2cb959c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline unrelated annotation data untouched. * Preserve schema-first alias metadata and strict/passthrough precedence; diagnose unsupported shape transformations instead of emitting wrong fields. +* Accept parenthesized no-op `null` overrides in generated schema metadata. * Resolve same-build value-model and getter references, nullable nested Sets, generated-name collisions, and application-generic nullability correctly. * Substitute nested and recursive type bounds when resolving type-owned schemas. diff --git a/docs.json b/docs.json index 73d03822..bf63d9e3 100644 --- a/docs.json +++ b/docs.json @@ -97,7 +97,7 @@ ], "variables": { "versions": { - "default": "2.0.0-alpha.1", + "default": "2.0.0-alpha.2", "isPrerelease": true } }, diff --git a/docs/guides/ecosystem-integrations.mdx b/docs/guides/ecosystem-integrations.mdx index b0fb8a60..8881efb4 100644 --- a/docs/guides/ecosystem-integrations.mdx +++ b/docs/guides/ecosystem-integrations.mdx @@ -137,10 +137,10 @@ Future askGeminiWeather( ); final response = await model.generateContent([Content.text(prompt)]); - final jsonMap = jsonDecode(response.text ?? 'null'); + final decoded = jsonDecode(response.text ?? 'null'); // Validate and decode Gemini's response into WeatherReport - return WeatherReportSchema.fromJson(jsonMap); + return WeatherReportSchema.parse(decoded); } ``` diff --git a/packages/ack/lib/src/json_schema/json_schema_compiler.dart b/packages/ack/lib/src/json_schema/json_schema_compiler.dart index 153d2bae..c8bfd543 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -474,7 +474,6 @@ final class _JsonSchemaCompiler { bool _discoverBundledDialect(_ImportedNode node) { final wanted = node.dialectUri; - if (resources.containsKey(wanted)) return true; bool visit(_ImportedNode parent, List<(_ImportedNode, String)> path) { final source = parent.source; diff --git a/packages/ack/lib/src/json_schema/json_schema_utils.dart b/packages/ack/lib/src/json_schema/json_schema_utils.dart deleted file mode 100644 index 2d8bd45c..00000000 --- a/packages/ack/lib/src/json_schema/json_schema_utils.dart +++ /dev/null @@ -1,26 +0,0 @@ -/// Shared utilities for JSON Schema conversion. -library; - -import 'package:meta/meta.dart'; - -/// Wraps property conversion with enhanced error context. -/// -/// When converting schema properties, this wrapper catches errors and re-throws -/// them with additional context about which property caused the failure. -@internal -T wrapPropertyConversion(String key, T Function() fn) { - try { - return fn(); - } catch (e, st) { - final msg = - 'Error converting property "$key": ${e is Error ? e.toString() : e}'; - if (e is UnsupportedError) { - Error.throwWithStackTrace(UnsupportedError(msg), st); - } else if (e is ArgumentError) { - Error.throwWithStackTrace(ArgumentError(msg), st); - } else if (e is StateError) { - Error.throwWithStackTrace(StateError(msg), st); - } - rethrow; - } -} diff --git a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart index 76c448f4..ffe2c525 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model_builder.dart @@ -6,7 +6,6 @@ import 'package:collection/collection.dart'; import '../constraints/constraint.dart'; import '../constraints/datetime_constraint.dart'; import '../context.dart'; -import '../json_schema/json_schema_utils.dart'; import '../schemas/schema.dart'; import '../utils/json_utils.dart'; import 'ack_schema_model.dart'; @@ -153,17 +152,22 @@ final class _SchemaModelBuilder { } final model = switch (schema) { - StringSchema() => _string(schema), - IntegerSchema() => _integer(schema), - DoubleSchema() => _number( + StringSchema() => AckStringSchemaModel( description: schema.description, nullable: schema.isNullable, ), - NumberSchema() => _number( + IntegerSchema() => AckIntegerSchemaModel( + description: schema.description, + nullable: schema.isNullable, + ), + DoubleSchema() || NumberSchema() => AckNumberSchemaModel( + description: schema.description, + nullable: schema.isNullable, + ), + BooleanSchema() => AckBooleanSchemaModel( description: schema.description, nullable: schema.isNullable, ), - BooleanSchema() => _boolean(schema), EnumSchema() => _enum(schema), ListSchema() => _array(schema), MapSchema() => _map(schema), @@ -393,31 +397,6 @@ final class _SchemaModelBuilder { return share(normalize(document, base))! as Map; } - AckSchemaModel _string(StringSchema schema) { - return AckStringSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); - } - - AckSchemaModel _integer(IntegerSchema schema) { - return AckIntegerSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); - } - - AckSchemaModel _number({String? description, required bool nullable}) { - return AckNumberSchemaModel(description: description, nullable: nullable); - } - - AckSchemaModel _boolean(BooleanSchema schema) { - return AckBooleanSchemaModel( - description: schema.description, - nullable: schema.isNullable, - ); - } - AckSchemaModel _enum(EnumSchema schema) { return AckStringSchemaModel( description: schema.description, @@ -451,7 +430,7 @@ final class _SchemaModelBuilder { for (final entry in schema.properties.entries) { ordering.add(entry.key); - properties[entry.key] = wrapPropertyConversion( + properties[entry.key] = _wrapPropertyConversion( entry.key, () => _build(entry.value), ); @@ -599,9 +578,6 @@ final class _SchemaModelBuilder { if (schema.excludesTargetNull) model = _excludeNull(model); final constraintCount = schema.runtimeConstraintCount; final refinementCount = schema.runtimeRefinementCount; - if (constraintCount == 0 && refinementCount == 0) { - return model; - } return model.withWarnings([ ...model.warnings, @@ -810,8 +786,26 @@ Object? _jsonRoundTripOrNull(Object? value) { } } -// Only schema-bearing keywords contain resource identities. Values of const, -// default, examples, and unknown annotations remain ordinary instance data. +/// Adds the property name to supported conversion errors without losing stacks. +T _wrapPropertyConversion(String key, T Function() fn) { + try { + return fn(); + } catch (e, st) { + final msg = + 'Error converting property "$key": ${e is Error ? e.toString() : e}'; + if (e is UnsupportedError) { + Error.throwWithStackTrace(UnsupportedError(msg), st); + } else if (e is ArgumentError) { + Error.throwWithStackTrace(ArgumentError(msg), st); + } else if (e is StateError) { + Error.throwWithStackTrace(StateError(msg), st); + } + rethrow; + } +} + +// Rewrite identities only at compiler-indexed schema locations. Annotation +// payloads stay ordinary data unless explicitly referenced as schemas. Map _mapImportedChildren( Map schema, Object? Function(Object?, List) visit, diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 40bce509..f07027a2 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,8 @@ ### Fixed +* Preserve schema flags and object policies when `copyWith` no-op `null` + arguments are parenthesized. * Preserve schema-first presence, nullability, and default-wrapper behavior through aliases and explicit constant flag overrides. Apply the outermost strict/passthrough policy to object models and aliases. diff --git a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart index def485cd..13761554 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -2,11 +2,14 @@ import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/nullability_suffix.dart'; import 'package:analyzer/dart/element/type.dart'; +// Analyzer 13 has no public API for substituting supplied method arguments +// into nested or F-bounded types. Keep its complete substituter for these bounds. +// ignore: implementation_imports +import 'package:analyzer/src/dart/element/type_algebra.dart' show Substitution; import 'package:source_gen/source_gen.dart'; import '../models/schema_model_graph.dart'; import '../utils/string_literal.dart'; -import '../utils/type_parameter_bounds.dart'; import 'generated_companion_visibility.dart'; import 'model_graph_builder_base.dart'; @@ -594,14 +597,15 @@ final class CustomSchemaResolver { element: field, ); } - final bounds = instantiateTypeParameterBounds( + final substitution = Substitution.fromPairs2( method.typeParameters, type.typeArguments, ); for (var i = 0; i < method.typeParameters.length; i++) { final typeParam = method.typeParameters[i]; - final bound = bounds[i]; - if (bound == null) continue; + final rawBound = typeParam.bound; + if (rawBound == null) continue; + final bound = substitution.substituteType(rawBound); final argument = type.typeArguments[i]; if (!_library.element.typeSystem.isSubtypeOf(argument, bound)) { throw InvalidGenerationSource( diff --git a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart index 5f359646..cb424c82 100644 --- a/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart +++ b/packages/ack_generator/lib/src/analyzer/model_graph_builder_base.dart @@ -155,14 +155,11 @@ abstract base class ModelGraphBuilderBase { /// Returns the `@Schemable` top-level declaration behind [element], if any. Element? schemableDeclaration(Element element) { - final declaration = switch (element) { - GetterElement(isOriginVariable: true) => element.variable.baseElement, - GetterElement() => element.baseElement, - TopLevelVariableElement() => element.baseElement, - _ => null, - }; - return declaration != null && - schemableChecker.hasAnnotationOfExact(declaration) + if (element is! GetterElement && element is! TopLevelVariableElement) { + return null; + } + final declaration = propertyDeclaration(element); + return schemableChecker.hasAnnotationOfExact(declaration) ? declaration : null; } @@ -847,9 +844,7 @@ abstract base class ModelGraphBuilderBase { : null; bool? constantBool(Expression expression) { - while (expression is ParenthesizedExpression) { - expression = expression.expression; - } + expression = expression.unParenthesized; if (expression is BooleanLiteral) return expression.value; final element = switch (expression) { diff --git a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart index a2bd7051..2af15cb4 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_model_graph_builder.dart @@ -46,6 +46,12 @@ final class _SchemaMetadata { final bool innerOptional; final bool innerNullable; + AckSchemaFieldPresence get presence { + if (defaulted) return AckSchemaFieldPresence.defaulted; + if (optional) return AckSchemaFieldPresence.optional; + return AckSchemaFieldPresence.required; + } + _SchemaMetadata copyWith({bool? optional, bool? nullable}) => _SchemaMetadata( optional: (optional ?? this.optional) || innerOptional, nullable: (nullable ?? this.nullable) || innerNullable, @@ -417,7 +423,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { AckFieldNode( dartName: jsonKey, jsonKey: jsonKey, - presence: _fieldPresence(fieldMetadata), + presence: fieldMetadata.presence, nullable: fieldMetadata.nullable, runtimeRef: runtimeRef, description: descriptionFromExpression(entry.value), @@ -546,7 +552,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required String path, required Element context, required bool throughLazy, - Set? visited, + Set visited = const {}, int depth = 0, String? followedName, bool collectionElement = false, @@ -708,7 +714,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: path, context: context, throughLazy: throughLazy, - visited: visited ?? {}, + visited: visited, depth: depth, ); if (followed != null) return followed; @@ -1142,12 +1148,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ]; } - AckSchemaFieldPresence _fieldPresence(_SchemaMetadata metadata) { - if (metadata.defaulted) return AckSchemaFieldPresence.defaulted; - if (metadata.optional) return AckSchemaFieldPresence.optional; - return AckSchemaFieldPresence.required; - } - String _className( String declarationName, String? customName, @@ -1447,7 +1447,10 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { for (final argument in current.argumentList.arguments) { final entry = namedArgument(argument); if (entry?.name != 'additionalProperties') continue; - if (entry!.expression is NullLiteral && name == 'copyWith') continue; + if (entry!.expression.unParenthesized is NullLiteral && + name == 'copyWith') { + continue; + } final value = constantBool(entry.expression); if (value != null) return value; throw InvalidGenerationSource( @@ -1568,10 +1571,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { final nullable = named('isNullable'); final optional = named('isOptional'); return inner.copyWith( - nullable: nullable == null || nullable is NullLiteral + nullable: + nullable == null || nullable.unParenthesized is NullLiteral ? null : flag(nullable, '$name isNullable'), - optional: optional == null || optional is NullLiteral + optional: + optional == null || optional.unParenthesized is NullLiteral ? null : flag(optional, '$name isOptional'), ); diff --git a/packages/ack_generator/lib/src/utils/type_parameter_bounds.dart b/packages/ack_generator/lib/src/utils/type_parameter_bounds.dart deleted file mode 100644 index ab3e1440..00000000 --- a/packages/ack_generator/lib/src/utils/type_parameter_bounds.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:analyzer/dart/element/element.dart'; -import 'package:analyzer/dart/element/type.dart'; -// The public TypeSystem API cannot substitute nested or F-bounded types. -// Keep this analyzer-13-specific dependency here while the package is pinned -// to >=13.0.0 <14.0.0, rather than maintaining a partial type substituter. -// ignore: implementation_imports -import 'package:analyzer/src/dart/element/type_algebra.dart' show Substitution; - -/// Substitutes all method type arguments into their parameter bounds. -List instantiateTypeParameterBounds( - List parameters, - List arguments, -) { - final substitution = Substitution.fromPairs2(parameters, arguments); - return [ - for (final parameter in parameters) - if (parameter.bound case final bound?) - substitution.substituteType(bound) - else - null, - ]; -} diff --git a/packages/ack_generator/test/integration/ack_infer_graph_test.dart b/packages/ack_generator/test/integration/ack_infer_graph_test.dart index 79b0c291..aea3dba8 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -111,6 +111,30 @@ final titleSchema = Ack.string().nullable().nullable(value: false); ); }); + for (final omitted in ['null', '(null)', '((null))']) { + test('treats copyWith null arguments as no-ops: $omitted', () async { + await _expectOutput( + ''' +$_head +final name = Ack.string().nullable().optional().copyWith( + isNullable: $omitted, isOptional: $omitted, +); +@Schemable() +final flagsSchema = Ack.object({'name': name}); +@Schemable() +final policySchema = Ack.object({}, additionalProperties: true).copyWith( + additionalProperties: $omitted, +); +''', + allOf([ + contains('final String? name;'), + isNot(contains('required this.name')), + contains('Policy({Map additionalProperties'), + ]), + ); + }); + } + test('preserves wrapper flag and outer default semantics', () async { await _expectOutput( '''