From 60261c45d08b7f1a60b74b0519f05b3f799f2239 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 10:55:12 -0400 Subject: [PATCH 01/37] fix(ack): align wrapper and lazy nullability contracts --- packages/ack/CHANGELOG.md | 8 + .../ack_schema_model_builder.dart | 18 +- .../ack/lib/src/schemas/boundary_schema.dart | 4 +- packages/ack/lib/src/schemas/lazy_schema.dart | 4 + .../ack/lib/src/schemas/wrapper_schema.dart | 7 + .../nullability_export_contract_test.dart | 182 ++++++++++++++++++ 6 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 packages/ack/test/schemas/nullability_export_contract_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 6a5b33c2..542cdbdc 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,11 @@ +## Unreleased + +### Fixed + +* 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. + ## 2.0.0-alpha.1 ### Breaking 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 a884814a..e9b731d3 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 @@ -106,9 +106,10 @@ final class _SchemaModelBuilder { AckSchemaModel _build(AckSchema schema) { if (schema is WrapperSchema) { final base = _build(schema.inner); - final layered = base + var layered = base .withDescription(schema.description ?? base.description) - .withNullable(schema.isNullable || base.nullable); + .withNullable(schema.isNullable); + if (schema.excludesInnerNull) layered = _excludeNull(layered); // `DefaultSchema.constraints` is a passthrough to `inner.constraints`, // which `_build(schema.inner)` already applied. Re-running them here // would emit duplicate warnings (e.g. datetime range under a default). @@ -553,11 +554,12 @@ final class _SchemaModelBuilder { } AckSchemaModel _lazyRef(LazySchema schema) { - var model = AckRefSchemaModel( + AckSchemaModel model = AckRefSchemaModel( refName: schema.name, description: schema.description, nullable: schema.isNullable, ); + if (schema.excludesTargetNull) model = _excludeNull(model); final constraintCount = schema.runtimeConstraintCount; final refinementCount = schema.runtimeRefinementCount; if (constraintCount == 0 && refinementCount == 0) { @@ -602,6 +604,16 @@ final class _SchemaModelBuilder { } } +AckSchemaModel _excludeNull(AckSchemaModel model) => model.withExtensions({ + ...model.extensions, + 'allOf': [ + ...?model.extensions['allOf'] as List?, + const { + 'not': {'type': 'null'}, + }, + ], +}); + AckSchemaModel _applyConstraints( AckSchemaModel model, AckSchema schema, { diff --git a/packages/ack/lib/src/schemas/boundary_schema.dart b/packages/ack/lib/src/schemas/boundary_schema.dart index 0d545f24..8775e33e 100644 --- a/packages/ack/lib/src/schemas/boundary_schema.dart +++ b/packages/ack/lib/src/schemas/boundary_schema.dart @@ -31,9 +31,11 @@ final class BoundarySchema Object? value, SchemaContext context, ) { + final nullResult = handleNullInput(value, context); + if (nullResult != null) return nullResult; + final result = inner.parseWithContext(value, context); if (result.isFail) return SchemaResult.fail(result.getError()); - if (value == null) return SchemaResult.ok(null); final Boundary boundary; final normalizedMap = jsonMapOrNull(value); diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 094f5877..594aee9a 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -61,6 +61,10 @@ final class LazySchema @internal AckSchema get target => _target; + /// Whether the lazy reference's own null policy narrows its target. + @internal + bool get excludesTargetNull => !acceptsNull && _target.acceptsNull; + /// Count of runtime-only constraints, including the always-present /// max-depth check that every `Ack.lazy` schema enforces. @internal diff --git a/packages/ack/lib/src/schemas/wrapper_schema.dart b/packages/ack/lib/src/schemas/wrapper_schema.dart index 469ee8c7..45b169b6 100644 --- a/packages/ack/lib/src/schemas/wrapper_schema.dart +++ b/packages/ack/lib/src/schemas/wrapper_schema.dart @@ -25,6 +25,13 @@ mixin WrapperSchema< /// The wrapped schema used for boundary-shape traversal. AnyAckSchema get inner; + /// Whether this wrapper rejects null that its inner schema would accept. + /// + /// Exporters must preserve this restriction even when null acceptance is + /// embedded in a union or an imported resource rather than a nullable flag. + @internal + bool get excludesInnerNull => !acceptsNull && inner.acceptsNull; + /// Returns a copy of this wrapper with [inner] swapped for [newInner]. /// /// Used by traversal utilities (e.g. discriminated-branch synthesis) that diff --git a/packages/ack/test/schemas/nullability_export_contract_test.dart b/packages/ack/test/schemas/nullability_export_contract_test.dart new file mode 100644 index 00000000..91010abc --- /dev/null +++ b/packages/ack/test/schemas/nullability_export_contract_test.dart @@ -0,0 +1,182 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +AnyOfSchema _nullableUnion() => Ack.anyOf([ + Ack.anyOf([Ack.string().nullable(), Ack.integer()]), + Ack.boolean(), +]); + +AckSchema _nullableImport() => Ack.fromJsonSchema({ + 'anyOf': [ + {'type': 'string'}, + {'type': 'null'}, + ], + 'allOf': [ + {'minLength': 2}, + ], +}); + +void main() { + final cases = + <({String name, AckSchema schema, bool acceptsNull})>[ + ( + name: 'boundary widens a non-null primitive', + schema: Ack.preserveBoundary(Ack.string()).nullable(), + acceptsNull: true, + ), + ( + name: 'boundary narrows a nullable primitive', + schema: Ack.preserveBoundary( + Ack.string().nullable(), + ).nullable(value: false), + acceptsNull: false, + ), + ( + name: 'boundary narrows a nested nullable union', + schema: Ack.preserveBoundary(_nullableUnion()).nullable(value: false), + acceptsNull: false, + ), + ( + name: 'boundary narrows imported source nullability', + schema: Ack.preserveBoundary( + _nullableImport(), + ).nullable(value: false), + acceptsNull: false, + ), + ( + name: 'codec narrows a nullable primitive', + schema: Ack.string() + .nullable() + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false), + acceptsNull: false, + ), + ( + name: 'codec narrows a nested nullable union', + schema: _nullableUnion() + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false), + acceptsNull: false, + ), + ( + name: 'codec narrows imported source nullability', + schema: _nullableImport() + .codec(decode: (value) => value, encode: (value) => value) + .nullable(value: false), + acceptsNull: false, + ), + ( + name: 'lazy uses its non-null flag over a nullable primitive', + schema: Ack.lazy('NullableString', () => Ack.string().nullable()), + acceptsNull: false, + ), + ( + name: 'lazy narrows a nested nullable union', + schema: Ack.lazy( + 'NullableUnion', + _nullableUnion, + ).nullable().nullable(value: false), + acceptsNull: false, + ), + ( + name: 'lazy uses its non-null flag over imported source nullability', + schema: Ack.lazy('NullableImport', _nullableImport), + acceptsNull: false, + ), + ( + name: 'lazy widens a non-null target', + schema: Ack.lazy('NonNullString', Ack.string).nullable(), + acceptsNull: true, + ), + ]; + + for (final testCase in cases) { + group(testCase.name, () { + final schema = testCase.schema; + final nested = Ack.object({'value': schema}); + + test('parse and encode honor the same root and property null policy', () { + expect(schema.safeParse(null).isOk, testCase.acceptsNull); + expect(schema.safeEncode(null).isOk, testCase.acceptsNull); + expect(nested.safeParse({'value': null}).isOk, testCase.acceptsNull); + expect(nested.safeEncode({'value': null}).isOk, testCase.acceptsNull); + expect(schema.parse('ok'), 'ok'); + expect(schema.encode('ok'), 'ok'); + }); + + for (final draft7 in [false, true]) { + test( + '${draft7 ? 'Draft-7' : 'default'} export preserves null policy', + () { + final restored = Ack.fromJsonSchema( + draft7 ? schema.toJsonSchemaDraft7() : schema.toJsonSchema(), + ); + final restoredNested = Ack.fromJsonSchema( + draft7 ? nested.toJsonSchemaDraft7() : nested.toJsonSchema(), + ); + expect(restored.safeParse(null).isOk, testCase.acceptsNull); + expect( + restoredNested.safeParse({'value': null}).isOk, + testCase.acceptsNull, + ); + expect(restored.safeParse('ok').isOk, isTrue); + expect(restoredNested.safeParse({'value': 'ok'}).isOk, isTrue); + }, + ); + } + }); + } + + test('a non-null restriction keeps imported source allOf assertions', () { + final schema = Ack.preserveBoundary( + _nullableImport(), + ).nullable(value: false); + for (final exported in [ + schema.toJsonSchema(), + schema.toJsonSchemaDraft7(), + ]) { + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse('x').isFail, isTrue); + expect(restored.safeParse(1).isFail, isTrue); + expect(restored.safeParse('ok').isOk, isTrue); + } + }); + + group('default null semantics', () { + test('nullable defaults still substitute only on parse', () { + final schema = Ack.string().nullable().withDefault('fallback'); + expect(schema.parse(null), 'fallback'); + expect(schema.encode(null), isNull); + expect( + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse(null).isOk, + isTrue, + ); + }); + + test('defaults over nullable unions retain effective null acceptance', () { + final schema = _nullableUnion().withDefault('fallback'); + expect(schema.isNullable, isFalse); + expect(schema.parse(null), 'fallback'); + expect(schema.encode(null), isNull); + expect( + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse(null).isOk, + isTrue, + ); + }); + + test( + 'a default outside a narrowed boundary still substitutes on parse', + () { + final schema = Ack.preserveBoundary( + _nullableUnion(), + ).nullable(value: false).withDefault('fallback'); + expect(schema.parse(null), 'fallback'); + expect(schema.safeEncode(null).isFail, isTrue); + expect( + Ack.fromJsonSchema(schema.toJsonSchema()).safeParse(null).isFail, + isTrue, + ); + }, + ); + }); +} From ef3ced6d4a7586dda06bda7b019a90436fa3b9e2 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 10:59:55 -0400 Subject: [PATCH 02/37] fix(ack): validate discriminated codec output branches --- packages/ack/CHANGELOG.md | 3 + .../schemas/discriminated_object_schema.dart | 60 ++++- ...discriminated_runtime_validation_test.dart | 229 ++++++++++++++++++ 3 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 packages/ack/test/schemas/discriminated_runtime_validation_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 542cdbdc..285ed253 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -5,6 +5,9 @@ * 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. +* Validate discriminated codec output against its selected map branch or a + matching typed runtime branch, including required fields and refinements, + without decoding runtime values again or adding encode callback passes. ## 2.0.0-alpha.1 diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 862250ec..91cb0aa7 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -334,6 +334,55 @@ final class DiscriminatedObjectSchema Object? value, SchemaContext context, ) { + final checked = _checkRuntimeType(value, context); + if (checked.isFail || value == null) return checked; + final runtime = checked.getOrNull()!; + + final mapRuntime = jsonMapOrNull(runtime); + if (mapRuntime != null) { + final discResult = _validateDiscriminatorKey(mapRuntime, context); + if (discResult.isFail) return SchemaResult.fail(discResult.getError()); + final discValue = discResult.getOrNull()!; + final effective = effectiveBranch(discValue); + final branchContext = context.createChild( + name: 'when $discriminatorKey="$discValue"', + schema: effective, + value: runtime, + pathSegment: '', + ); + final result = effective.validateRuntimeWithContext( + runtime, + branchContext, + ); + if (result.isFail) return SchemaResult.fail(result.getError()); + return applyConstraintsAndRefinements(result.getOrNull()!, context); + } + + final errors = []; + for (final discValue in schemas.keys) { + final effective = effectiveBranch(discValue); + final branchContext = context.createChild( + name: 'when $discriminatorKey="$discValue"', + schema: effective, + value: runtime, + pathSegment: '', + ); + final result = effective.validateRuntimeWithContext( + runtime, + branchContext, + ); + if (result.isOk) { + return applyConstraintsAndRefinements(result.getOrNull()!, context); + } + errors.add(result.getError()); + } + + return SchemaResult.fail( + SchemaNestedError(errors: errors, context: context), + ); + } + + SchemaResult _checkRuntimeType(Object? value, SchemaContext context) { final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; if (value is! T) { @@ -346,13 +395,20 @@ final class DiscriminatedObjectSchema ); } - return applyConstraintsAndRefinements(value, context); + return SchemaResult.ok(value); } @override @protected SchemaResult encodeWithContext(T value, SchemaContext context) { - final validated = validateRuntimeWithContext(value, context); + final checked = _checkRuntimeType(value, context); + if (checked.isFail) return SchemaResult.fail(checked.getError()); + final typed = checked.getOrNull(); + if (typed == null) return SchemaResult.ok(null); + + // Encoding validates each branch below. Repeating the full runtime + // membership check here would execute branch callbacks an extra time. + final validated = applyConstraintsAndRefinements(typed, context); if (validated.isFail) { return SchemaResult.fail(validated.getError()); } diff --git a/packages/ack/test/schemas/discriminated_runtime_validation_test.dart b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart new file mode 100644 index 00000000..6a3b3a36 --- /dev/null +++ b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart @@ -0,0 +1,229 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +final class _Cat { + const _Cat(this.name); + final String name; +} + +final class _Dog { + const _Dog(this.name); + final String name; +} + +enum _Role { member } + +CodecSchema _codecWithOutput( + AckSchema output, + T decoded, +) => Ack.codec( + input: Ack.string(), + decode: (_) => decoded, + encode: (_) => 'wire', + output: output, +); + +Iterable _errorPaths(SchemaError error) sync* { + yield error.path; + if (error is SchemaNestedError) { + for (final child in error.errors) { + yield* _errorPaths(child); + } + } +} + +void main() { + group('discriminated codec output validation', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({'count': Ack.integer()}), + }, + ); + final invalidCases = <({String name, JsonMap value, String errorPath})>[ + ( + name: 'wrong property runtime type', + value: {'kind': 'a', 'count': 'bad'}, + errorPath: '#/count', + ), + ( + name: 'unknown discriminator', + value: {'kind': 'unknown', 'count': 1}, + errorPath: '#/kind', + ), + (name: 'missing discriminator', value: {'count': 1}, errorPath: '#/kind'), + ( + name: 'missing required property', + value: {'kind': 'a'}, + errorPath: '#/count', + ), + ]; + for (final testCase in invalidCases) { + test('rejects ${testCase.name} on decode and before encode', () { + var encodeCalls = 0; + final codec = Ack.codec( + input: Ack.string(), + decode: (_) => testCase.value, + encode: (_) { + encodeCalls++; + return 'wire'; + }, + output: output, + ); + for (final result in [ + codec.safeParse('wire'), + codec.safeEncode(testCase.value), + ]) { + expect(result.isFail, isTrue); + expect(_errorPaths(result.getError()), contains(testCase.errorPath)); + } + expect(encodeCalls, 0); + }); + } + + test('validates already-decoded nested codec and enum runtimes', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({ + 'createdAt': Ack.datetime(), + 'role': Ack.enumValues(_Role.values), + }), + }, + ); + final runtime = { + 'kind': 'a', + 'createdAt': DateTime.utc(2026, 1, 1), + 'role': _Role.member, + }; + final codec = _codecWithOutput(output, runtime); + expect(codec.parse('wire'), runtime); + expect(codec.encode(runtime), 'wire'); + }); + + test('typed runtimes must satisfy at least one branch runtime schema', () { + final output = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) => {'name': cat.name}, + output: Ack.instance<_Cat>().refine((cat) => cat.name.isNotEmpty), + ), + 'dog': Ack.object({'name': Ack.string()}).codec<_Dog>( + decode: (map) => _Dog(map['name']! as String), + encode: (dog) => {'name': dog.name}, + ), + }, + ); + for (final invalid in [Object(), const _Cat('')]) { + final codec = _codecWithOutput(output, invalid); + expect(codec.safeParse('wire').isFail, isTrue); + expect(codec.safeEncode(invalid).isFail, isTrue); + } + for (final valid in [const _Cat('Mittens'), const _Dog('Spot')]) { + final codec = _codecWithOutput(output, valid); + expect(codec.parse('wire'), same(valid)); + expect(codec.encode(valid), 'wire'); + } + }); + + test('branch membership checks never execute branch codecs', () { + var decodeCalls = 0; + var encodeCalls = 0; + final output = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) { + decodeCalls++; + return _Cat(map['name']! as String); + }, + encode: (cat) { + encodeCalls++; + return {'name': cat.name}; + }, + ), + }, + ); + const value = _Cat('Mittens'); + final codec = _codecWithOutput(output, value); + expect(codec.parse('wire'), same(value)); + expect(codec.encode(value), 'wire'); + expect(decodeCalls, 0); + expect(encodeCalls, 0); + }); + + test('ambiguous runtime membership remains an encode-only failure', () { + final branch = Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) => {'name': cat.name}, + ); + final union = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: {'a': branch, 'b': branch}, + ); + final parsed = union.parse({'kind': 'a', 'name': 'Mittens'})!; + final codec = _codecWithOutput(union, parsed); + expect(codec.parse('wire'), same(parsed)); + expect(codec.encode(parsed), 'wire'); + expect(union.safeEncode(parsed).isFail, isTrue); + }); + }); + + group('direct discriminated encoding callback counts', () { + test('map branches do not acquire an extra runtime refinement pass', () { + var refinements = 0; + var encoders = 0; + final union = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'a': Ack.object({'count': Ack.integer()}).codec( + decode: (map) => map, + encode: (map) { + encoders++; + return {'count': map['count']}; + }, + output: Ack.instance().refine((_) { + refinements++; + return true; + }), + ), + }, + ); + expect(union.encode({'kind': 'a', 'count': 1}), { + 'kind': 'a', + 'count': 1, + }); + expect(refinements, 1); + expect(encoders, 1); + }); + + test('typed branches retain one membership probe and one encode pass', () { + var refinements = 0; + var encoders = 0; + final union = Ack.discriminated<_Cat>( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec<_Cat>( + decode: (map) => _Cat(map['name']! as String), + encode: (cat) { + encoders++; + return {'name': cat.name}; + }, + output: Ack.instance<_Cat>().refine((_) { + refinements++; + return true; + }), + ), + }, + ); + expect(union.encode(const _Cat('Mittens')), { + 'kind': 'cat', + 'name': 'Mittens', + }); + expect(refinements, 2); + expect(encoders, 1); + }); + }); +} From 70b4f95c40387965715ede1da73d3152daef5a92 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:05:55 -0400 Subject: [PATCH 03/37] fix(ack): reject lossy duration millisecond inputs --- packages/ack/CHANGELOG.md | 2 + packages/ack/lib/src/ack.dart | 11 ++- .../ack/test/schemas/duration_range_test.dart | 78 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 packages/ack/test/schemas/duration_range_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 285ed253..8b2b69fe 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -8,6 +8,8 @@ * Validate discriminated codec output against its selected map branch or a matching typed runtime branch, including required fields and refinements, without decoding runtime values again or adding encode callback passes. +* Reject duration millisecond inputs that overflow the platform's `Duration` + representation instead of silently decoding to a different duration. ## 2.0.0-alpha.1 diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 1f0fc756..15774f87 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -232,6 +232,9 @@ final class Ack { /// Creates a bidirectional duration codec between integer milliseconds and /// `Duration` values. /// + /// Inputs that cannot be represented by `Duration` on the current platform + /// without changing their millisecond value are rejected. + /// /// Runtime invariant: the `Duration` must be a whole number of /// milliseconds (sub-millisecond precision is rejected to avoid silent /// truncation on encode). @@ -271,7 +274,13 @@ bool _isWholeMillisecondDuration(Duration value) { } Duration _decodeDuration(int milliseconds) { - return Duration(milliseconds: milliseconds); + final duration = Duration(milliseconds: milliseconds); + if (duration.inMilliseconds != milliseconds) { + throw const FormatException( + 'Milliseconds cannot be represented by Duration without loss.', + ); + } + return duration; } int _encodeDuration(Duration value) => value.inMilliseconds; diff --git a/packages/ack/test/schemas/duration_range_test.dart b/packages/ack/test/schemas/duration_range_test.dart new file mode 100644 index 00000000..6fabb4a8 --- /dev/null +++ b/packages/ack/test/schemas/duration_range_test.dart @@ -0,0 +1,78 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +final _usesJavaScriptNumberRepresentation = [1e20].single is int; + +void main() { + test('preserves milliseconds within the exact range on VM and web', () { + final schema = Ack.duration(); + for (final milliseconds in [0, 1, -1, 9007199254740, -9007199254740]) { + final parsed = schema.parse(milliseconds)!; + expect(parsed.inMilliseconds, milliseconds); + expect(schema.encode(parsed), milliseconds); + } + }); + + test('retains lossless JSON integer normalization', () { + final schema = Ack.duration(); + expect(schema.encode(schema.parse(1500.0)), 1500); + expect(schema.encode(schema.parse(-1500.0)), -1500); + expect(schema.safeParse(1.5).isFail, isTrue); + }); + + test('any accepted large input must round-trip without corruption', () { + final schema = Ack.duration(); + for (final text in [ + '9007199254740991', + '-9007199254740991', + '9223372036854775', + '-9223372036854775', + '9223372036854776', + '-9223372036854776', + '9223372036854775807', + '-9223372036854775808', + ]) { + final milliseconds = int.parse(text); + final result = schema.safeParse(milliseconds); + if (result.isOk) { + final parsed = result.getOrNull()!; + expect(parsed.inMilliseconds, milliseconds, reason: text); + expect(schema.encode(parsed), milliseconds, reason: text); + } + } + }); + + group( + 'native signed 64-bit Duration bounds', + () { + test('accepts both whole-millisecond supported edges', () { + final schema = Ack.duration(); + final edge = (BigInt.parse('9223372036854775807') ~/ BigInt.from(1000)) + .toInt(); + for (final milliseconds in [edge, -edge]) { + final parsed = schema.parse(milliseconds)!; + expect(parsed.inMilliseconds, milliseconds); + expect(schema.encode(parsed), milliseconds); + } + }); + + test('rejects values outside the representable microsecond range', () { + final schema = Ack.duration(); + for (final text in [ + '9223372036854776', + '-9223372036854776', + '9223372036854775807', + '-9223372036854775808', + ]) { + final result = schema.safeParse(int.parse(text)); + expect(result.isFail, isTrue, reason: text); + expect(result.getError(), isA(), reason: text); + expect(result.getError().cause, isA(), reason: text); + } + }); + }, + skip: _usesJavaScriptNumberRepresentation + ? 'JavaScript does not use the native signed 64-bit representation.' + : false, + ); +} From 5d55d341b405acc2dd9fa64669ae380d1b68f4d5 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:30:02 -0400 Subject: [PATCH 04/37] fix(ack): preserve custom JSON Schema vocabulary semantics --- packages/ack/CHANGELOG.md | 5 + .../src/json_schema/json_schema_compiler.dart | 142 +++++- .../src/schema_model/ack_schema_model.dart | 45 -- .../ack_schema_model_builder.dart | 84 ++-- .../lib/src/schemas/imported_json_schema.dart | 91 +++- .../json_schema_vocabulary_contract_test.dart | 420 ++++++++++++++++++ 6 files changed, 669 insertions(+), 118 deletions(-) create mode 100644 packages/ack/test/json_schema_vocabulary_contract_test.dart diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 8b2b69fe..2702d15c 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -10,6 +10,11 @@ without decoding runtime values again or adding encode callback passes. * Reject duration millisecond inputs that overflow the platform's `Duration` representation instead of silently decoding to a different duration. +* Respect custom JSON Schema vocabularies during import and export: keep + inactive keywords inert, preserve annotation payloads and explicit format + assertions, and apply fluent nullability under an active assertion dialect. + Identical caller-supplied standard meta-schemas can replace offline fallbacks + without relaxing duplicate-ID checks between caller resources. ## 2.0.0-alpha.1 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 622a7374..7664a50e 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -26,6 +26,9 @@ final class _ImportedNode { bool applicatorVocabulary = true; bool unevaluatedVocabulary = true; bool validationVocabulary = true; + bool metadataVocabulary = true; + bool contentVocabulary = true; + bool formatAnnotationVocabulary = true; bool formatAssertion = false; final keywords = {}; final children = {}; @@ -80,6 +83,36 @@ final class _ImportedNode { List<_ImportedNode>? oneOf; List<_ImportedNode>? prefixItems; + /// Keywords outside the selected dialect retain only annotation semantics. + bool isKeywordActive(String keyword) { + if (keyword == r'$defs' || keyword == 'definitions') return true; + if (keyword == 'unevaluatedProperties' || keyword == 'unevaluatedItems') { + return unevaluatedVocabulary; + } + if (_JsonSchemaCompiler.mapKeywords.contains(keyword) || + _JsonSchemaCompiler.childKeywords.contains(keyword) || + _JsonSchemaCompiler.listKeywords.contains(keyword)) { + return applicatorVocabulary; + } + if (_JsonSchemaCompiler.validationKeywords.contains(keyword)) { + return validationVocabulary; + } + return switch (keyword) { + 'title' || + 'description' || + 'default' || + 'examples' || + 'readOnly' || + 'writeOnly' || + 'deprecated' => metadataVocabulary, + 'contentEncoding' || + 'contentMediaType' || + 'contentSchema' => contentVocabulary, + 'format' => formatAnnotationVocabulary || formatAssertion, + _ => true, + }; + } + List<_ImportedNode> get dependencies => [ ?reference, ?dynamicReference, @@ -122,7 +155,7 @@ final class _ImportedNode { }; if (source case final Map document) { for (final keyword in unsupportedKeywords) { - if (document.containsKey(keyword)) { + if (isKeywordActive(keyword) && document.containsKey(keyword)) { throw UnsupportedError( 'Cannot lower "$keyword" at ${_keywordPointer(this, keyword)} ' 'with toJsonSchemaDraft7(); use toJsonSchema() for 2020-12 semantics.', @@ -132,7 +165,8 @@ final class _ImportedNode { } if (dynamicReference != null || dynamicAnchorName != null || - (source is Map && + (isKeywordActive('format') && + source is Map && (source as Map).containsKey('format'))) { throw UnsupportedError( 'This imported schema needs 2020-12 semantics and cannot be lowered ' @@ -183,6 +217,8 @@ final class _JsonSchemaCompiler { final resources = {}; final anchors = {}; final dynamicAnchors = {}; + final _pendingChildren = Queue<_ImportedNode>(); + final _fallbackDocumentUris = {}; static const mapKeywords = { r'$defs', @@ -242,8 +278,26 @@ final class _JsonSchemaCompiler { 'exclusiveMinimum', 'exclusiveMaximum', }; + static const validationKeywords = { + ...counts, + ...bounds, + 'type', + 'const', + 'enum', + 'multipleOf', + 'minContains', + 'maxContains', + 'uniqueItems', + 'pattern', + 'required', + 'dependentRequired', + }; - _ImportedNode addDocument(Object document, Uri uri) { + _ImportedNode addDocument( + Object document, + Uri uri, { + bool isFallback = false, + }) { if (uri.hasFragment && uri.fragment.isNotEmpty) { throw ArgumentError.value(uri, 'baseUri', 'Must not contain a fragment.'); } @@ -268,6 +322,7 @@ final class _JsonSchemaCompiler { 'Conflicting schema documents for retrieval URI: ${uri.removeFragment()}.', ); } + if (isFallback) _fallbackDocumentUris.add(uri.removeFragment()); return _index( copy!, uri.removeFragment(), @@ -336,10 +391,22 @@ final class _JsonSchemaCompiler { _register(dynamicAnchors, uri, node, r'$dynamicAnchor'); _register(anchors, uri, node, r'$dynamicAnchor'); } + // Core definition locations cannot be disabled. Register them now so a + // bundled dialect can be found before interpreting optional keywords. + _indexChildren(node, coreOnly: true); + _pendingChildren.add(node); + return node; + } + + void _indexChildren(_ImportedNode node, {required bool coreOnly}) { + final source = node.source; + if (source is! Map) return; for (final entry in source.entries) { final key = entry.key; + final coreLocation = key == r'$defs' || key == 'definitions'; + if (coreLocation != coreOnly || !node.isKeywordActive(key)) continue; final value = entry.value; - final path = '$pointer/${escapeJsonPointerToken(key)}'; + final path = '${node.pointer}/${escapeJsonPointerToken(key)}'; if (mapKeywords.contains(key)) { if (value is! Map) { _fail(node, key, 'Expected a map of schemas.'); @@ -347,19 +414,19 @@ final class _JsonSchemaCompiler { for (final child in value.entries) { _index( child.value ?? _invalidSchema(node, key), - document, + node.documentUri, '$path/${escapeJsonPointerToken(child.key)}', - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } } else if (childKeywords.contains(key)) { _index( value ?? _invalidSchema(node, key), - document, + node.documentUri, path, - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } else if (listKeywords.contains(key)) { if (value is! List || value.isEmpty) { @@ -368,15 +435,35 @@ final class _JsonSchemaCompiler { for (final (i, child) in value.indexed) { _index( child ?? _invalidSchema(node, key), - document, + node.documentUri, '$path/$i', - base, - dialect: dialect, + node.baseUri, + dialect: node.dialectUri, ); } } } - return node; + } + + void _indexPendingChildren() { + while (_pendingChildren.isNotEmpty) { + final count = _pendingChildren.length; + var progressed = false; + for (var i = 0; i < count; i++) { + final node = _pendingChildren.removeFirst(); + if (!resources.containsKey(node.dialectUri) && + !draft7Dialects.contains(node.dialectUri)) { + _pendingChildren.add(node); + continue; + } + _configureDialect(node); + _indexChildren(node, coreOnly: false); + progressed = true; + } + // Other documents may have supplied a nested dialect during this pass. + // If none could be indexed, report the unresolved dialect as before. + if (!progressed) _configureDialect(_pendingChildren.first); + } } Never _invalidSchema(_ImportedNode node, String key) => @@ -390,7 +477,16 @@ final class _JsonSchemaCompiler { ) { if (registry[uri] case final prior?) { if (!identical(prior, node)) { - _fail(node, keyword, 'Duplicate schema identifier: $uri.'); + // Delayed optional-keyword indexing can discover an explicitly supplied + // copy after the offline fallback was registered. Only an identical + // fallback may be replaced; two caller-owned identifiers still conflict. + final replacesFallback = + _fallbackDocumentUris.contains(prior.documentUri) && + !_fallbackDocumentUris.contains(node.documentUri) && + deepEquals(prior.source, node.source); + if (!replacesFallback) { + _fail(node, keyword, 'Duplicate schema identifier: $uri.'); + } } } registry[uri] = node; @@ -436,9 +532,7 @@ final class _JsonSchemaCompiler { } void compile(_ImportedNode root) { - for (final node in locations.values) { - _configureDialect(node); - } + _indexPendingChildren(); final nodes = _compileReachable(root); for (final node in locations.values) { node.resourceRoot = resources[node.baseUri]!; @@ -487,6 +581,15 @@ final class _JsonSchemaCompiler { unevaluatedVocabularyUri, ); node.validationVocabulary = vocabulary.containsKey(validationVocabularyUri); + node.metadataVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/meta-data', + ); + node.contentVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/content', + ); + node.formatAnnotationVocabulary = vocabulary.containsKey( + 'https://json-schema.org/draft/2020-12/vocab/format-annotation', + ); node.formatAssertion = assertFormats || vocabulary.containsKey(formatAssertionVocabularyUri); } @@ -578,7 +681,7 @@ final class _JsonSchemaCompiler { base, dialect: resource.dialectUri, ); - _configureDialect(indexed); + _indexPendingChildren(); return indexed; } @@ -588,6 +691,7 @@ final class _JsonSchemaCompiler { for (final entry in source.entries) { final key = entry.key; final value = entry.value; + if (!node.isKeywordActive(key)) continue; if (const { r'$id', r'$schema', diff --git a/packages/ack/lib/src/schema_model/ack_schema_model.dart b/packages/ack/lib/src/schema_model/ack_schema_model.dart index 85e8b02b..d9f1d582 100644 --- a/packages/ack/lib/src/schema_model/ack_schema_model.dart +++ b/packages/ack/lib/src/schema_model/ack_schema_model.dart @@ -365,27 +365,6 @@ Map _relocateRootPointers( Set retainedKeywords, Set> indexedSchemas, ) { - const mapKeywords = { - r'$defs', - 'definitions', - 'properties', - 'patternProperties', - 'dependentSchemas', - }; - const childKeywords = { - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'if', - 'then', - 'else', - 'unevaluatedProperties', - 'unevaluatedItems', - }; - const listKeywords = {'anyOf', 'allOf', 'oneOf', 'prefixItems'}; - late Object? Function(Object?, Uri) visit; Object? visitExtension(Object? value, Uri base) { @@ -437,31 +416,7 @@ Map _relocateRootPointers( .replace(fragment: '/anyOf/0$fragment') .toString(); } - for (final key in mapKeywords) { - if (rewritten[key] case final Map children) { - rewritten[key] = children.map( - (name, child) => MapEntry(name, visit(child, currentBase)), - ); - } - } - for (final key in childKeywords) { - if (rewritten.containsKey(key)) { - rewritten[key] = visit(rewritten[key], currentBase); - } - } - for (final key in listKeywords) { - if (rewritten[key] case final List children) { - rewritten[key] = [ - for (final child in children) visit(child, currentBase), - ]; - } - } for (final key in rewritten.keys.toList(growable: false)) { - if (mapKeywords.contains(key) || - childKeywords.contains(key) || - listKeywords.contains(key)) { - continue; - } rewritten[key] = visitExtension(rewritten[key], currentBase); } return rewritten; 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 e9b731d3..76c448f4 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 @@ -103,9 +103,14 @@ final class _SchemaModelBuilder { return merged; } - AckSchemaModel _build(AckSchema schema) { + AckSchemaModel _build( + AckSchema schema, { + bool requireStandardDialect = false, + }) { if (schema is WrapperSchema) { - final base = _build(schema.inner); + // Wrappers can add null operators or projected constraints. Keep those + // outside a source dialect that may have disabled their vocabularies. + final base = _build(schema.inner, requireStandardDialect: true); var layered = base .withDescription(schema.description ?? base.description) .withNullable(schema.isNullable); @@ -168,7 +173,10 @@ final class _SchemaModelBuilder { InstanceSchema() => _instance(schema), DiscriminatedObjectSchema() => _discriminated(schema), LazySchema() => _lazy(schema), - ImportedJsonSchema() => _imported(schema), + ImportedJsonSchema() => _imported( + schema, + requireStandardDialect: requireStandardDialect, + ), _ => throw UnsupportedError( 'Schema type ${schema.runtimeType} is not supported for AckSchemaModel conversion.', ), @@ -177,11 +185,19 @@ final class _SchemaModelBuilder { return schema is LazySchema ? model : _applyConstraints(model, schema); } - AckSchemaModel _imported(ImportedJsonSchema schema) { + AckSchemaModel _imported( + ImportedJsonSchema schema, { + required bool requireStandardDialect, + }) { final importIndex = _importCount++; if (preserveImportedDialect) { final indexedSchemas = HashSet>.identity(); - if (_composingImports) { + final needsAssertionEnvelope = + schema.needsStandardAssertionEnvelope && + (requireStandardDialect || + schema.isNullable != schema.sourceAllowsNull || + schema.constraints.isNotEmpty); + if (_composingImports || needsAssertionEnvelope) { final document = schema.export2020Document( importIndex: importIndex, includeFluentNullability: false, @@ -279,6 +295,27 @@ final class _SchemaModelBuilder { final base = Uri.parse(document[r'$id']! as String); index(document, base, []); + + Uri canonicalLocation(Uri reference) { + if (locations[reference] case final exact?) return exact; + // Boolean schemas have no map identity to index. Their pointer still + // follows the canonical resource of the nearest indexed ancestor. + var prefix = reference.fragment; + while (prefix.startsWith('/')) { + prefix = prefix.substring(0, prefix.lastIndexOf('/')); + final ancestor = prefix.isEmpty + ? reference.removeFragment() + : reference.replace(fragment: prefix); + if (locations[ancestor] case final canonical?) { + return canonical.replace( + fragment: + '${canonical.fragment}${reference.fragment.substring(prefix.length)}', + ); + } + } + return reference; + } + Object? normalize(Object? value, Uri base) { if (value is! Map) return value; final id = value[r'$id']; @@ -308,7 +345,7 @@ final class _SchemaModelBuilder { // Dormant malformed references are not evaluated by the importer. continue; } - final canonical = locations[resolved] ?? resolved; + final canonical = canonicalLocation(resolved); final address = _resourceLocations[canonical.removeFragment()]; if (address != null && (canonical.fragment.isEmpty || @@ -797,41 +834,8 @@ Map _mapImportedChildren( return value; } - const maps = { - r'$defs', - 'definitions', - 'properties', - 'patternProperties', - 'dependentSchemas', - }; - const children = { - 'items', - 'additionalProperties', - 'not', - 'contains', - 'propertyNames', - 'if', - 'then', - 'else', - 'unevaluatedProperties', - 'unevaluatedItems', - }; - const lists = {'allOf', 'anyOf', 'oneOf', 'prefixItems'}; return { for (final entry in schema.entries) - entry.key: maps.contains(entry.key) && entry.value is Map - ? { - for (final child - in (entry.value! as Map).entries) - child.key: visit(child.value, [entry.key, child.key]), - } - : children.contains(entry.key) - ? visit(entry.value, [entry.key]) - : lists.contains(entry.key) && entry.value is List - ? [ - for (final (index, child) in (entry.value! as List).indexed) - visit(child, [entry.key, '$index']), - ] - : visitIndexed(entry.value, [entry.key]), + entry.key: visitIndexed(entry.value, [entry.key]), }; } diff --git a/packages/ack/lib/src/schemas/imported_json_schema.dart b/packages/ack/lib/src/schemas/imported_json_schema.dart index edf13037..d9af8563 100644 --- a/packages/ack/lib/src/schemas/imported_json_schema.dart +++ b/packages/ack/lib/src/schemas/imported_json_schema.dart @@ -31,7 +31,7 @@ final class ImportedJsonSchema extends AckSchema } for (final entry in _standardMetaSchemaDocuments.entries) { if (!compiler.resources.containsKey(entry.key)) { - compiler.addDocument(entry.value, entry.key); + compiler.addDocument(entry.value, entry.key, isFallback: true); } } compiler.compile(root); @@ -112,6 +112,44 @@ final class ImportedJsonSchema extends AckSchema _ => value, }; + final dialectResources = { + for (final node in _indexedNodes) + if (identical(node, node.resourceRoot)) node.baseUri: node, + for (final node in _indexedNodes) + if (node.pointer == '#') node.documentUri: node, + }; + final customAssertionDialects = + vocabulary})>{}; + + Uri? assertionDialectFor(_ImportedNode node) { + if (!assertFormats) return null; + if (node.dialectUri == _JsonSchemaCompiler.standardDialect || + _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri)) { + return _assertionDialect; + } + final dialect = dialectResources[node.dialectUri]!; + final vocabulary = + (dialect.source as Map)[r'$vocabulary']! + as Map; + if (vocabulary.containsKey( + _JsonSchemaCompiler.formatAssertionVocabularyUri, + )) { + return null; + } + return customAssertionDialects.putIfAbsent(dialect.baseUri, () { + final token = base64Url + .encode(utf8.encode(dialect.baseUri.toString())) + .replaceAll('=', ''); + return ( + id: Uri.parse('$_assertionDialect:$token'), + vocabulary: { + ...vocabulary, + _JsonSchemaCompiler.formatAssertionVocabularyUri: true, + }, + ); + }).id; + } + Map resource(_ImportedNode node, {Uri? syntheticId}) { final source = copyJson(node.source); // Edit only indexed schema locations, never annotation payloads. This @@ -129,13 +167,10 @@ final class ImportedJsonSchema extends AckSchema } if (exported is! Map) continue; onSchema?.call(exported); - if (assertFormats && - (indexed.dialectUri == _JsonSchemaCompiler.standardDialect || - _JsonSchemaCompiler.draft7Dialects.contains( - indexed.dialectUri, - )) && - (identical(indexed, node) || exported.containsKey(r'$schema'))) { - exported[r'$schema'] = _assertionDialect.toString(); + if (identical(indexed, node) || exported.containsKey(r'$schema')) { + if (assertionDialectFor(indexed) case final dialect?) { + exported[r'$schema'] = dialect.toString(); + } } // A retrieval URI is an address for the original document, not a // resource that can be replaced by a $ref-only alias. Resolve it to @@ -164,15 +199,15 @@ final class ImportedJsonSchema extends AckSchema } } } - return { + final exportedResource = { if (source is Map) ...source, r'$id': (syntheticId ?? node.baseUri).toString(), - if (assertFormats && - (node.dialectUri == _JsonSchemaCompiler.standardDialect || - _JsonSchemaCompiler.draft7Dialects.contains(node.dialectUri))) - r'$schema': _assertionDialect.toString(), + if (assertionDialectFor(node) case final dialect?) + r'$schema': dialect.toString(), if (source is bool) 'allOf': [source], }; + onSchema?.call(exportedResource); + return exportedResource; } final syntheticId = @@ -203,7 +238,7 @@ final class ImportedJsonSchema extends AckSchema while (definitions.containsKey(name)) { name = '${name}_'; } - definitions[name] = { + final standardAssertionDialect = { r'$id': _assertionDialect.toString(), r'$schema': _JsonSchemaCompiler.standardDialect.toString(), r'$vocabulary': { @@ -224,6 +259,29 @@ final class ImportedJsonSchema extends AckSchema {r'$ref': 'https://json-schema.org/draft/2020-12/meta/$name'}, ], }; + onSchema?.call(standardAssertionDialect); + definitions[name] = standardAssertionDialect; + var customIndex = 0; + for (final entry in customAssertionDialects.entries) { + var name = '_ack_format_assertion_custom_${customIndex++}'; + while (definitions.containsKey(name)) { + name = '${name}_'; + } + // Dialect vocabularies are not inherited through $ref. Repeat the + // original map, adding only the requested format assertion, while + // retaining the original meta-schema's constraints and identity. + final customAssertionDialect = { + r'$id': entry.value.id.toString(), + r'$schema': _JsonSchemaCompiler.standardDialect.toString(), + r'$vocabulary': entry.value.vocabulary, + r'$dynamicAnchor': 'meta', + 'allOf': [ + {r'$ref': entry.key.toString()}, + ], + }; + onSchema?.call(customAssertionDialect); + definitions[name] = customAssertionDialect; + } } root[r'$defs'] = definitions; } @@ -243,6 +301,11 @@ final class ImportedJsonSchema extends AckSchema @internal bool get sourceAllowsNull => _checkImportedNode(_root, null) == null; + /// Whether generated applicator/validation assertions need an outer dialect. + @internal + bool get needsStandardAssertionEnvelope => + !_root.applicatorVocabulary || !_root.validationVocabulary; + @override @protected SchemaResult validateRuntimeWithContext( diff --git a/packages/ack/test/json_schema_vocabulary_contract_test.dart b/packages/ack/test/json_schema_vocabulary_contract_test.dart new file mode 100644 index 00000000..73a19727 --- /dev/null +++ b/packages/ack/test/json_schema_vocabulary_contract_test.dart @@ -0,0 +1,420 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +const _standardDialect = 'https://json-schema.org/draft/2020-12/schema'; +const _vocabularyPrefix = 'https://json-schema.org/draft/2020-12/vocab/'; +final _dialectUri = Uri.parse('https://example.test/meta/custom'); + +Map _dialect(List vocabularies) => { + r'$id': _dialectUri.toString(), + r'$schema': _standardDialect, + r'$vocabulary': { + '${_vocabularyPrefix}core': true, + for (final vocabulary in vocabularies) + '$_vocabularyPrefix$vocabulary': true, + }, +}; + +AckSchema _import( + Map source, { + List vocabularies = const ['validation'], + bool assertFormats = false, +}) => Ack.fromJsonSchema( + {r'$schema': _dialectUri.toString(), ...source}, + documents: {_dialectUri: _dialect(vocabularies)}, + assertFormats: assertFormats, +); + +Iterable> _maps(Object? value) sync* { + if (value is Map) { + yield value; + for (final child in value.values) { + yield* _maps(child); + } + } else if (value is List) { + for (final child in value) { + yield* _maps(child); + } + } +} + +const _officialFormatAnnotationMetaSchema = { + r'$schema': _standardDialect, + r'$id': 'https://json-schema.org/draft/2020-12/meta/format-annotation', + r'$dynamicAnchor': 'meta', + 'title': 'Format vocabulary meta-schema for annotation results', + 'type': ['object', 'boolean'], + 'properties': { + 'format': {'type': 'string'}, + }, +}; + +void main() { + group('inactive vocabulary keywords are annotations', () { + final applicatorCases = { + 'properties': false, + 'patternProperties': 'annotation', + 'dependentSchemas': 42, + 'items': 'annotation', + 'additionalProperties': [], + 'not': 42, + 'contains': 'annotation', + 'propertyNames': null, + 'if': [], + 'then': null, + 'else': 'annotation', + 'allOf': [], + 'anyOf': false, + 'oneOf': 'annotation', + 'prefixItems': {}, + }; + for (final entry in applicatorCases.entries) { + test('does not interpret inactive ${entry.key}', () { + final schema = _import({'type': 'string', entry.key: entry.value}); + expect(schema.safeParse('ok').isOk, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + final exported = schema.toJsonSchema(); + expect(exported[entry.key], entry.value); + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse('ok').isOk, isTrue); + expect(restored.safeParse(1).isFail, isTrue); + }); + } + + test( + 'does not register identifiers or resolve references in annotations', + () { + final annotation = { + 'notASchema': { + r'$id': 'https://example.test/real', + r'$ref': 'https://example.test/not-supplied', + }, + 'alsoNotASchema': {r'$id': 42}, + }; + final schema = _import({ + 'type': 'string', + 'properties': annotation, + r'$defs': { + 'real': {r'$id': 'https://example.test/real', 'type': 'string'}, + }, + }); + expect(schema.parse('ok'), 'ok'); + expect(schema.toJsonSchema()['properties'], annotation); + }, + ); + + test( + 'does not validate inactive validation, metadata, or content syntax', + () { + final source = { + 'type': 42, + 'enum': 'annotation', + 'minimum': false, + 'multipleOf': 0, + 'minLength': 'annotation', + 'required': false, + 'dependentRequired': [], + 'uniqueItems': 'annotation', + 'description': 42, + 'readOnly': 'annotation', + 'examples': false, + 'format': false, + 'contentSchema': 'annotation', + 'unevaluatedProperties': 'annotation', + 'unevaluatedItems': 42, + }; + final schema = _import(source, vocabularies: []); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final value in [null, 1, 'text', {}, []]) { + expect(schema.safeParse(value).isOk, isTrue); + expect(restored.safeParse(value).isOk, isTrue); + } + for (final entry in source.entries) { + expect(schema.toJsonSchema()[entry.key], entry.value); + } + }, + ); + }); + + test('still validates the syntax of active vocabularies', () { + for (final testCase in [ + ( + vocabulary: 'applicator', + source: {'properties': false}, + ), + (vocabulary: 'validation', source: {'minLength': 'bad'}), + (vocabulary: 'meta-data', source: {'description': 42}), + (vocabulary: 'content', source: {'contentSchema': 42}), + ( + vocabulary: 'unevaluated', + source: {'unevaluatedItems': 'bad'}, + ), + ( + vocabulary: 'format-annotation', + source: {'format': 42}, + ), + ]) { + expect( + () => _import(testCase.source, vocabularies: [testCase.vocabulary]), + throwsA(isA()), + ); + } + expect( + () => _import({'format': 42}, vocabularies: [], assertFormats: true), + throwsA(isA()), + ); + }); + + test( + 'core definitions remain schemas regardless of optional vocabularies', + () { + expect( + () => _import({ + r'$defs': {'invalid': 42}, + }, vocabularies: []), + throwsA(isA()), + ); + }, + ); + + test('explicit references can target schemas inside inert containers', () { + final schema = _import({ + r'$ref': '#/properties/target', + 'properties': { + 'target': {'type': 'string', 'minLength': 2}, + }, + }); + expect(schema.safeParse('ok').isOk, isTrue); + expect(schema.safeParse('x').isFail, isTrue); + expect(schema.safeParse(1).isFail, isTrue); + }); + + test('a dialect can be indexed after the schema that selects it', () { + final catalogUri = Uri.parse('https://example.test/catalog'); + for (final container in [r'$defs', 'properties']) { + final schema = Ack.fromJsonSchema( + { + r'$schema': _dialectUri.toString(), + 'type': 'string', + 'properties': false, + }, + documents: { + catalogUri: { + container: { + 'customDialect': _dialect(['validation']), + }, + }, + }, + ); + expect(schema.parse('ok'), 'ok'); + expect(schema.safeParse(1).isFail, isTrue); + } + }); + + test('nullable export does not rewrite references in inert containers', () { + const id = 'https://example.test/nullable-source'; + final annotation = { + 'notASchema': {r'$ref': '#'}, + }; + final schema = _import({ + r'$id': id, + 'type': 'string', + 'properties': annotation, + }).nullable(); + final resource = _maps( + schema.toJsonSchema(), + ).where((map) => map[r'$id'] == id).single; + // The source may be enveloped, but its annotation payload is unchanged. + final properties = + resource['properties'] ?? + _maps( + resource, + ).firstWhere((map) => map.containsKey('properties'))['properties']; + expect(properties, annotation); + }); + + test('composed export does not register or rewrite inert resource IDs', () { + const id = 'https://example.test/composed-source'; + const realId = 'https://example.test/real-resource'; + final annotation = { + 'notASchema': {r'$id': realId, r'$ref': '#', 'type': 'integer'}, + }; + final schema = Ack.object({ + 'value': _import({ + r'$id': id, + 'type': 'string', + 'properties': annotation, + r'$defs': { + 'real': {r'$id': realId, 'type': 'string'}, + }, + }), + }); + final exported = schema.toJsonSchema(); + final resource = _maps(exported).where((map) => map[r'$id'] == id).single; + expect(resource['properties'], annotation); + final restored = Ack.fromJsonSchema(exported); + expect(restored.safeParse({'value': 'ok'}).isOk, isTrue); + expect(restored.safeParse({'value': 1}).isFail, isTrue); + }); + + test('fluent null policies use operators from an active dialect', () { + final widened = _import({'type': 'string'}).nullable(); + final narrowed = _import({}, vocabularies: []).nullable(value: false); + final wrapped = Ack.preserveBoundary( + _import({}, vocabularies: []), + ).nullable(value: false); + final defaulted = _import({ + 'type': ['string', 'null'], + }).withDefault('fallback'); + for (final schema in [widened, narrowed, wrapped, defaulted]) { + for (final exported in [ + schema.toJsonSchema(), + schema.toJsonSchemaDraft7(), + ]) { + final restored = Ack.fromJsonSchema(exported); + for (final value in [null, 'ok', 42, {}]) { + expect( + restored.safeParse(value).isOk, + schema.safeParse(value).isOk, + reason: '$value exported as $exported', + ); + } + } + } + }); + + test( + 'inactive modern keywords do not block semantics-preserving Draft-7', + () { + final schema = _import({ + 'type': 'string', + 'prefixItems': 'annotation', + 'unevaluatedItems': false, + 'format': 42, + }); + final restored = Ack.fromJsonSchema(schema.toJsonSchemaDraft7()); + expect(restored.safeParse('ok').isOk, isTrue); + expect(restored.safeParse(1).isFail, isTrue); + }, + ); + + test('identical supplied official resources precede bundled fallbacks', () { + for (final container in [r'$defs', 'properties']) { + final schema = Ack.fromJsonSchema( + {r'$ref': _officialFormatAnnotationMetaSchema[r'$id']}, + documents: { + Uri.parse('https://example.test/catalog'): { + container: {'official': _officialFormatAnnotationMetaSchema}, + }, + }, + ); + expect(schema.safeParse({'format': 'email'}).isOk, isTrue); + expect(schema.safeParse({'format': 42}).isFail, isTrue); + } + }); + + test( + 'caller resource conflicts still fail instead of replacing fallbacks', + () { + for (final resources in [ + { + 'different': { + ..._officialFormatAnnotationMetaSchema, + 'title': 'A different definition using the same official ID', + }, + }, + { + 'first': _officialFormatAnnotationMetaSchema, + 'second': _officialFormatAnnotationMetaSchema, + }, + ]) { + expect( + () => Ack.fromJsonSchema( + {'type': 'string'}, + documents: { + Uri.parse('https://example.test/catalog'): { + 'properties': resources, + }, + }, + ), + throwsA(isA()), + ); + } + }, + ); + + test('custom format assertion exports add only the requested vocabulary', () { + for (final validation in [false, true]) { + final schema = _import( + { + 'type': validation ? 'string' : 42, + 'format': 'email', + 'allOf': [false], + 'properties': false, + }, + vocabularies: [if (validation) 'validation'], + assertFormats: true, + ); + final restored = Ack.fromJsonSchema(schema.toJsonSchema()); + for (final candidate in [ + (value: 'alice@example.com', valid: true), + (value: 'not-an-email', valid: false), + (value: 42, valid: !validation), + ]) { + expect(schema.safeParse(candidate.value).isOk, candidate.valid); + expect(restored.safeParse(candidate.value).isOk, candidate.valid); + } + } + }); + + test('custom assertion dialects retain aliases and compose consistently', () { + const canonical = 'https://example.test/meta/canonical'; + final retrieval = Uri.parse('https://example.test/meta/retrieval'); + const originalVocabulary = { + '${_vocabularyPrefix}core': true, + '${_vocabularyPrefix}validation': true, + '${_vocabularyPrefix}format-annotation': false, + }; + final schema = Ack.fromJsonSchema( + { + r'$id': 'https://example.test/aliased-source', + r'$schema': retrieval.toString(), + 'type': 'string', + 'format': 'email', + 'properties': false, + }, + documents: { + retrieval: { + r'$id': canonical, + r'$schema': _standardDialect, + r'$vocabulary': originalVocabulary, + }, + }, + assertFormats: true, + ); + final exported = schema.toJsonSchema(); + expect(schema.toJsonSchema(), exported); + final originalDialect = _maps( + exported, + ).where((map) => map[r'$id'] == canonical).single; + expect(originalDialect[r'$vocabulary'], originalVocabulary); + + for (final testCase in [ + ( + schema: Ack.fromJsonSchema(exported), + valid: 'alice@example.com', + invalid: 'not-an-email', + ), + ( + schema: Ack.fromJsonSchema( + Ack.object({'first': schema, 'second': schema}).toJsonSchema(), + ), + valid: {'first': 'alice@example.com', 'second': 'bob@example.com'}, + invalid: {'first': 'alice@example.com', 'second': 'not-an-email'}, + ), + ]) { + expect(testCase.schema.safeParse(testCase.valid).isOk, isTrue); + expect(testCase.schema.safeParse(testCase.invalid).isFail, isTrue); + } + }); +} From 7dcb3815338dc4360a4a242ed11d22060a7be086 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:53:38 -0400 Subject: [PATCH 05/37] chore(release): select 2.0.0-alpha.2 candidate --- PUBLISHING.md | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/PUBLISHING.md b/PUBLISHING.md index 8c690a47..f9611faa 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -12,7 +12,12 @@ new compatible features/packages, and major when any stable public API breaks. For 1.7.0, the imported-pattern Unicode behavior change is a deliberately accepted exception to this major-version policy. -The next candidate is **2.0.0-alpha.1**. It introduces single-pass `.g.dart` +The next candidate is **2.0.0-alpha.2**, following the already-published alpha.1. +It corrects runtime nullability and discriminated validation, duration overflow, +custom JSON Schema vocabulary preservation, and generator metadata, naming, +and generic-type contracts. Published alpha.1 notes remain unchanged. + +The 2.0 release line introduces single-pass `.g.dart` code generation without a runtime `json_serializable` delegation step, the unified `@Validate.*` constraint annotation namespace, custom schema resolution via `@Schemable(schemas: [...])` and automatic same-library detection, @@ -49,7 +54,9 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 1. Check pub.dev and tags before selecting the version; published versions and their changelog sections are immutable. -2. Start a release branch from current `origin/main` and resolve dependencies: +2. Start from the appropriate release line: current `origin/main` for a stable + release, or the existing `origin/release/.` branch for a + prerelease. Resolve dependencies: ```sh dart pub get @@ -58,7 +65,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 3. Preview/prepare a coordinated version without creating commits or tags: ```sh - dart run melos version --manual-version=ack:2.0.0-alpha.1 --yes --no-git-commit-version + dart run melos version --manual-version=ack:2.0.0-alpha.2 --yes --no-git-commit-version ``` Use the named flag, not a positional package argument: the positional form @@ -85,7 +92,7 @@ References: [Melos versioning](https://melos.invertase.dev/commands/version), 7. Validate the complete release from that clean commit: ```sh - dart scripts/verify_release_tag.dart v2.0.0-alpha.1 --skip-ancestry + dart scripts/verify_release_tag.dart v2.0.0-alpha.2 --skip-ancestry dart run melos run ci dart scripts/api_check.dart 1.7.0 dart scripts/publish_dry_run.dart @@ -138,13 +145,16 @@ repository cannot configure pub.dev Admin settings on behalf of its owner. After the release commit's CI/preflight succeeds: ```sh -git fetch origin main --tags +git fetch origin main release/2.0 --tags # Use the exact reviewed release merge commit, not an arbitrary later main head. -git tag -a v2.0.0-alpha.1 -m 'Ack 2.0.0 alpha 1' -dart scripts/verify_release_tag.dart v2.0.0-alpha.1 -git push origin v2.0.0-alpha.1 +git tag -a v2.0.0-alpha.2 -m 'Ack 2.0.0 alpha 2' +dart scripts/verify_release_tag.dart v2.0.0-alpha.2 --main-ref=origin/release/2.0 +git push origin v2.0.0-alpha.2 ``` +These commands target the 2.0 prerelease branch. Stable release tags must be +reachable from `origin/main`; use the verifier's default main reference for them. + Pushing the tag triggers `.github/workflows/release.yml`; there is no Melos publish/release script. The workflow verifies the tag, reruns release preflight, and publishes in dependency order: @@ -172,4 +182,4 @@ After all five exact versions are visible, create the GitHub Release from the existing tag using the prepared release notes and mark it as a prerelease for alpha/beta versions. Before the first subsequent code change, begin a new unreleased changelog section instead of editing the -2.0.0-alpha.1 notes. +2.0.0-alpha.2 notes (and preserve every earlier published section). From 0b8703b0becdadada218c0dbb9ab771181a1a1a9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:31:45 -0400 Subject: [PATCH 06/37] fix(generator): preserve effective schema metadata --- packages/ack_generator/CHANGELOG.md | 10 + packages/ack_generator/README.md | 9 + .../analyzer/model_graph_builder_base.dart | 18 - .../analyzer/schema_model_graph_builder.dart | 361 +++++++++++++++--- .../integration/ack_infer_graph_test.dart | 190 +++++++++ .../ack_infer_runtime_build_test.dart | 73 ++++ .../schema_alias_shape_contract_test.dart | 96 +++++ 7 files changed, 678 insertions(+), 79 deletions(-) create mode 100644 packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 4ea69bc7..d3ea1796 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +### Fixed + +* 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. +* Reject object shape-changing methods that cannot be modeled statically, + instead of generating fields that disagree with the authored schema. + ## 2.0.0-alpha.1 ### Breaking diff --git a/packages/ack_generator/README.md b/packages/ack_generator/README.md index abf20830..9eb8030f 100644 --- a/packages/ack_generator/README.md +++ b/packages/ack_generator/README.md @@ -243,6 +243,15 @@ Named model references work through direct imports, prefixes, and re-exports. Nested conversion uses each model's public `$ack` adapter so codec runtime values aren't parsed twice. +Schema-first fields retain optional, nullable, and default behavior through +named schema variables and getters. Explicit constant flag overrides such as +`.optional(value: false)` are honored, as is the outermost `.strict()` or +`.passthrough()` object policy. Shape-changing object methods (`extend`, +`partial`, `merge`, `pick`, `omit`, or `copyWith(properties: ...)`) are rejected +when generating an object model; write its final properties in an +`Ack.object({...})` map literal instead. An outer bidirectional codec can still +wrap such a schema as a value model. + ## Generated part Every annotated library declares a single `.g.dart` part: 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 1dc8a126..c460b3ea 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 @@ -17,18 +17,12 @@ final class SchemaChain { const SchemaChain({ required this.base, required this.reference, - required this.optional, - required this.nullable, - required this.defaulted, required this.hasTransform, required this.hasCodec, }); final MethodInvocation? base; final Expression? reference; - final bool optional; - final bool nullable; - final bool defaulted; final bool hasTransform; final bool hasCodec; } @@ -677,9 +671,6 @@ abstract base class ModelGraphBuilderBase { return SchemaChain( base: null, reference: expression, - optional: false, - nullable: false, - defaulted: false, hasTransform: false, hasCodec: false, ); @@ -687,16 +678,10 @@ abstract base class ModelGraphBuilderBase { MethodInvocation? current = expression; MethodInvocation? base; Expression? reference; - var optional = false; - var nullable = false; - var defaulted = false; var transform = false; var codec = false; while (current != null) { final name = current.methodName.name; - optional |= name == 'optional'; - nullable |= name == 'nullable'; - defaulted |= name == 'withDefault'; transform |= oneWayTransformMethods.contains(name); codec |= name == 'codec'; if (current.methodName.element is TopLevelFunctionElement || @@ -719,9 +704,6 @@ abstract base class ModelGraphBuilderBase { return SchemaChain( base: base, reference: reference, - optional: optional, - nullable: nullable, - defaulted: defaulted, hasTransform: transform, hasCodec: codec, ); 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 22339390..42a59c7b 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 @@ -27,6 +27,37 @@ final class _Declaration { typedef _SchemaTypes = ({AckInferRef boundary, AckInferRef runtime}); +/// The effective flags of a schema expression, including referenced aliases. +/// +/// DefaultSchema cannot clear a nullable/optional flag inherited from its inner +/// schema. Other public wrappers copy the flags but have no such inner floor. +final class _SchemaMetadata { + const _SchemaMetadata({ + this.optional = false, + this.nullable = false, + this.defaulted = false, + this.innerOptional = false, + this.innerNullable = false, + }); + + final bool optional; + final bool nullable; + final bool defaulted; + final bool innerOptional; + final bool innerNullable; + + _SchemaMetadata copyWith({bool? optional, bool? nullable}) => _SchemaMetadata( + optional: (optional ?? this.optional) || innerOptional, + nullable: (nullable ?? this.nullable) || innerNullable, + defaulted: defaulted, + innerOptional: innerOptional, + innerNullable: innerNullable, + ); + + _SchemaMetadata get wrapped => + _SchemaMetadata(optional: optional, nullable: nullable); +} + /// Builds the single normalized graph consumed by Ack model emission. final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaModelGraphBuilder(super.library); @@ -160,7 +191,17 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { graph.begin(declaration.id); final chain = parseSchemaChain(declaration.expression); _rejectTransform(chain, path, declaration.element); - if (chain.nullable) { + await _rejectObjectShapeChanges( + declaration.expression, + path: path, + context: declaration.element, + ); + final metadata = await _schemaMetadata( + declaration.expression, + path: path, + context: declaration.element, + ); + if (metadata.nullable) { throw InvalidGenerationSource( '$path is a nullable root. Generated Ack models are non-nullable.', element: declaration.element, @@ -268,13 +309,23 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); final source = graph.nodeFor(target.id)!; if (source is AckObjectModelNode) { + final additionalProperties = await _additionalProperties( + declaration.expression, + path: path, + context: declaration.element, + ); return AckObjectModelNode( id: declaration.id, className: declaration.className, fields: source.fields, - unknownPropertyPolicy: source.unknownPropertyPolicy, - captureFieldName: source.captureFieldName, - constructorParameters: source.constructorParameters, + unknownPropertyPolicy: additionalProperties + ? AckUnknownPropertyPolicy.capture + : AckUnknownPropertyPolicy.reject, + captureFieldName: additionalProperties ? 'additionalProperties' : null, + constructorParameters: _schemaFirstConstructorParameters( + source.fields, + capture: additionalProperties, + ), description: source.description, ); } @@ -300,6 +351,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaChain chain, String path, ) async { + _schemaTypes(declaration.expression, path, declaration.element); final invocation = chain.base!; final arguments = argumentExpressions(invocation.argumentList); if (arguments.isEmpty || arguments.first is! SetOrMapLiteral) { @@ -309,10 +361,10 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } final literal = arguments.first as SetOrMapLiteral; - final additionalProperties = _additionalProperties( + final additionalProperties = await _additionalProperties( declaration.expression, path: path, - element: declaration.element, + context: declaration.element, ); final fields = []; for (final entry in literal.elements) { @@ -335,6 +387,11 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } final fieldPath = '$path.$jsonKey'; final fieldChain = parseSchemaChain(entry.value); + final fieldMetadata = await _schemaMetadata( + entry.value, + path: fieldPath, + context: declaration.element, + ); _rejectTransform(fieldChain, fieldPath, declaration.element); if (fieldChain.base?.methodName.name == 'object') { throw InvalidGenerationSource( @@ -347,8 +404,8 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { AckFieldNode( dartName: jsonKey, jsonKey: jsonKey, - presence: _fieldPresence(fieldChain), - nullable: fieldChain.nullable, + presence: _fieldPresence(fieldMetadata), + nullable: fieldMetadata.nullable, runtimeRef: await _runtimeRefForSchema( entry.value, path: fieldPath, @@ -380,6 +437,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { SchemaChain chain, String path, ) async { + _schemaTypes(declaration.expression, path, declaration.element); String? discriminatorKey; SetOrMapLiteral? branchesLiteral; for (final argumentNode in chain.base!.argumentList.arguments) { @@ -487,9 +545,16 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { }) async { final chain = parseSchemaChain(expression); _rejectTransform(chain, path, context); - if (collectionElement && chain.nullable) { + await _rejectObjectShapeChanges(expression, path: path, context: context); + if (collectionElement && + (await _schemaMetadata( + expression, + path: path, + context: context, + )).nullable) { throw InvalidGenerationSource( - '$path uses nullable collection elements. Ack.list does not support ' + '$path uses nullable collection elements (${expression.toSource()}). ' + 'Ack.list does not support ' 'nullable item schemas.', element: context, todo: @@ -542,7 +607,11 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { depth: depth, ); // Unlike list items, JSON object values may be null. - final nullableValue = await _isNullableSchema(arguments.first); + final nullableValue = (await _schemaMetadata( + arguments.first, + path: '$path{}', + context: context, + )).nullable; return AckMapTypeRef( nullableValue && valueType is! AckNullableTypeRef ? AckNullableTypeRef(valueType) @@ -627,7 +696,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { throughLazy: throughLazy, visited: visited ?? {}, depth: depth, - collectionElement: collectionElement, ); if (followed != null) return followed; } @@ -647,7 +715,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { required bool throughLazy, required Set visited, required int depth, - required bool collectionElement, }) async { final element = referencedElement(reference); if (element == null) return null; @@ -689,35 +756,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { visited: {...visited, canonical}, depth: depth + 1, followedName: element.name, - collectionElement: collectionElement, - ); - } - - /// Whether [expression] marks its schema `.nullable()`, following - /// unannotated variable references such as `Ack.map(nullableLabel)`. - Future _isNullableSchema(Expression expression, {int depth = 0}) async { - final chain = parseSchemaChain(expression); - if (chain.nullable) return true; - final reference = chain.reference; - if (chain.base != null || - reference == null || - depth >= ModelGraphBuilderBase.maxReferenceDepth) { - return false; - } - final element = referencedElement(reference); - if (element == null || - (element is! TopLevelVariableElement && element is! GetterElement)) { - return false; - } - final declaration = propertyDeclaration(element); - final owningLibrary = declaration.library; - if (owningLibrary == null) return false; - final initializer = declarationExpression( - await resolvedLibraryFor(owningLibrary), - declaration, ); - return initializer != null && - await _isNullableSchema(initializer, depth: depth + 1); } Future _lazyType( @@ -904,9 +943,9 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ]; } - AckSchemaFieldPresence _fieldPresence(SchemaChain chain) { - if (chain.defaulted) return AckSchemaFieldPresence.defaulted; - if (chain.optional) return AckSchemaFieldPresence.optional; + AckSchemaFieldPresence _fieldPresence(_SchemaMetadata metadata) { + if (metadata.defaulted) return AckSchemaFieldPresence.defaulted; + if (metadata.optional) return AckSchemaFieldPresence.optional; return AckSchemaFieldPresence.required; } @@ -1122,30 +1161,230 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } - bool _additionalProperties( + Future _rejectObjectShapeChanges( Expression expression, { required String path, - required Element element, - }) { + required Element context, + Set visited = const {}, + }) async { Expression? current = expression; + while (current is ParenthesizedExpression) { + current = current.expression; + } while (current is MethodInvocation) { - if (current.methodName.name == 'passthrough') return true; - if (current.methodName.name == 'object') { - for (final argumentNode in current.argumentList.arguments) { - final argument = namedArgument(argumentNode); - if (argument != null && argument.name == 'additionalProperties') { - final value = constantBool(argument.expression); - if (value != null) return value; - throw InvalidGenerationSource( - '$path additionalProperties must be a bool literal or const ' - 'variable reference.', - element: element, - ); - } + 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; + if (const {'extend', 'partial', 'merge', 'pick', 'omit'}.contains(name) || + (name == 'copyWith' && + current.argumentList.arguments.any( + (argument) => const { + 'properties', + 'schemas', + 'discriminatorKey', + }.contains(namedArgument(argument)?.name), + ))) { + throw InvalidGenerationSource( + '$path uses .$name(...), which changes the object shape and cannot ' + 'be modeled statically. Declare the final shape in literal ' + 'Ack.object({...}) or Ack.discriminated(...) arguments instead.', + element: context, + ); + } + if (isAckTarget(current.target)) return; + if (current.methodName.element is TopLevelFunctionElement || + isTypeOwnedSchema(current.methodName.element)) { + break; + } + current = current.target; + } + if (current == null) return; + final target = await resolveReferencedTopLevelExpression( + memberElement(current) ?? referencedElement(current), + visited: visited, + ); + if (target == null) return; + await _rejectObjectShapeChanges( + target.expression, + path: '$path(→ ${target.declaration.name})', + context: context, + visited: {...visited, target.declaration.baseElement}, + ); + } + + Future _additionalProperties( + Expression expression, { + required String path, + required Element context, + Set visited = const {}, + }) async { + Expression? current = expression; + while (current is MethodInvocation) { + final name = current.methodName.name; + // The outermost policy wins; an earlier dynamic value is irrelevant + // when strict() or passthrough() replaces it. + if (name == 'strict') return false; + if (name == 'passthrough') return true; + if (name == 'object' || name == 'copyWith') { + for (final argument in current.argumentList.arguments) { + final entry = namedArgument(argument); + if (entry?.name != 'additionalProperties') continue; + if (entry!.expression is NullLiteral && name == 'copyWith') continue; + final value = constantBool(entry.expression); + if (value != null) return value; + throw InvalidGenerationSource( + '$path additionalProperties must be a bool literal or const ' + 'variable reference.', + element: context, + ); } } + if (isAckTarget(current.target)) return false; current = current.target; } - return false; + if (current == null) return false; + if (current is ParenthesizedExpression) { + return _additionalProperties( + current.expression, + path: path, + context: context, + visited: visited, + ); + } + final referenced = referencedElement(current); + final target = await resolveReferencedTopLevelExpression( + referenced, + visited: visited, + ); + if (target == null) return false; + return _additionalProperties( + target.expression, + path: path, + context: context, + visited: {...visited, target.declaration.baseElement}, + ); + } + + Future<_SchemaMetadata> _schemaMetadata( + Expression expression, { + required String path, + required Element context, + Set visited = const {}, + }) async { + if (expression is ParenthesizedExpression) { + return _schemaMetadata( + expression.expression, + path: path, + context: context, + visited: visited, + ); + } + if (expression is MethodInvocation) { + final name = expression.methodName.name; + final arguments = expression.argumentList.arguments; + Expression? named(String key) { + for (final argument in arguments) { + final entry = namedArgument(argument); + if (entry?.name == key) return entry!.expression; + } + return null; + } + + bool flag(Expression value, String option) { + final resolved = constantBool(value); + if (resolved != null) return resolved; + throw InvalidGenerationSource( + '$path $option must be a bool literal or const variable reference.', + element: context, + ); + } + + if (isAckTarget(expression.target)) { + final input = name == 'codec' + ? named('input') + : name == 'preserveBoundary' + ? argumentExpressions(expression.argumentList).firstOrNull + : null; + if (input != null) { + return (await _schemaMetadata( + input, + path: path, + context: context, + visited: visited, + )).wrapped; + } + // Factories, including Ack.lazy, start with their own unset flags. + return const _SchemaMetadata(); + } + + final target = expression.target; + if (target != null && !isTypeOwnedSchema(expression.methodName.element)) { + final inner = await _schemaMetadata( + target, + path: path, + context: context, + visited: visited, + ); + switch (name) { + case 'nullable': + final value = named('value'); + return inner.copyWith( + nullable: value == null ? true : flag(value, 'nullable value'), + ); + case 'optional': + final value = named('value'); + return inner.copyWith( + optional: value == null ? true : flag(value, 'optional value'), + ); + case 'withDefault': + return _SchemaMetadata( + optional: inner.optional, + nullable: inner.nullable, + defaulted: true, + innerOptional: inner.optional, + innerNullable: inner.nullable, + ); + case 'codec' || 'transform': + return inner.wrapped; + case 'copyWith' || 'withRuntimeConfig' || 'extend': + final nullable = named('isNullable'); + final optional = named('isOptional'); + return inner.copyWith( + nullable: nullable == null || nullable is NullLiteral + ? null + : flag(nullable, '$name isNullable'), + optional: optional == null || optional is NullLiteral + ? null + : flag(optional, '$name isOptional'), + ); + default: + return inner; + } + } + } + + final element = memberElement(expression) ?? referencedElement(expression); + final declaration = element == null ? null : propertyDeclaration(element); + if (declaration == null || + visited.contains(declaration.baseElement) || + visited.length >= ModelGraphBuilderBase.maxReferenceDepth) { + // Shape/type resolution owns cycle and depth diagnostics. Do not hide + // its actionable alias trace while resolving only metadata. + return const _SchemaMetadata(); + } + final owningLibrary = declaration.library; + if (owningLibrary == null) return const _SchemaMetadata(); + final initializer = declarationExpression( + await resolvedLibraryFor(owningLibrary), + declaration, + ); + if (initializer == null) return const _SchemaMetadata(); + return _schemaMetadata( + initializer, + path: path, + context: context, + visited: {...visited, declaration.baseElement}, + ); } } 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 7523e496..10a9743c 100644 --- a/packages/ack_generator/test/integration/ack_infer_graph_test.dart +++ b/packages/ack_generator/test/integration/ack_infer_graph_test.dart @@ -45,6 +45,196 @@ part 'schema.g.dart'; '''; void main() { + test( + 'follows field presence and nullability through schema aliases', + () async { + await _expectOutput( + ''' +$_head +final optionalName = Ack.string().optional(); +final optionalAlias = optionalName; +StringSchema get nullableName => Ack.string().nullable(); +final defaultName = Ack.string().withDefault('member'); + +@Schemable() +final profileSchema = Ack.object({ + 'name': optionalAlias, + 'nickname': nullableName, + 'role': defaultName, +}); +''', + allOf([ + contains('final String? name;'), + contains('this.name'), + isNot(contains('required this.name')), + contains('final String? nickname;'), + contains('required this.nickname'), + contains('final String role;'), + contains('required this.role'), + ]), + ); + }, + ); + + test('honors explicit false and outermost schema flag overrides', () async { + await _expectOutput( + ''' +$_head +const disabled = false; +final optionalName = Ack.string().optional(); +final nullableName = Ack.string().nullable(); + +@Schemable() +final flagsSchema = Ack.object({ + 'name': optionalName.optional(value: disabled), + 'nickname': nullableName.nullable(value: false), + 'labels': Ack.map(nullableName.nullable(value: false)), + 'items': Ack.list(Ack.string().nullable().nullable(value: false)), + 'otherItems': Ack.list(nullableName.nullable(value: false)), +}); + +@Schemable() +final titleSchema = Ack.string().nullable().nullable(value: false); +''', + allOf([ + contains('required this.name'), + contains('final String name;'), + contains('required this.nickname'), + contains('final String nickname;'), + contains('final Map labels;'), + contains('final List items;'), + contains('final List otherItems;'), + contains('final class Title'), + ]), + ); + }); + + test('preserves wrapper flag and outer default semantics', () async { + await _expectOutput( + ''' +$_head +final optionalDefault = Ack.string().optional().withDefault('member'); +final codec = optionalDefault.codec( + decode: (value) => value, + encode: (value) => value, +); +final nullableDefault = Ack.string().nullable().withDefault('member'); + +@Schemable() +final wrappersSchema = Ack.object({ + 'name': codec, + 'nickname': nullableDefault.nullable(value: false), +}); +''', + allOf([ + contains('final String? name;'), + isNot(contains('required this.name')), + contains('required this.nickname'), + contains('final String? nickname;'), + ]), + ); + }); + + test('rejects nullable roots reached through unannotated aliases', () async { + await _expectFailure( + ''' +$_head +final nullableName = Ack.string().nullable(); +@Schemable() +final titleSchema = nullableName; +''', + ['titleSchema', 'nullable root'], + ); + }); + + test('rejects schema flags that are not statically constant', () async { + await _expectFailure( + ''' +$_head +bool get enabled => true; +@Schemable() +final flagsSchema = Ack.object({'name': Ack.string().optional(value: enabled)}); +''', + ['flagsSchema.name', 'optional', 'bool literal or const'], + ); + }); + + for (final modifier in [ + "extend({'age': Ack.integer()})", + 'partial()', + "merge(Ack.object({'age': Ack.integer()}))", + "pick(['name'])", + "omit(['name'])", + 'copyWith(properties: {})', + ]) { + test('rejects unsupported object shape operation $modifier', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final userSchema = Ack.object({'name': Ack.string()}).$modifier; +''', + ['userSchema', modifier.split('(').first, 'shape', 'literal'], + ); + }); + } + + test('strict and passthrough use the outermost object policy', () async { + await _expectOutput( + ''' +$_head +bool get extras => true; +@Schemable() +final strictSchema = Ack.object({}, additionalProperties: extras).passthrough().strict(); +@Schemable() +final aliasSchema = strictSchema.passthrough(); +''', + allOf([ + contains('Strict()'), + isNot(contains('Strict({')), + contains('Alias({Map additionalProperties'), + ]), + ); + }); + + test('rejects shape-changing discriminated copyWith arguments', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final catSchema = Ack.object({'name': Ack.string()}); +@Schemable() +final petSchema = Ack.discriminated( + discriminatorKey: 'kind', schemas: {'cat': catSchema}, +).copyWith(discriminatorKey: 'type'); +''', + ['petSchema', 'copyWith', 'shape', 'literal'], + ); + }); + + test('rejects annotated object results that are no longer schemas', () async { + await _expectFailure( + ''' +$_head +@Schemable() +final userSchema = Ack.object({'name': Ack.string()}).toJsonSchema(); +''', + ['userSchema', 'does not resolve to AckSchema'], + ); + }); + + test('permits shape operations behind an outer value codec', () async { + await _expectOutput(''' +$_head +bool get extras => true; +@Schemable() +final titleSchema = Ack.object({}, additionalProperties: extras).extend({'title': Ack.string()}).codec( + decode: (value) => value['title'] as String, + encode: (value) => {'title': value}, +); +''', allOf([contains('final class Title'), contains('final String value;')])); + }); + test('rejects a codec type the library cannot name', () async { final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); await readerWriter.testing.loadIsolateSources(); 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 54b7f5ba..fdcee051 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 @@ -203,6 +203,40 @@ final labelsSchema = Ack.object({ 'groups': Ack.map(Ack.list(Ack.string()).nullable()), }); +final optionalLabel = Ack.string().optional(); +final optionalLabelAlias = optionalLabel; +StringSchema get nullableLabelAlias => nullableLabel; +final defaultLabel = Ack.string().withDefault('member'); +final optionalDefaultLabel = optionalLabel.withDefault('member'); +final nullableDefaultLabel = nullableLabel.withDefault('member'); + +@Schemable() +final flagAliasesSchema = Ack.object({ + 'name': optionalLabelAlias, + 'nickname': nullableLabelAlias, + 'role': defaultLabel, + 'forced': optionalLabel.optional(value: false), + 'notNull': nullableLabel.nullable(value: false), + 'codec': optionalDefaultLabel.codec( + decode: (value) => value, + encode: (value) => value, + ), + 'wrapped': nullableDefaultLabel.nullable(value: false), + 'labels': Ack.map(nullableLabel.nullable(value: false)), +}); + +@Schemable() +final strictPolicySchema = Ack.object( + {'name': Ack.string()}, + additionalProperties: true, +).passthrough().strict(); + +@Schemable() +final openPolicySchema = strictPolicySchema.passthrough(); + +@Schemable() +final resetNullableSchema = Ack.string().nullable().nullable(value: false); + final class Counted { Counted(this.value); final String value; @@ -268,6 +302,9 @@ part 'address.g.dart'; @Schemable() final addressSchema = Ack.object({'city': Ack.string()}); + +final optionalLabel = Ack.string().optional(); +StringSchema get nullableLabel => Ack.string().nullable(); ''', ); File( @@ -286,6 +323,8 @@ part 'person.g.dart'; final personSchema = Ack.object({ 'home': direct.addressSchema, 'history': Ack.list(exported.addressSchema), + 'note': direct.optionalLabel, + 'label': exported.nullableLabel, }); ''', ); @@ -386,12 +425,17 @@ void main() { final person = Person.parse({ 'home': {'city': 'New York'}, + 'label': null, 'history': [ {'city': 'Amsterdam'}, ], }); expect(person.home.city, 'New York'); expect(person.history.single.city, 'Amsterdam'); + expect(person.note, isNull); + expect(person.label, isNull); + expect(person.toJson()['label'], isNull); + expect(person.toJson().containsKey('note'), isFalse); }); test('generated list fields use deep equality, hashing, and copyWith', () { @@ -539,6 +583,35 @@ void main() { expect(labels.toJson(), json); }); + test('aliases and wrapper boundaries preserve field flags', () { + final json = { + 'nickname': null, + 'forced': 'present', + 'notNull': 'present', + 'wrapped': null, + 'labels': {'a': 'x'}, + }; + final value = FlagAliases.parse(json); + expect(value.name, isNull); + expect(value.nickname, isNull); + expect(value.role, 'member'); + expect(value.codec, isNull); + expect(value.wrapped, 'member'); + expect(value.toJson(), {...json, 'role': 'member', 'wrapped': 'member'}); + expect(FlagAliases.parse(value.toJson()), value); + expect(FlagAliases.safeParse({...json, 'notNull': null}).isFail, isTrue); + expect(FlagAliases.safeParse({...json, 'labels': {'a': null}}).isFail, isTrue); + expect(ResetNullable.parse('value').value, 'value'); + expect(ResetNullable.safeParse(null).isFail, isTrue); + }); + + test('strict and passthrough override object policy through aliases', () { + expect(StrictPolicy.safeParse({'name': 'Ada', 'extra': 1}).isFail, isTrue); + final open = OpenPolicy.parse({'name': 'Ada', 'extra': 1}); + expect(open.additionalProperties, {'extra': 1}); + expect(open.toJson(), {'name': 'Ada', 'extra': 1}); + }); + test('union discriminators cannot be spoofed through extras', () { final dog = Dog( friendly: true, diff --git a/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart b/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart new file mode 100644 index 00000000..8eda10c5 --- /dev/null +++ b/packages/ack_generator/test/integration/schema_alias_shape_contract_test.dart @@ -0,0 +1,96 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +const _source = r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +@Schemable() +final baseSchema = Ack.object({'name': Ack.string()}); +final changed = (baseSchema.extend({'age': Ack.integer()})); +'''; + +Future _build( + String suffix, + Map outputs, { + void Function(String)? onError, +}) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + {'test_pkg|lib/models.dart': '$_source\n$suffix'}, + generateFor: const {'test_pkg|lib/models.dart'}, + readerWriter: readerWriter, + outputs: outputs, + onLog: (log) { + if (log.level.name == 'SEVERE') onError?.call(log.message); + }, + ); +} + +void main() { + test( + 'rejects shape changes hidden behind unannotated schema aliases', + () async { + final errors = []; + await _build( + r''' +final intermediate = changed; +@Schemable() +final aliasSchema = intermediate; +''', + const {}, + onError: errors.add, + ); + expect(errors, contains(contains('aliasSchema'))); + expect(errors, contains(contains('changed'))); + expect(errors, contains(contains('extend'))); + expect(errors, contains(contains('shape'))); + }, + ); + + test('stops alias shape inspection at an outer value codec', () async { + await _build( + r''' +final wrapped = changed.codec( + decode: (value) => value['age'] as int, + encode: (value) => {'name': 'Ada', 'age': value}, +); +@Schemable() +final aliasSchema = wrapped; +''', + { + 'test_pkg|lib/models.g.dart': decodedMatches( + allOf([contains('final class Alias'), contains('final int value;')]), + ), + }, + ); + }); + + test( + 'inspects type-owned schema calls without self-referencing AST recursion', + () async { + await _build( + r''' +final class Token { + const Token(this.value); + final String value; + static AckSchema schema() => Ack.string().codec( + decode: Token.new, encode: (token) => token.value, + ); +} +@Schemable() +final aliasSchema = Ack.object({'token': Token.schema()}); +''', + { + 'test_pkg|lib/models.g.dart': decodedMatches( + contains('final Token token;'), + ), + }, + ); + }, + ); +} From 430494bc4a8cef8803cfd4aff843e0ac1361f626 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:31:52 -0400 Subject: [PATCH 07/37] fix(generator): resolve generated value model runtime types --- packages/ack_generator/CHANGELOG.md | 2 + .../src/analyzer/custom_schema_resolver.dart | 22 +++- .../lib/src/inference/schema_inference.dart | 6 +- ...class_first_nested_runtime_build_test.dart | 116 ++++++++++++++++++ .../class_first_value_runtime_refs_test.dart | 82 +++++++++++++ 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index d3ea1796..05eae921 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -7,6 +7,8 @@ strict/passthrough policy to object models and aliases. * Reject object shape-changing methods that cannot be modeled statically, instead of generating fields that disagree with the authored schema. +* Resolve same-build scalar, list, and codec model references using their + actual runtime types, and generate valid codecs for nullable nested Sets. ## 2.0.0-alpha.1 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 4f7a270e..0b3c308d 100644 --- a/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart +++ b/packages/ack_generator/lib/src/analyzer/custom_schema_resolver.dart @@ -826,6 +826,22 @@ final class CustomSchemaResolver { field: field, ); if (target == null) return null; + final schemaType = switch (target) { + TopLevelVariableElement(:final type) => type, + GetterElement(:final returnType) => returnType, + _ => null, + }; + final runtime = schemaType == null + ? null + : _base.ackSchemaRuntimeType(schemaType); + if (runtime == null || _base.containsInvalidType(runtime)) { + throw InvalidGenerationSource( + '${_fieldPath(field)} resolves to generated model "$name", but ' + '${target.name} has no resolvable AckSchema runtime type. Declare ' + 'the schema type explicitly.', + element: field, + ); + } final visibleName = qualifySymbol(prefix, name); result = ( schemaExpression: '$visibleName.\$ack.schema', @@ -835,7 +851,11 @@ final class CustomSchemaResolver { declarationName: target.name!, ), className: name, - runtimeRef: ModelGraphBuilderBase.jsonMapRef, + runtimeRef: _base.inferTypeRef( + runtime, + path: _fieldPath(field), + context: field, + ), importPrefix: prefix, ), setListSchema: null, diff --git a/packages/ack_generator/lib/src/inference/schema_inference.dart b/packages/ack_generator/lib/src/inference/schema_inference.dart index fa254bda..6c831136 100644 --- a/packages/ack_generator/lib/src/inference/schema_inference.dart +++ b/packages/ack_generator/lib/src/inference/schema_inference.dart @@ -70,7 +70,11 @@ final class AckSchemaInference { rejectNullableCollectionElement: rejectNullableCollectionElement, validateMapKey: validateMapKey, ); - return '${_ack('Ack')}.list($itemSchema).codec<${renderType(type)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)'; + final runtimeType = type.element.instantiate( + typeArguments: type.typeArguments, + nullabilitySuffix: NullabilitySuffix.none, + ); + return '${_ack('Ack')}.list($itemSchema).codec<${renderType(runtimeType)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)'; } if (type.isDartCoreMap) { validateMapKey(type); diff --git a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart index f8c81d35..4df7eb74 100644 --- a/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart +++ b/packages/ack_generator/test/integration/class_first_nested_runtime_build_test.dart @@ -252,6 +252,76 @@ final class Holder with _$HolderAck { } ''', ); + File( + p.join(temporary.path, 'lib', 'value_models.dart'), + ).writeAsStringSync(r''' +import 'package:ack/ack.dart'; + +part 'value_models.g.dart'; + +final class Token { + const Token(this.value); + final String value; + static int decodes = 0; + static int encodes = 0; + + static Token decode(String value) { + decodes++; + return Token(value); + } + + static String encode(Token token) { + encodes++; + return token.value; + } + + @override + bool operator ==(Object other) => other is Token && other.value == value; + @override + int get hashCode => value.hashCode; +} + +@Schemable() +final scalarSchema = Ack.integer(); +@Schemable() +final stringsSchema = Ack.list(Ack.string()); +@Schemable() +final encodedSchema = Ack.string().codec( + decode: Token.decode, + encode: Token.encode, +); +'''); + File( + p.join(temporary.path, 'lib', 'value_holder.dart'), + ).writeAsStringSync(r''' +import 'package:ack/ack.dart'; +import 'value_models.dart' as values; + +part 'value_holder.g.dart'; + +@Schemable() +final class ValueHolder with _$ValueHolderAck { + const ValueHolder({ + required this.scalar, + required this.strings, + required this.encoded, + required this.history, + required this.unique, + this.patch = const JsonMaybe.absent(), + required this.sets, + required this.nestedSets, + }); + + final values.Scalar scalar; + final values.Strings strings; + final values.Encoded encoded; + final List history; + final Set unique; + final JsonMaybe patch; + final Map?> sets; + final Map?>?> nestedSets; +} +'''); File( p.join(temporary.path, 'lib', 'address_envelope.dart'), ).writeAsStringSync(r''' @@ -284,9 +354,55 @@ import 'package:ack_class_first_nested/itinerary.dart'; import 'package:ack_class_first_nested/order.dart'; import 'package:ack_class_first_nested/parcel.dart'; import 'package:ack_class_first_nested/pet.dart'; +import 'package:ack_class_first_nested/value_holder.dart'; +import 'package:ack_class_first_nested/value_models.dart' as values; +import 'package:ack/ack.dart'; import 'package:test/test.dart'; void main() { + test('same-build value models and nullable nested Sets round-trip', () { + values.Token.decodes = 0; + values.Token.encodes = 0; + final json = { + 'scalar': 7, + 'strings': ['one', 'two'], + 'encoded': 'token', + 'history': [1, 2], + 'unique': [3, 4], + 'sets': {'present': [1, 2], 'none': null}, + 'nestedSets': { + 'present': {'items': ['a', 'b'], 'none': null}, + 'none': null, + }, + }; + final holder = ValueHolderSchema.parse(json); + expect(holder.scalar.value, 7); + expect(holder.strings.value, ['one', 'two']); + expect(holder.encoded.value.value, 'token'); + expect(holder.history.map((model) => model.value), [1, 2]); + expect(holder.unique.map((model) => model.value), [3, 4]); + expect(holder.patch, const JsonMaybe.absent()); + expect(holder.sets['present'], {1, 2}); + expect(holder.sets['none'], isNull); + expect(holder.nestedSets['present']!['items'], {'a', 'b'}); + expect(holder.nestedSets['present']!['none'], isNull); + expect(holder.nestedSets['none'], isNull); + expect(values.Token.decodes, 1); + expect(holder.toJson(), json); + expect(values.Token.encodes, 1); + expect(ValueHolderSchema.parse(holder.toJson()), holder); + expect(() => holder.strings.value.add('three'), throwsUnsupportedError); + expect(() => holder.sets['present']!.add(3), throwsUnsupportedError); + expect( + () => holder.nestedSets['present']!['items']!.add('c'), + throwsUnsupportedError, + ); + + final patched = ValueHolderSchema.parse({...json, 'patch': 'changed'}); + expect(patched.patch.valueOrNull!.value.value, 'changed'); + expect(patched.toJson()['patch'], 'changed'); + }); + test('nested models parse, encode, and export through facades', () { final order = OrderSchema.parse({ 'shipping': {'city': 'Amsterdam'}, diff --git a/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart b/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart new file mode 100644 index 00000000..f76ffb7d --- /dev/null +++ b/packages/ack_generator/test/integration/class_first_value_runtime_refs_test.dart @@ -0,0 +1,82 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +Future _expectOutput(String source, Matcher matcher) async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + {'test_pkg|lib/models.dart': source}, + generateFor: const {'test_pkg|lib/models.dart'}, + readerWriter: readerWriter, + outputs: {'test_pkg|lib/models.g.dart': decodedMatches(matcher)}, + ); +} + +void main() { + test( + 'same-build value models use their actual schema runtime types', + () async { + await _expectOutput( + r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +final class Token { + const Token(this.value); + final String value; +} + +@Schemable() +final scalarSchema = Ack.integer(); +@Schemable() +final stringsSchema = Ack.list(Ack.string()); +@Schemable() +final encodedSchema = Ack.string().codec( + decode: Token.new, + encode: (token) => token.value, +); + +@Schemable() +final class Holder with _$HolderAck { + const Holder({required this.scalar, required this.strings, required this.encoded}); + final Scalar scalar; + final Strings strings; + final Encoded encoded; +} +''', + allOf([ + contains(r"Scalar.$ack.fromRuntime(value['scalar'] as int)"), + contains( + r"Strings.$ack.fromRuntime(value['strings'] as List)", + ), + contains(r"Encoded.$ack.fromRuntime(value['encoded'] as Token)"), + ]), + ); + }, + ); + + test('nullable nested Sets use non-nullable codec type arguments', () async { + await _expectOutput( + r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +@Schemable() +final class Holder with _$HolderAck { + const Holder({required this.values, required this.nested}); + final Map?> values; + final Map?>?> nested; +} +''', + allOf([ + contains('.codec>('), + contains('.codec>('), + isNot(contains('.codec?>(')), + isNot(contains('.codec?>(')), + ]), + ); + }); +} From 121581d75b7f10233bd24cd3ad90b2ec05021429 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 11:40:29 -0400 Subject: [PATCH 08/37] fix(generator): validate generated names and getter references --- packages/ack_generator/CHANGELOG.md | 3 + .../analyzer/model_graph_builder_base.dart | 1 + .../analyzer/schema_model_graph_builder.dart | 53 ++++++--- .../class_first_getter_model_refs_test.dart | 44 ++++++++ ...class_first_nested_runtime_build_test.dart | 6 ++ .../generated_names_contract_test.dart | 101 ++++++++++++++++++ 6 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 packages/ack_generator/test/integration/class_first_getter_model_refs_test.dart create mode 100644 packages/ack_generator/test/integration/generated_names_contract_test.dart diff --git a/packages/ack_generator/CHANGELOG.md b/packages/ack_generator/CHANGELOG.md index 05eae921..53239348 100644 --- a/packages/ack_generator/CHANGELOG.md +++ b/packages/ack_generator/CHANGELOG.md @@ -9,6 +9,9 @@ instead of generating fields that disagree with the authored schema. * Resolve same-build scalar, list, and codec model references using their actual runtime types, and generate valid codecs for nullable nested Sets. +* Diagnose value-model helper collisions, class/member name conflicts, and + duplicate schema-first keys before emission. Resolve same-build models + declared with annotated getters. ## 2.0.0-alpha.1 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 c460b3ea..7b63ac0c 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 @@ -157,6 +157,7 @@ abstract base class ModelGraphBuilderBase { Element? schemableDeclaration(Element element) { final declaration = switch (element) { GetterElement(isOriginVariable: true) => element.variable.baseElement, + GetterElement() => element.baseElement, TopLevelVariableElement() => element.baseElement, _ => null, }; 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 42a59c7b..c60eacf0 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 @@ -125,7 +125,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { path: declaration.id.declarationName, ); } - _validateDelegatedHelperNames(); + _validateGeneratedNames(); return graph; } @@ -367,6 +367,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { context: declaration.element, ); final fields = []; + final declaredKeys = {}; for (final entry in literal.elements) { if (entry is! MapLiteralEntry || entry.key is! SimpleStringLiteral) { throw InvalidGenerationSource( @@ -375,6 +376,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { ); } final jsonKey = (entry.key as SimpleStringLiteral).value; + if (!declaredKeys.add(jsonKey)) { + throw InvalidGenerationSource( + '$path.$jsonKey is a duplicate object field. Declare each key once.', + element: declaration.element, + ); + } final problem = ackSchemaFirstFieldNameProblem( jsonKey, capturesExtras: additionalProperties, @@ -1006,27 +1013,45 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase { } } - void _validateDelegatedHelperNames() { + void _validateGeneratedNames() { final localNames = localDeclarationNames(); for (final node in graph.nodes) { final declaration = _declarationsById[node.id]; if (declaration == null) continue; - switch (node) { - case AckUnionModelNode(): - case AckValueModelNode(): - continue; - case AckObjectModelNode(): - validateCopyWithHelperNames( - className: node.className, - needsCopyWithSentinel: node.fields.any( + + void checkMember(String name) { + if (name != node.className) return; + throw InvalidGenerationSource( + '${node.id.declarationName}.$name conflicts with the generated ' + 'class name "${node.className}".', + element: declaration.element, + ); + } + + if (node is AckUnionModelNode) { + checkMember(node.discriminatorKey); + continue; + } + if (node is AckObjectModelNode) { + for (final field in node.fields) { + checkMember(field.dartName); + } + if (node.discriminatorKey case final discriminator?) { + checkMember(discriminator); + } + } + validateCopyWithHelperNames( + className: node.className, + needsCopyWithSentinel: + node is AckObjectModelNode && + node.fields.any( (field) => field.jsonKey != node.discriminatorKey && (field.nullable || !field.isRequired), ), - element: declaration.element, - localNames: localNames, - ); - } + element: declaration.element, + localNames: localNames, + ); } } diff --git a/packages/ack_generator/test/integration/class_first_getter_model_refs_test.dart b/packages/ack_generator/test/integration/class_first_getter_model_refs_test.dart new file mode 100644 index 00000000..dcdee38c --- /dev/null +++ b/packages/ack_generator/test/integration/class_first_getter_model_refs_test.dart @@ -0,0 +1,44 @@ +import 'package:ack_generator/src/builder.dart'; +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:test/test.dart'; + +void main() { + test( + 'class-first fields resolve same-build getter-generated models', + () async { + final readerWriter = TestReaderWriter(rootPackage: 'test_pkg'); + await readerWriter.testing.loadIsolateSources(); + await testBuilder( + ackModelBuilder(BuilderOptions.empty), + { + 'test_pkg|lib/models.dart': r''' +import 'package:ack/ack.dart'; +part 'models.g.dart'; + +@Schemable() +StringSchema get labelSchema => Ack.string(); + +@Schemable() +final class Holder with _$HolderAck { + const Holder({required this.label, required this.history}); + final Label label; + final List