From dd664749bb9e3029af3aa9aeba286a47c06e3df3 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 19:15:04 -0400 Subject: [PATCH 1/4] fix(generator): reconcile JSON boundary and member contracts --- CHANGELOG.md | 3 + packages/ack/CHANGELOG.md | 3 + packages/ack/lib/ack.dart | 6 +- packages/ack/lib/src/schemas/any_schema.dart | 3 +- .../ack/lib/src/schemas/default_schema.dart | 14 +- .../ack/lib/src/utils/collection_utils.dart | 21 +- .../ack/test/utils/collection_utils_test.dart | 38 +++ packages/ack_generator/CHANGELOG.md | 14 + packages/ack_generator/README.md | 20 ++ .../analyzer/class_model_graph_builder.dart | 9 +- .../lib/src/analyzer/schema_first_names.dart | 10 +- .../analyzer/schema_model_graph_builder.dart | 167 +++++++++++- .../lib/src/builders/data_class_emitter.dart | 12 +- .../lib/src/models/schema_model_graph.dart | 5 +- .../lib/src/utils/string_literal.dart | 82 +----- .../integration/ack_infer_graph_test.dart | 70 ++++- .../integration/ack_infer_models_test.dart | 26 ++ .../ack_infer_runtime_build_test.dart | 254 ++++++++++++++++++ .../integration/class_model_graph_test.dart | 20 ++ .../test/src/schema_first_names_test.dart | 42 +++ 20 files changed, 712 insertions(+), 107 deletions(-) create mode 100644 packages/ack_generator/test/src/schema_first_names_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb959c2..28f7da47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline ### Fixed +* Accept contextual Dart member names and deeply snapshot JSON-owned generated + fields without changing opaque codec values. Preserve effective nullable + overrides when inferring explicit JSON boundaries. * Align wrapper and lazy nullability across parsing, encoding, and JSON Schema export; validate discriminated codec outputs against their runtime branches. * Reject duration inputs that cannot preserve their original milliseconds. diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 5e9239aa..fe301be1 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -2,6 +2,9 @@ ### Fixed +* Expose the shared `deepUnmodifiableCopy` helper for generated immutable + constructors. It snapshots nested collections without validating data or + changing opaque noncollection objects. * Honor explicit boundary-wrapper nullability consistently during parse and encode. Preserve non-null restrictions on wrappers and lazy references when exporting nullable primitives, unions, and imported JSON Schema resources. diff --git a/packages/ack/lib/ack.dart b/packages/ack/lib/ack.dart index 816b7543..fdd6d599 100644 --- a/packages/ack/lib/ack.dart +++ b/packages/ack/lib/ack.dart @@ -12,7 +12,11 @@ export 'src/common_types.dart' show JsonMap; export 'src/models/ack_model_adapter.dart'; export 'src/models/json_maybe.dart'; export 'src/utils/collection_utils.dart' - show deepEquals, deepHashCode, deepUnmodifiableJsonMap; + show + deepEquals, + deepHashCode, + deepUnmodifiableCopy, + deepUnmodifiableJsonMap; // Constraints export 'src/constraints/constraint.dart'; export 'src/constraints/duration_constraint.dart'; diff --git a/packages/ack/lib/src/schemas/any_schema.dart b/packages/ack/lib/src/schemas/any_schema.dart index d5a8bdbc..91578873 100644 --- a/packages/ack/lib/src/schemas/any_schema.dart +++ b/packages/ack/lib/src/schemas/any_schema.dart @@ -24,8 +24,7 @@ final class AnySchema extends AckSchema final validated = result.getOrNull(); if (validated == null) return result; - // Same deep, unmodifiable copy that schema defaults use. - return SchemaResult.ok(cloneDefault(validated)!); + return SchemaResult.ok(deepUnmodifiableCopy(validated)!); } @override diff --git a/packages/ack/lib/src/schemas/default_schema.dart b/packages/ack/lib/src/schemas/default_schema.dart index 23d6bbdb..f1587107 100644 --- a/packages/ack/lib/src/schemas/default_schema.dart +++ b/packages/ack/lib/src/schemas/default_schema.dart @@ -30,16 +30,15 @@ final class DefaultSchema @internal SchemaResult resolveDefaultWithContext(SchemaContext context) { // Defaults are runtime values, not boundary values, so validate via the - // runtime path. `cloneDefault` returns unmodifiable collection copies when - // it can; mutable collection defaults are rejected if the inner schema - // would otherwise return the original reference. - final cloned = cloneDefault(defaultValue); + // runtime path. Reject collection defaults when neither the snapshot nor + // the inner schema can detach the value while preserving its runtime type. + final cloned = deepUnmodifiableCopy(defaultValue); final clonedSafely = cloned is Runtime && !identical(cloned, defaultValue); - final effective = (cloned is Runtime) ? cloned : defaultValue; + final effective = cloned is Runtime ? cloned : defaultValue; final result = inner.validateRuntimeWithContext(effective, context); if (result.isOk && !clonedSafely && - _isCollectionDefault(defaultValue) && + (defaultValue is List || defaultValue is Map || defaultValue is Set) && identical(result.getOrNull(), defaultValue)) { return SchemaResult.fail( SchemaValidationError( @@ -157,6 +156,3 @@ final class DefaultSchema int get hashCode => Object.hash(inner, defaultValue, isNullable, isOptional, description); } - -bool _isCollectionDefault(Object value) => - value is List || value is Map || value is Set; diff --git a/packages/ack/lib/src/utils/collection_utils.dart b/packages/ack/lib/src/utils/collection_utils.dart index 63f4e71a..ff20a761 100644 --- a/packages/ack/lib/src/utils/collection_utils.dart +++ b/packages/ack/lib/src/utils/collection_utils.dart @@ -5,31 +5,34 @@ import '../common_types.dart'; /// Nested maps, lists, and sets are copied without validating their contents. JsonMap deepUnmodifiableJsonMap(JsonMap value) => Map.unmodifiable( - value.map((key, item) => MapEntry(key, _deepUnmodifiableCopy(item))), + value.map((key, item) => MapEntry(key, deepUnmodifiableCopy(item))), ); -/// Deeply snapshots a schema default without exposing it from ACK's public API. -Object? cloneDefault(Object? value) => _deepUnmodifiableCopy(value); - -Object? _deepUnmodifiableCopy(Object? value) { +/// Returns a detached, recursively unmodifiable snapshot of [value]. +/// +/// Maps retain their keys while their values are copied. Lists and sets are +/// copied recursively. Other objects, including opaque codec values, retain +/// their identity. This does not validate data; schemas still validate it at +/// parse and encode boundaries. Inputs must be acyclic. +Object? deepUnmodifiableCopy(Object? value) { if (value is Map) { if (value.keys.every((key) => key is String)) { return Map.unmodifiable( value.map( - (key, item) => MapEntry(key as String, _deepUnmodifiableCopy(item)), + (key, item) => MapEntry(key as String, deepUnmodifiableCopy(item)), ), ); } return Map.unmodifiable( - value.map((key, item) => MapEntry(key, _deepUnmodifiableCopy(item))), + value.map((key, item) => MapEntry(key, deepUnmodifiableCopy(item))), ); } if (value is List) { - return List.unmodifiable(value.map(_deepUnmodifiableCopy)); + return List.unmodifiable(value.map(deepUnmodifiableCopy)); } if (value is Set) { - return Set.unmodifiable(value.map(_deepUnmodifiableCopy)); + return Set.unmodifiable(value.map(deepUnmodifiableCopy)); } return value; diff --git a/packages/ack/test/utils/collection_utils_test.dart b/packages/ack/test/utils/collection_utils_test.dart index f5b673a2..903cf1ae 100644 --- a/packages/ack/test/utils/collection_utils_test.dart +++ b/packages/ack/test/utils/collection_utils_test.dart @@ -2,6 +2,44 @@ import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { + test('deepUnmodifiableCopy accepts null and preserves opaque leaves', () { + final opaque = Object(); + final timestamp = DateTime.utc(2026, 10, 26); + expect(deepUnmodifiableCopy(null), isNull); + expect(deepUnmodifiableCopy(opaque), same(opaque)); + expect(deepUnmodifiableCopy(timestamp), same(timestamp)); + expect(deepUnmodifiableCopy(double.infinity), double.infinity); + }); + + test( + 'deepUnmodifiableCopy snapshots arbitrary map keys, lists, and sets', + () { + final opaque = Object(); + final nested = [opaque, null]; + final map = {1: nested}; + final set = {map}; + final source = [map, set]; + final snapshot = deepUnmodifiableCopy(source)! as List; + nested.clear(); + map[2] = 'later'; + set.clear(); + source.clear(); + + final copiedMap = snapshot.first! as Map; + expect(copiedMap.keys, [1]); + final copiedList = copiedMap[1]! as List; + expect(copiedList.first, same(opaque)); + expect(copiedList.last, isNull); + final copiedSet = snapshot.last! as Set; + expect(copiedSet, hasLength(1)); + expect(deepEquals(copiedSet.single, copiedMap), isTrue); + expect(() => snapshot.clear(), throwsUnsupportedError); + expect(() => copiedMap.clear(), throwsUnsupportedError); + expect(() => copiedList.clear(), throwsUnsupportedError); + expect(() => copiedSet.clear(), throwsUnsupportedError); + }, + ); + test('deepUnmodifiableJsonMap snapshots nested maps, lists, and sets', () { final nestedMap = {'enabled': true}; final nestedList = [nestedMap]; diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 8d1a4831..aadd5f06 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,20 @@ ### Fixed +* Resolve preserved JSON boundary nullability through the same effective + metadata as ordinary fields, including aliases and explicit flag overrides. +* Snapshot nested JSON collections held as `Object` or `Object?` in + schema-first model constructors and `copyWith`. Reuse Ack's shared deep + copier; constructors still do not validate data. Opaque codec runtime values + retain their identity and container types. Use `Ack.preserveBoundary(...)` + for heterogeneous JSON fields; native JSON unions are inferred only within + that explicit wire view. +* Allow built-in and contextual Dart keywords such as `required` as generated + field, discriminator, and capture-member names without changing their JSON + keys. Reserved words such as `class` remain rejected. Member validation now + follows the analyzer's keyword classification rather than a fixed word list. + `dynamic` remains rejected because it shadows the type in generated JSON + methods, not because it is a reserved Dart word. * Preserve schema flags and object policies when `copyWith` no-op `null` arguments are parenthesized. * Preserve schema-first presence, nullability, and default-wrapper behavior diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index 9eb8030f..cb7ccbd3 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -173,6 +173,26 @@ void main() { } ``` +### Heterogeneous JSON fields + +Use `Ack.preserveBoundary(...)` for a generated field that stores wire JSON +rather than a codec's decoded runtime values. For example: + +```dart +final answerValues = Ack.preserveBoundary(Ack.map(Ack.anyOf([ + Ack.string(), Ack.number(), Ack.list(Ack.string()), +]))); + +@Schemable() +final answerSchema = Ack.object({'values': answerValues}); +``` + +The constructor snapshots nested JSON collections without validating them. +Parsing and encoding still enforce the original schema. Opaque codec runtime +values retain their identity and runtime container types. Statically +unresolvable or opaque JSON input schemas are rejected instead of inferred. +`Ack.anyOf(...)` model roots remain unsupported. + ### Custom schema resolution A field of custom type `T` (including inside `List`, `Set`, `Map`, diff --git a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart index 71b23021..4e773dad 100644 --- a/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart +++ b/packages/ack_generator/lib/src/analyzer/class_model_graph_builder.dart @@ -1411,9 +1411,16 @@ final class ClassModelGraphBuilder extends ModelGraphBuilderBase { Element element, { required String memberKind, }) { + if (name == 'dynamic') { + throw InvalidGenerationSource( + '${element.name}.$name shadows the dynamic type used by generated ' + 'JSON methods.', + element: element, + ); + } if (name.startsWith('_') || !RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(name) || - isDartKeyword(name)) { + isDartReservedWord(name)) { throw InvalidGenerationSource( '${element.name}.$name is not a valid public $memberKind.', element: element, diff --git a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart index 3564da12..a7c05001 100644 --- a/packages/ack_generator/lib/src/analyzer/schema_first_names.dart +++ b/packages/ack_generator/lib/src/analyzer/schema_first_names.dart @@ -1,9 +1,10 @@ import '../utils/string_literal.dart'; -/// Member names every schema-first model generates, so a field cannot use -/// them. +/// Names that conflict with generated APIs or their unqualified type references. const ackSchemaFirstReservedMembers = { r'$ack', + // Generated JSON signatures use the unqualified dynamic type. + 'dynamic', 'parse', 'safeParse', 'fromJson', @@ -30,9 +31,12 @@ String? ackSchemaFirstFieldNameProblem( return "cannot start with '_' (private Dart member)."; } if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(key) || - isDartKeyword(key)) { + isDartReservedWord(key)) { return 'cannot be represented as a Dart field name.'; } + if (key == 'dynamic') { + return 'shadows the dynamic type used by generated JSON methods.'; + } if (ackSchemaFirstReservedMembers.contains(key)) { return 'conflicts with generated/Object member "$key".'; } 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 41215681..d6a42289 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 @@ -413,18 +413,19 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { todo: 'Extract it to a named @Schemable schema declaration.', ); } + final runtimeRef = await _runtimeRefForSchema( + entry.value, + path: fieldPath, + context: declaration.element, + throughLazy: false, + ); fields.add( AckFieldNode( dartName: jsonKey, jsonKey: jsonKey, presence: fieldMetadata.presence, nullable: fieldMetadata.nullable, - runtimeRef: await _runtimeRefForSchema( - entry.value, - path: fieldPath, - context: declaration.element, - throughLazy: false, - ), + runtimeRef: runtimeRef, description: descriptionFromExpression(entry.value), ), ); @@ -599,7 +600,13 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); case 'any': // A JSON-safe value field. Ack.any() roots are rejected in _resolve. - return const AckScalarTypeRef('Object'); + return const AckScalarTypeRef('Object', isJsonValue: true); + case 'preserveBoundary': + final arguments = argumentExpressions(chain.base!.argumentList); + if (arguments.length != 1) { + _rejectUnsupportedRoot('preserveBoundary', path, context); + } + return _jsonBoundaryRef(expression, path: path, context: context); case 'anyOf': case 'instance': _rejectUnsupportedRoot(baseName, path, context); @@ -772,6 +779,150 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } + /// Infers an explicitly preserved JSON boundary without inferring opaque + /// codec runtime values. Reuses the normal source/reference machinery. + Future _jsonBoundaryRef( + Expression expression, { + required String path, + required Element context, + Set visited = const {}, + int depth = 0, + }) async { + if (depth >= ModelGraphBuilderBase.maxReferenceDepth) { + throw InvalidGenerationSource( + '$path exceeds JSON boundary reference depth.', + element: context, + ); + } + final chain = parseSchemaChain(expression); + _rejectTransform(chain, path, context); + final base = chain.base; + AckInferRef result; + switch (base?.methodName.name) { + case 'string' || 'literal' || 'enumString': + result = const AckScalarTypeRef('String'); + case 'integer': + result = const AckScalarTypeRef('int'); + case 'number': + result = const AckScalarTypeRef('num'); + case 'double': + result = const AckScalarTypeRef('double'); + case 'boolean': + result = const AckScalarTypeRef('bool'); + case 'any': + result = const AckScalarTypeRef('Object', isJsonValue: true); + case 'list' || 'map' || 'preserveBoundary': + final arguments = argumentExpressions(base!.argumentList); + if (arguments.length != 1) { + return _unsupportedJsonBoundary(path, context); + } + final inner = await _jsonBoundaryRef( + arguments.single, + path: '$path[]', + context: context, + visited: visited, + depth: depth + 1, + ); + if (base.methodName.name == 'list') { + if (inner is AckNullableTypeRef) { + return _unsupportedJsonBoundary(path, context); + } + result = AckListTypeRef(inner); + } else if (base.methodName.name == 'map') { + result = AckMapTypeRef(inner); + } else { + result = inner; + } + case 'object': + final arguments = argumentExpressions(base!.argumentList); + if (arguments.firstOrNull case SetOrMapLiteral(:final elements)) { + for (final entry in elements) { + if (entry is! MapLiteralEntry) { + return _unsupportedJsonBoundary(path, context); + } + await _jsonBoundaryRef( + entry.value, + path: '$path{}', + context: context, + visited: visited, + depth: depth + 1, + ); + } + result = const AckMapTypeRef( + AckNullableTypeRef(AckScalarTypeRef('Object', isJsonValue: true)), + ); + } else { + return _unsupportedJsonBoundary(path, context); + } + case 'anyOf': + final arguments = argumentExpressions(base!.argumentList); + if (arguments.firstOrNull case ListLiteral( + :final elements, + ) when elements.isNotEmpty) { + for (final branch in elements) { + if (branch is! Expression) { + return _unsupportedJsonBoundary(path, context); + } + final inner = await _jsonBoundaryRef( + branch, + path: '$path.anyOf', + context: context, + visited: visited, + depth: depth + 1, + ); + // Nullable alternatives require an explicit union .nullable() so + // the field's storage/constructor nullability remains unambiguous. + if (inner is AckNullableTypeRef) { + return _unsupportedJsonBoundary(path, context); + } + } + result = const AckScalarTypeRef('Object', isJsonValue: true); + } else { + return _unsupportedJsonBoundary(path, context); + } + default: + final reference = chain.reference; + final element = reference == null ? null : referencedElement(reference); + if (element == null || + (element is! TopLevelVariableElement && + element is! GetterElement) || + visited.contains(element.baseElement)) { + return _unsupportedJsonBoundary(path, context); + } + final target = await resolveReferencedTopLevelExpression( + element, + visited: visited, + ); + if (target == null) return _unsupportedJsonBoundary(path, context); + result = await _jsonBoundaryRef( + target.expression, + path: '$path(→ ${element.name})', + context: context, + visited: {...visited, target.declaration.baseElement}, + depth: depth + 1, + ); + } + // Codec outputs are intentionally ignored here: preserveBoundary stores + // and validates the original input-side JSON, not decoded runtime objects. + final metadata = await _schemaMetadata( + expression, + path: path, + context: context, + ); + // An outer flag can clear an alias's nullable type without changing the + // nullability of its nested collection values. + final nonNullable = result is AckNullableTypeRef ? result.inner : result; + return metadata.nullable ? AckNullableTypeRef(nonNullable) : nonNullable; + } + + Never _unsupportedJsonBoundary(String path, Element context) => + throw InvalidGenerationSource( + '$path cannot prove a native JSON boundary. Use Ack.preserveBoundary ' + 'with statically resolvable JSON schemas; opaque instance/codec input ' + 'types and nullable union alternatives are not inferred.', + element: context, + ); + Future _lazyType( MethodInvocation invocation, String path, @@ -1107,7 +1258,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } if (!RegExp(r'^[A-Za-z$][A-Za-z0-9_$]*$').hasMatch(jsonKey) || - isDartKeyword(jsonKey)) { + isDartReservedWord(jsonKey)) { throw InvalidGenerationSource( '$path.$jsonKey cannot be represented as a Dart field name.', element: element, diff --git a/packages/ack_generator/lib/src/builders/data_class_emitter.dart b/packages/ack_generator/lib/src/builders/data_class_emitter.dart index 05955e9c..5e4e3822 100644 --- a/packages/ack_generator/lib/src/builders/data_class_emitter.dart +++ b/packages/ack_generator/lib/src/builders/data_class_emitter.dart @@ -367,7 +367,10 @@ ${_ack('SchemaResult')}> safeToJson() => bool requiresImmutableCopy(AckInferRef type) => switch (type) { AckNullableTypeRef(:final inner) => requiresImmutableCopy(inner), - AckListTypeRef() || AckSetTypeRef() || AckMapTypeRef() => true, + AckScalarTypeRef(dartType: 'Object', isJsonValue: true) || + AckListTypeRef() || + AckSetTypeRef() || + AckMapTypeRef() => true, _ => false, }; @@ -382,6 +385,13 @@ ${_ack('SchemaResult')}> safeToJson() => String immutableCopy(AckInferRef type, String expression) { return switch (type) { + AckNullableTypeRef( + inner: AckScalarTypeRef(dartType: 'Object', isJsonValue: true), + ) => + '${_ack('deepUnmodifiableCopy')}($expression)', + // A non-null input stays non-null; no generic collection casts are used. + AckScalarTypeRef(dartType: 'Object', isJsonValue: true) => + '${_ack('deepUnmodifiableCopy')}($expression)!', AckNullableTypeRef(:final inner) when !requiresImmutableCopy(inner) => expression, AckNullableTypeRef(:final inner) => diff --git a/packages/ack_generator/lib/src/models/schema_model_graph.dart b/packages/ack_generator/lib/src/models/schema_model_graph.dart index 5c954cd6..ac9eb9f8 100644 --- a/packages/ack_generator/lib/src/models/schema_model_graph.dart +++ b/packages/ack_generator/lib/src/models/schema_model_graph.dart @@ -91,9 +91,12 @@ final class AckNullableTypeRef extends AckInferRef { /// A core scalar such as `String`, `int`, `double`, `bool`, or `num`. final class AckScalarTypeRef extends AckInferRef { - const AckScalarTypeRef(this.dartType); + const AckScalarTypeRef(this.dartType, {this.isJsonValue = false}); final String dartType; + + /// Object values owned by a JSON schema, rather than an opaque codec. + final bool isJsonValue; } /// A visible type declared outside the generated model graph. diff --git a/packages/ack_generator/lib/src/utils/string_literal.dart b/packages/ack_generator/lib/src/utils/string_literal.dart index 6eec0b7e..a5ce7502 100644 --- a/packages/ack_generator/lib/src/utils/string_literal.dart +++ b/packages/ack_generator/lib/src/utils/string_literal.dart @@ -1,3 +1,5 @@ +import 'package:analyzer/dart/ast/token.dart' show Keyword; + /// Returns [value] as a single-quoted Dart string literal. /// /// Escapes backslashes, quotes, and `$`, and writes control characters and @@ -19,75 +21,13 @@ String dartStringLiteral(String value) { String qualifySymbol(String? prefix, String symbol) => prefix == null || prefix.isEmpty ? symbol : '$prefix.$symbol'; -/// Whether [value] is a reserved word, built-in identifier, or contextual -/// keyword that cannot be used as an unqualified Dart member or type name. -bool isDartKeyword(String value) => _dartKeywords.contains(value); +/// Whether [value] is a Dart keyword, including built-in identifiers and +/// contextual keywords. Type-name guards have stricter rules than members. +bool isDartKeyword(String value) => Keyword.keywords.containsKey(value); -const _dartKeywords = { - 'abstract', - 'as', - 'assert', - 'async', - 'await', - 'base', - 'break', - 'case', - 'catch', - 'class', - 'const', - 'continue', - 'covariant', - 'default', - 'deferred', - 'do', - 'dynamic', - 'else', - 'enum', - 'export', - 'extends', - 'extension', - 'external', - 'factory', - 'false', - 'final', - 'finally', - 'for', - 'get', - 'hide', - 'if', - 'implements', - 'import', - 'in', - 'interface', - 'is', - 'late', - 'library', - 'mixin', - 'new', - 'null', - 'of', - 'on', - 'operator', - 'part', - 'required', - 'rethrow', - 'return', - 'sealed', - 'set', - 'show', - 'static', - 'super', - 'switch', - 'sync', - 'this', - 'throw', - 'true', - 'try', - 'typedef', - 'var', - 'void', - 'when', - 'while', - 'with', - 'yield', -}; +/// Whether [value] is reserved in every Dart identifier context. +/// +/// Built-in identifiers and contextual keywords remain valid member names in +/// the synchronous constructors, getters, and methods emitted by the generator. +bool isDartReservedWord(String value) => + Keyword.keywords[value]?.isReservedWord ?? false; 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 692b2e12..f8c62511 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -21,6 +21,7 @@ Future _expectFailure(String source, List messages) async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); final seen = {}; + final logs = []; await testBuilder( ackModelBuilder(BuilderOptions.empty), {'test_pkg|lib/schema.dart': source}, @@ -28,13 +29,14 @@ Future _expectFailure(String source, List messages) async { readerWriter: readerWriter, outputs: const {}, onLog: (LogRecord log) { + logs.add('${log.level}: ${log.message}'); if (log.level.name != 'SEVERE') return; for (final message in messages) { if (log.message.contains(message)) seen.add(message); } }, ); - expect(seen, containsAll(messages)); + expect(seen, containsAll(messages), reason: logs.join('\n')); } const _head = ''' @@ -133,6 +135,54 @@ final policySchema = Ack.object({}, additionalProperties: true).copyWith( }); } + test('preserved JSON boundaries honor an outer nullable clear', () async { + await _expectOutput(''' +$_head +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'value': Ack.preserveBoundary(Ack.string().nullable()).nullable(value: false), +}); +''', allOf([contains('final String value;'), contains('required this.value')])); + }); + + test('preserved JSON boundaries use effective nested flags', () async { + await _expectOutput( + ''' +$_head +final nullableValue = Ack.string().nullable(); +final nullableAlias = nullableValue; +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), + 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), + 'clearedValues': Ack.preserveBoundary(Ack.map(nullableAlias.nullable(value: false))), + 'items': Ack.preserveBoundary(Ack.list(nullableAlias.nullable(value: false))), + 'outerMap': Ack.preserveBoundary(Ack.map(nullableValue).nullable()).nullable(value: false), +}); +''', + allOf([ + contains('final Map copiedValues;'), + contains('final Map configuredValues;'), + contains('final Map clearedValues;'), + contains('final List items;'), + contains('final Map outerMap;'), + ]), + ); + }); + + test('rejects actually nullable preserved boundary list items', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'items': Ack.preserveBoundary(Ack.list(Ack.string().nullable())), +}); +''', + ['boundaryFlagsSchema.items', 'cannot prove a native JSON boundary'], + ); + }); + test('preserves wrapper flag and outer default semantics', () async { await _expectOutput( ''' @@ -483,6 +533,24 @@ final payloadSchema = ${unsupported.key}; }); } + for (final boundary in [ + 'Ack.instance()', + 'Ack.anyOf([Ack.string(), Ack.instance()])', + ]) { + test('rejects opaque JSON boundary inference for $boundary', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final boundaryRecordSchema = Ack.object({ + 'value': Ack.preserveBoundary($boundary), +}); +''', + ['boundaryRecordSchema.value', 'cannot prove a native JSON boundary'], + ); + }); + } + test('rejects a .transform() field with the declaration path', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/ack_infer_models_test.dart b/packages/ack_generator/test/integration/ack_infer_models_test.dart index 17be2ce9..ab8d3071 100644 --- a/packages/ack_generator/test/integration/ack_infer_models_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_models_test.dart @@ -502,6 +502,32 @@ final class \$UserCopyWith {} }, ); + test('rejects a field that shadows the generated dynamic type', () async { + final messages = {}; + await _build( + { + 'bad.dart': + ''' +$_imports +part 'bad.g.dart'; + +@Schemable() +final badSchema = Ack.object({'dynamic': Ack.string()}); +''', + }, + outputs: const {}, + onLog: (log) { + if (log.level.name == 'SEVERE') messages.add(log.message); + }, + ); + expect( + messages.single, + contains( + 'badSchema.dynamic shadows the dynamic type used by generated JSON methods', + ), + ); + }); + test('rejects Dart keywords used as generated field names', () async { final messages = {}; await _build( diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index fdcee051..ff379704 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -237,6 +237,97 @@ final openPolicySchema = strictPolicySchema.passthrough(); @Schemable() final resetNullableSchema = Ack.string().nullable().nullable(value: false); +@Schemable() +final keywordFieldsSchema = Ack.object({ + 'required': Ack.boolean().withDefault(false), + 'abstract': Ack.string(), + 'as': Ack.string(), + 'late': Ack.string(), + 'operator': Ack.string(), + 'async': Ack.string(), + 'await': Ack.string(), + 'yield': Ack.string(), +}); + +final choiceOptionSchema = Ack.anyOf([ + Ack.string(), + Ack.object({'label': Ack.string(), 'value': Ack.string()}), +]); +final choiceOptionsSchema = Ack.preserveBoundary(Ack.list(choiceOptionSchema)); + +@Schemable() +final choiceFieldSchema = Ack.object({'options': choiceOptionsSchema.optional()}); + +final opaqueValueSchema = Ack.string().codec( + decode: DateTime.parse, + encode: (value) => (value as DateTime).toIso8601String(), +); + +@Schemable() +final opaqueRecordSchema = Ack.object({'value': opaqueValueSchema}); +final typedListValueSchema = Ack.string().codec( + decode: (_) => [1, 2], + encode: (value) => (value as List).join(','), +); +final typedMapValueSchema = Ack.string().codec( + decode: (_) => >{'values': [3, 4]}, + encode: (value) => (value as Map>)['values']!.join(','), +); + +@Schemable() +final opaqueCollectionsSchema = Ack.object({ + 'value': typedListValueSchema, + 'items': Ack.list(typedListValueSchema), + 'byKey': Ack.map(typedListValueSchema), + 'map': typedMapValueSchema, + 'nullable': typedListValueSchema.nullable(), +}); + +final answerValueSchema = Ack.anyOf([ + Ack.string(), Ack.number(), Ack.list(Ack.string()), +]); +final answerValuesSchema = Ack.preserveBoundary(Ack.map(answerValueSchema)); + +@Schemable() +final answerRecordSchema = Ack.object({ + 'values': answerValuesSchema, + 'ratio': Ack.preserveBoundary(Ack.double()), +}); + +final nullableBoundaryValue = Ack.string().nullable(); +final nullableBoundaryAlias = nullableBoundaryValue; + +@Schemable() +final boundaryFlagsSchema = Ack.object({ + 'copiedValues': Ack.preserveBoundary(Ack.map(Ack.string().copyWith(isNullable: true))), + 'configuredValues': Ack.preserveBoundary(Ack.map(Ack.string().withRuntimeConfig(isNullable: true))), + 'clearedValues': Ack.preserveBoundary(Ack.map(nullableBoundaryAlias.nullable(value: false))), + 'items': Ack.preserveBoundary(Ack.list(nullableBoundaryAlias.nullable(value: false))), + 'outer': Ack.preserveBoundary(nullableBoundaryValue).nullable(value: false), + 'outerMap': Ack.preserveBoundary(Ack.map(nullableBoundaryValue).nullable()).nullable(value: false), +}); + + +@Schemable() +final keywordBranchSchema = Ack.object({'answer': Ack.string()}); + +@Schemable() +final keywordUnionSchema = Ack.discriminated( + discriminatorKey: 'required', + schemas: {'yes': keywordBranchSchema}, +); + +@Schemable( + unknownProperties: AckUnknownPropertyPolicy.capture, + captureField: 'required', +) +final class KeywordCapture with _$KeywordCaptureAck { + KeywordCapture({Map required = const {}}) + : required = Map.unmodifiable(required); + + final Map required; +} + final class Counted { Counted(this.value); final String value; @@ -337,6 +428,169 @@ import 'package:ack_ack_infer_runtime/person.dart'; import 'package:test/test.dart'; void main() { + test('contextual and built-in field names retain their JSON keys', () { + final json = { + 'required': true, + 'abstract': 'a', + 'as': 'b', + 'late': 'c', + 'operator': 'd', + 'async': 'e', + 'await': 'f', + 'yield': 'g', + }; + final fields = KeywordFields.parse(json); + expect(fields.required, isTrue); + expect(fields.abstract, 'a'); + expect(fields.as, 'b'); + expect(fields.late, 'c'); + expect(fields.operator, 'd'); + expect(fields.async, 'e'); + expect(fields.await, 'f'); + expect(fields.yield, 'g'); + expect(fields.toJson(), json); + expect(fields.copyWith(required: false).toJson(), {...json, 'required': false}); + expect(KeywordFields.parse({...json}..remove('required')).required, isFalse); + }); + + test('constructor snapshots Object-valued option maps without validating them', () { + final option = {'label': 'Keep', 'value': 'keep'}; + final options = ['skip', option]; + final field = ChoiceField(options: options); + option['value'] = 'changed'; + options.clear(); + final expected = ChoiceField.parse({ + 'options': ['skip', {'label': 'Keep', 'value': 'keep'}], + }); + expect(field, expected); + expect(field.hashCode, expected.hashCode); + expect(field.toJson(), expected.toJson()); + expect(() => field.options!.clear(), throwsUnsupportedError); + expect(() => (field.options!.last as Map)['value'] = 'changed', + throwsUnsupportedError); + expect(ChoiceField(options: [false]).safeToJson().isFail, isTrue); + expect(ChoiceField(options: [Object()]).safeToJson().isFail, isTrue); + expect(ChoiceField(options: [{1: 'invalid'}]) + .safeToJson().isFail, isTrue); + }); + + test('direct Object fields and typed collections snapshot nested aliases', () { + final items = ['original']; + final nested = {'items': items}; + final optional = [nested]; + final record = Envelope( + kind: nested, + payload: optional, + items: [nested], + values: {'key': nested}, + metadata: {'key': nested, 'absent': null}, + labels: const {}, + ); + items.add('changed'); + nested.clear(); + optional.clear(); + const expected = {'items': ['original']}; + expect(record.kind, expected); + expect(record.payload, [expected]); + expect(record.items, [expected]); + expect(record.values, {'key': expected}); + expect(record.metadata, {'key': expected, 'absent': null}); + expect(() => (record.kind as Map).clear(), throwsUnsupportedError); + expect(() => ((record.kind as Map)['items'] as List).clear(), throwsUnsupportedError); + expect(() => (record.payload as List).clear(), throwsUnsupportedError); + expect(() => (record.values['key']! as Map).clear(), throwsUnsupportedError); + expect(() => (record.metadata['key']! as Map).clear(), throwsUnsupportedError); + }); + + test('Object-valued codecs preserve opaque constructor values', () { + final value = DateTime.utc(2026, 10, 26); + final record = OpaqueRecord(value: value); + expect(record.value, same(value)); + expect(record.toJson(), {'value': value.toIso8601String()}); + }); + + + test('opaque codec Object collections retain their reified runtime types', () { + final list = [1, 2]; + final nested = >{'values': [3, 4]}; + final record = OpaqueCollections(value: list, items: [list], byKey: {'key': list}, + map: nested, nullable: list); + expect(record.value, same(list)); + expect(record.items.single, same(list)); + expect(record.byKey['key'], same(list)); + expect(record.map, same(nested)); + expect(record.nullable, same(list)); + expect(record.toJson(), {'value': '1,2', 'items': ['1,2'], 'byKey': {'key': '1,2'}, + 'map': '3,4', 'nullable': '1,2'}); + final parsed = OpaqueCollections.parse({'value': 'x', 'items': ['x'], 'byKey': {'key': 'x'}, + 'map': 'x', 'nullable': 'x'}); + expect(parsed.value, isA>()); + expect(parsed.items.single, isA>()); + expect(parsed.byKey['key'], isA>()); + expect(parsed.map, isA>>()); + expect(parsed.nullable, isA>()); + expect(parsed.copyWith(nullable: null).toJson()['nullable'], isNull); + expect(parsed.toJson(), record.toJson()); + }); + + test('explicit JSON boundary unions encode normalized nested string lists', () { + final tags = ['a', 'b']; + final values = {'tags': tags, 'title': 'Review', 'count': 2}; + final record = AnswerRecord(values: values, ratio: 0.5); + tags.add('changed'); + values.clear(); + final expected = {'tags': ['a', 'b'], 'title': 'Review', 'count': 2}; + expect(record.values, expected); + expect(record.toJson(), {'values': expected, 'ratio': 0.5}); + final double ratio = record.ratio; + expect(ratio, 0.5); + expect(AnswerRecord.parse({'values': expected, 'ratio': 0.5}).toJson(), + {'values': expected, 'ratio': 0.5}); + expect(() => (record.values['tags'] as List).clear(), throwsUnsupportedError); + expect(AnswerRecord(values: {'invalid': false}, ratio: 0.5).safeToJson().isFail, isTrue); + }); + + test('preserved boundary metadata agrees with generated runtime fields', () { + final json = { + 'copiedValues': {'a': null}, + 'configuredValues': {'b': null}, + 'clearedValues': {'c': 'present'}, + 'items': ['present'], + 'outer': 'present', + 'outerMap': {'d': null}, + }; + final record = BoundaryFlags.parse(json); + final Map copied = record.copiedValues; + final Map configured = record.configuredValues; + final Map cleared = record.clearedValues; + final List items = record.items; + final String outer = record.outer; + final Map outerMap = record.outerMap; + expect(copied['a'], isNull); + expect(configured['b'], isNull); + expect(cleared['c'], 'present'); + expect(items, ['present']); + expect(outer, 'present'); + expect(outerMap['d'], isNull); + expect(record.toJson(), json); + expect(BoundaryFlags.parse(record.toJson()), record); + expect(BoundaryFlags.safeParse({...json, 'outer': null}).isFail, isTrue); + expect(BoundaryFlags.safeParse({...json, 'outerMap': null}).isFail, isTrue); + expect(BoundaryFlags.safeParse({...json, 'clearedValues': {'c': null}}).isFail, isTrue); + expect(() => record.copiedValues['a'] = 'changed', throwsUnsupportedError); + }); + + test('contextual discriminator and capture members retain their wire shape', () { + final unionJson = {'required': 'yes', 'answer': 'done'}; + final union = KeywordUnion.parse(unionJson); + expect(union.required, 'yes'); + expect(union.toJson(), unionJson); + + final capture = KeywordCaptureSchema.parse({'extra': 1}); + expect(capture.required, {'extra': 1}); + expect(capture.toJson(), {'extra': 1}); + }); + test('models expose their model schema as a static shorthand', () { final AckSchema, Node> schema = Node.schema; expect(identical(Node.schema, schema), isTrue); diff --git a/packages/ack_generator/test/integration/class_model_graph_test.dart b/packages/ack_generator/test/integration/class_model_graph_test.dart index 7442ff92..d0ec25d5 100644 --- a/packages/ack_generator/test/integration/class_model_graph_test.dart +++ b/packages/ack_generator/test/integration/class_model_graph_test.dart @@ -550,6 +550,26 @@ final class Config with _\$ConfigAck { ); }); + test( + 'rejects a capture field that shadows the generated dynamic type', + () async { + await _expectFailure( + ''' +@Schemable( + unknownProperties: AckUnknownPropertyPolicy.capture, + captureField: 'dynamic', +) +final class Config with _\$ConfigAck { + const Config({required this.dynamic}); + + final Map dynamic; +} +''', + ['Config.dynamic', 'shadows the dynamic type'], + ); + }, + ); + test('rejects captureField without the capture policy', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/src/schema_first_names_test.dart b/packages/ack_generator/test/src/schema_first_names_test.dart new file mode 100644 index 00000000..86e5d56e --- /dev/null +++ b/packages/ack_generator/test/src/schema_first_names_test.dart @@ -0,0 +1,42 @@ +import 'package:ack_generator/src/analyzer/schema_first_names.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/token.dart'; +import 'package:test/test.dart'; + +void main() { + test('member checks distinguish keywords from generated type collisions', () { + for (final entry in Keyword.keywords.entries) { + if (entry.value.isReservedWord) continue; + final name = entry.key; + expect( + ackSchemaFirstFieldNameProblem(name, capturesExtras: false), + name == 'dynamic' + ? 'shadows the dynamic type used by generated JSON methods.' + : isNull, + reason: name, + ); + final parsed = parseString( + content: + '''class Fields { + const Fields({required this.$name}); + final bool $name; + Fields copyWith({bool? $name}) => Fields($name: $name ?? this.$name); +} +''', + throwIfDiagnostics: false, + ); + expect(parsed.errors, isEmpty, reason: name); + } + }); + + test('reserved words remain invalid field names', () { + for (final entry in Keyword.keywords.entries) { + if (!entry.value.isReservedWord) continue; + expect( + ackSchemaFirstFieldNameProblem(entry.key, capturesExtras: false), + 'cannot be represented as a Dart field name.', + reason: entry.key, + ); + } + }); +} From 254835b4244bb0ae609a920d90ff69f2f5c065ba Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 19:20:47 -0400 Subject: [PATCH 2/4] fix(generator): honor consuming null policy on boundary aliases --- .../analyzer/schema_model_graph_builder.dart | 12 +++++- .../integration/ack_infer_graph_test.dart | 25 +++++++++++++ .../ack_infer_runtime_build_test.dart | 37 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) 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 d6a42289..22f1e5aa 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 @@ -717,7 +717,17 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited: visited, depth: depth, ); - if (followed != null) return followed; + if (followed != null) { + if (followed is AckNullableTypeRef && + !(await _schemaMetadata( + expression, + path: path, + context: context, + )).nullable) { + return followed.inner; + } + return followed; + } } } 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 f8c62511..48b4a394 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -170,6 +170,31 @@ final boundaryFlagsSchema = Ack.object({ ); }); + test('preserved boundary aliases honor consuming flag overrides', () async { + await _expectOutput( + ''' +$_head +final nullableText = Ack.preserveBoundary(Ack.string().nullable()); +final textAlias = nullableText; +final nullableMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); +final mapAlias = nullableMap; +@Schemable() +final boundaryAliasesSchema = Ack.object({ + 'text': textAlias.nullable(value: false), + 'values': mapAlias.nullable(value: false), + 'groups': Ack.list(mapAlias.nullable(value: false)), + 'byName': Ack.map(mapAlias.nullable(value: false)), +}); +''', + allOf([ + contains('final String text;'), + contains('final Map values;'), + contains('final List> groups;'), + contains('final Map> byName;'), + ]), + ); + }); + test('rejects actually nullable preserved boundary list items', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index ff379704..265eb29a 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -307,6 +307,19 @@ final boundaryFlagsSchema = Ack.object({ 'outerMap': Ack.preserveBoundary(Ack.map(nullableBoundaryValue).nullable()).nullable(value: false), }); +final nullableBoundaryText = Ack.preserveBoundary(Ack.string().nullable()); +final boundaryTextAlias = nullableBoundaryText; +final nullableBoundaryMap = Ack.preserveBoundary(Ack.map(Ack.string().nullable()).nullable()); +final boundaryMapAlias = nullableBoundaryMap; + +@Schemable() +final boundaryAliasesSchema = Ack.object({ + 'text': boundaryTextAlias.nullable(value: false), + 'values': boundaryMapAlias.nullable(value: false), + 'groups': Ack.list(boundaryMapAlias.nullable(value: false)), + 'byName': Ack.map(boundaryMapAlias.nullable(value: false)), +}); + @Schemable() final keywordBranchSchema = Ack.object({'answer': Ack.string()}); @@ -580,6 +593,30 @@ void main() { expect(() => record.copiedValues['a'] = 'changed', throwsUnsupportedError); }); + test('preserved boundary aliases clear only their outer nullable type', () { + final json = { + 'text': 'present', + 'values': {'a': null}, + 'groups': [{'b': null}], + 'byName': {'first': {'c': null}}, + }; + final record = BoundaryAliases.parse(json); + final String text = record.text; + final Map values = record.values; + final List> groups = record.groups; + final Map> byName = record.byName; + expect(text, 'present'); + expect(values['a'], isNull); + expect(groups.single['b'], isNull); + expect(byName['first']!['c'], isNull); + expect(record.toJson(), json); + expect(BoundaryAliases.parse(record.toJson()), record); + expect(BoundaryAliases.safeParse({...json, 'text': null}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'values': null}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'groups': [null]}).isFail, isTrue); + expect(BoundaryAliases.safeParse({...json, 'byName': {'first': null}}).isFail, isTrue); + }); + test('contextual discriminator and capture members retain their wire shape', () { final unionJson = {'required': 'yes', 'answer': 'done'}; final union = KeywordUnion.parse(unionJson); From d82f2f3679783c7b45646c8570617ff5eef47dd2 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 19:22:23 -0400 Subject: [PATCH 3/4] fix(generator): preserve native codec boundary inference --- packages/ack_generator/CHANGELOG.md | 3 + .../analyzer/schema_model_graph_builder.dart | 58 +++++++++++++++---- .../integration/ack_infer_graph_test.dart | 37 ++++++++++++ .../ack_infer_runtime_build_test.dart | 55 ++++++++++++++++++ 4 files changed, 143 insertions(+), 10 deletions(-) diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index aadd5f06..d8662bce 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,9 @@ ### Fixed +* Preserve native codec boundary types when inferring `Ack.preserveBoundary` + fields, including dates, datetimes, URIs, durations, enums, explicit codec + inputs and statically resolvable nonrecursive lazy schemas. * Resolve preserved JSON boundary nullability through the same effective metadata as ordinary fields, including aliases and explicit flag overrides. * Snapshot nested JSON collections held as `Object` or `Object?` in 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 22f1e5aa..0ef7c6a0 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 @@ -809,18 +809,56 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { final base = chain.base; AckInferRef result; switch (base?.methodName.name) { - case 'string' || 'literal' || 'enumString': - result = const AckScalarTypeRef('String'); - case 'integer': - result = const AckScalarTypeRef('int'); - case 'number': - result = const AckScalarTypeRef('num'); - case 'double': - result = const AckScalarTypeRef('double'); - case 'boolean': - result = const AckScalarTypeRef('bool'); + case 'string' || + 'literal' || + 'enumString' || + 'integer' || + 'number' || + 'double' || + 'boolean' || + 'date' || + 'datetime' || + 'uri' || + 'duration' || + 'enumValues': + // Native factories own their boundary types; decoded codec types do + // not belong in a preserved wire field. + result = _schemaTypes(base!, path, context).boundary; case 'any': result = const AckScalarTypeRef('Object', isJsonValue: true); + case 'codec': + Expression? input; + for (final node in base!.argumentList.arguments) { + final argument = namedArgument(node); + if (argument?.name == 'input') { + input = argument!.expression; + break; + } + } + if (input == null) return _unsupportedJsonBoundary(path, context); + result = await _jsonBoundaryRef( + input, + path: '$path.input', + context: context, + visited: visited, + depth: depth + 1, + ); + case 'lazy': + final arguments = argumentExpressions(base!.argumentList); + if (arguments.length < 2 || arguments[1] is! FunctionExpression) { + return _unsupportedJsonBoundary(path, context); + } + final target = functionBodyExpression( + (arguments[1] as FunctionExpression).body, + ); + if (target == null) return _unsupportedJsonBoundary(path, context); + result = await _jsonBoundaryRef( + target, + path: '$path.lazy', + context: context, + visited: visited, + depth: depth + 1, + ); case 'list' || 'map' || 'preserveBoundary': final arguments = argumentExpressions(base!.argumentList); if (arguments.length != 1) { 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 48b4a394..33a051ee 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -561,6 +561,7 @@ final payloadSchema = ${unsupported.key}; for (final boundary in [ 'Ack.instance()', 'Ack.anyOf([Ack.string(), Ack.instance()])', + 'Ack.codec(input: Ack.instance(), decode: (value) => value, encode: (value) => value)', ]) { test('rejects opaque JSON boundary inference for $boundary', () async { await _expectFailure( @@ -576,6 +577,42 @@ final boundaryRecordSchema = Ack.object({ }); } + test( + 'preserves native codec boundary types instead of decoded types', + () async { + await _expectOutput( + ''' +$_head +enum Status { active, archived } + +@Schemable() +final nativeBoundarySchema = Ack.object({ + 'day': Ack.preserveBoundary(Ack.date()), + 'instant': Ack.preserveBoundary(Ack.datetime()), + 'url': Ack.preserveBoundary(Ack.uri()), + 'milliseconds': Ack.preserveBoundary(Ack.duration()), + 'status': Ack.preserveBoundary(Ack.enumValues(Status.values)), + 'boxed': Ack.preserveBoundary(Ack.codec( + input: Ack.string(), + decode: DateTime.parse, + encode: (DateTime value) => value.toIso8601String(), + )), + 'later': Ack.preserveBoundary(Ack.lazy('later', () => Ack.integer(), maxDepth: 4)), +}); +''', + allOf([ + contains('final String day;'), + contains('final String instant;'), + contains('final String url;'), + contains('final int milliseconds;'), + contains('final String status;'), + contains('final String boxed;'), + contains('final int later;'), + ]), + ); + }, + ); + test('rejects a .transform() field with the declaration path', () async { await _expectFailure( ''' diff --git a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart index 265eb29a..6aa6dd34 100644 --- a/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_runtime_build_test.dart @@ -320,6 +320,27 @@ final boundaryAliasesSchema = Ack.object({ 'byName': Ack.map(boundaryMapAlias.nullable(value: false)), }); +enum WireStatus { active, archived } + +@Schemable() +final nativeBoundarySchema = Ack.object({ + 'day': Ack.preserveBoundary(Ack.date()), + 'instant': Ack.preserveBoundary(Ack.datetime()), + 'url': Ack.preserveBoundary(Ack.uri()), + 'milliseconds': Ack.preserveBoundary(Ack.duration()), + 'status': Ack.preserveBoundary(Ack.enumValues(WireStatus.values)), + 'boxed': Ack.preserveBoundary(Ack.codec( + input: Ack.string(), + decode: DateTime.parse, + encode: (DateTime value) => value.toIso8601String(), + )), + 'later': Ack.preserveBoundary(Ack.lazy('later', () => Ack.integer(), maxDepth: 4)), + 'payload': Ack.preserveBoundary(Ack.object({ + 'createdAt': Ack.datetime(), + 'statuses': Ack.list(Ack.enumValues(WireStatus.values)), + })), +}); + @Schemable() final keywordBranchSchema = Ack.object({'answer': Ack.string()}); @@ -617,6 +638,40 @@ void main() { expect(BoundaryAliases.safeParse({...json, 'byName': {'first': null}}).isFail, isTrue); }); + test('preserved native codecs keep JSON boundary types and validation', () { + final json = { + 'day': '2026-10-26', + 'instant': '2026-10-26T12:00:00Z', + 'url': 'https://example.invalid/review', + 'milliseconds': 1500, + 'status': 'active', + 'boxed': '2026-10-26T12:00:00Z', + 'later': 7, + 'payload': { + 'createdAt': '2026-10-26T12:00:00Z', + 'statuses': ['active'], + }, + }; + final record = NativeBoundary.parse(json); + final String instant = record.instant; + final String status = record.status; + final int milliseconds = record.milliseconds; + final int later = record.later; + expect(instant, json['instant']); + expect(status, 'active'); + expect(milliseconds, 1500); + expect(later, 7); + expect(record.toJson(), json); + expect(record.copyWith(milliseconds: 2500).toJson(), { + ...json, + 'milliseconds': 2500, + }); + expect(NativeBoundary.safeParse({...json, 'instant': 'bad'}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'status': 'unknown'}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'milliseconds': 1.5}).isFail, isTrue); + expect(NativeBoundary.safeParse({...json, 'boxed': 'bad'}).isFail, isTrue); + }); + test('contextual discriminator and capture members retain their wire shape', () { final unionJson = {'required': 'yes', 'answer': 'done'}; final union = KeywordUnion.parse(unionJson); From cf2c087cbba95dd477e594b80e280df334ae9ad8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 19:32:30 -0400 Subject: [PATCH 4/4] fix(generator): prove preserved boundary input shapes --- packages/ack_generator/CHANGELOG.md | 2 ++ .../analyzer/schema_model_graph_builder.dart | 16 ++++++++--- .../integration/ack_infer_graph_test.dart | 27 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index d8662bce..988971ed 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,8 @@ ### Fixed +* Reject unproven object-shape changes inside preserved boundaries, including + aliases and fluent codecs, rather than copying opaque values as JSON. * Preserve native codec boundary types when inferring `Ack.preserveBoundary` fields, including dates, datetimes, URIs, durations, enums, explicit codec inputs and statically resolvable nonrecursive lazy schemas. 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 0ef7c6a0..e43d59a0 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 @@ -804,6 +804,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { element: context, ); } + await _rejectObjectShapeChanges( + expression, + path: path, + context: context, + throughCodecs: true, + ); final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); final base = chain.base; @@ -1390,6 +1396,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required String path, required Element context, Set visited = const {}, + bool throughCodecs = false, }) async { Expression? current = expression; while (current is ParenthesizedExpression) { @@ -1397,9 +1404,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } while (current is MethodInvocation) { final name = current.methodName.name; - // A value codec owns its runtime shape, so its input need not expose - // statically generated object fields. - if (name == 'codec') return; + // Runtime models can stop at a value codec. Boundary inference still + // needs to prove the input shape underneath that codec. + if (name == 'codec' && !throughCodecs) return; if (const {'extend', 'partial', 'merge', 'pick', 'omit'}.contains(name) || (name == 'copyWith' && current.argumentList.arguments.any( @@ -1421,7 +1428,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { isTypeOwnedSchema(current.methodName.element)) { break; } - current = current.target; + current = current.target?.unParenthesized; } if (current == null) return; final target = await resolveReferencedTopLevelExpression( @@ -1434,6 +1441,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: '$path(→ ${target.declaration.name})', context: context, visited: {...visited, target.declaration.baseElement}, + throughCodecs: throughCodecs, ); } 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 33a051ee..8ccd1d37 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -558,6 +558,33 @@ final payloadSchema = ${unsupported.key}; }); } + for (final change in [ + "extend({'payload': Ack.instance>()})", + "copyWith(properties: {'payload': Ack.instance>()})", + ]) { + for (final conversion in [ + '', + '.codec(decode: (value) => value, encode: (value) => value as Map)', + ]) { + test( + 'rejects unproven boundary shape changes: $change$conversion', + () async { + await _expectFailure( + ''' +$_head +final alteredBoundary = (Ack.object({}).$change)$conversion; +@Schemable() +final boundaryRecordSchema = Ack.object({ + 'value': Ack.preserveBoundary(alteredBoundary), +}); +''', + ['boundaryRecordSchema.value', change.split('(').first, 'shape'], + ); + }, + ); + } + } + for (final boundary in [ 'Ack.instance()', 'Ack.anyOf([Ack.string(), Ack.instance()])',