From ce03219707a824afe6e9fbe53c16d37450db87cd Mon Sep 17 00:00:00 2001 From: andrewkolos Date: Wed, 3 Jun 2026 12:17:55 +0000 Subject: [PATCH 1/5] fix(genui): explicitly enforce primitive types in JSON schema validator --- packages/genui/CHANGELOG.md | 1 + packages/genui/lib/src/model/ui_models.dart | 27 ++++ packages/genui/test/model/ui_models_test.dart | 138 ++++++++++++++++++ 3 files changed, 166 insertions(+) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 0ce448fd6..55762280c 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.9.2 - **Feature**: Updated example/README.md. +- **Fix**: `SurfaceDefinition.validate` now strictly enforces primitive data types, correctly identifying and rejecting malformed components (like numbers passed for strings) before they can cause fatal rendering crashes. ## 0.9.0 diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 3915740c3..7cf741b07 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -241,6 +241,33 @@ class SurfaceDefinition { return; } + if (schema case {'type': Object expectedType}) { + final List types = expectedType is List + ? expectedType.cast() + : [expectedType as String]; + + final bool isValid = types.any( + (t) => switch (t) { + 'string' => instance is String, + 'number' => instance is num, + 'integer' => instance is int, + 'boolean' => instance is bool, + 'object' => instance is Map, + 'array' => instance is List, + 'null' => false, // instance is guaranteed non-null here + _ => true, // Pass unknown constraints + }, + ); + + if (!isValid) { + throw A2uiValidationException( + 'Type mismatch. Expected $types, got ${instance.runtimeType}', + surfaceId: surfaceId, + path: path, + ); + } + } + if (schema case {'const': Object? constVal} when instance != constVal) { throw A2uiValidationException( 'Value mismatch. Expected $constVal, got $instance', diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index d7ae0642f..77097df80 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -118,6 +118,144 @@ void main() { surfaceDefinition.validate(schema); // Should not throw }); + + test('validate enforces primitive types ' + '(this will fail due to the bug)', () { + final component = const Component( + id: 'test', + type: 'Text', + // BAD DATA: text is an int, but schema expects a string + properties: {'text': 42}, + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Text'), + 'text': S.string(), // Validator should enforce this type! + }, + ), + ), + }, + ); + + // The schema validator should throw an exception because 'text' + // is an int. + // Since it silently ignores the 'type' keyword, this expect will FAIL, + // proving the bug exists. + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); + + test('validate enforces primitive types: boolean', () { + final component = const Component( + id: 'test', + type: 'Checkbox', + properties: {'checked': 'true'}, // BAD: string instead of bool + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Checkbox'), + 'checked': S.boolean(), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); + + test('validate enforces primitive types: ' + 'number vs integer', () { + final componentInt = const Component( + id: 'test1', + type: 'Slider', + properties: {'value': 42}, // Valid integer + ); + final componentDouble = const Component( + id: 'test2', + type: 'Slider', + properties: {'value': 42.5}, // Valid number, invalid integer + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Slider'), + 'value': S.integer(), // Explicitly requires integer + }, + ), + ), + }, + ); + + // Integer should pass + SurfaceDefinition( + surfaceId: 's1', + components: {'test1': componentInt}, + ).validate(schema); + + // Double should fail because schema requires integer + expect( + () => SurfaceDefinition( + surfaceId: 's2', + components: {'test2': componentDouble}, + ).validate(schema), + throwsA(isA()), + ); + }); + + test('validate enforces primitive types: array and object', () { + final component = const Component( + id: 'test', + type: 'List', + properties: {'items': 42}, // BAD: int instead of array + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'List'), + 'items': S.list(items: S.string()), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); }); group('SurfaceDefinition extended', () { From 57fdb4cd7c32288f78e363ff0b9fe349f64024c9 Mon Sep 17 00:00:00 2001 From: andrewkolos Date: Wed, 3 Jun 2026 12:24:36 +0000 Subject: [PATCH 2/5] clean up --- packages/genui/test/model/ui_models_test.dart | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index 77097df80..6bfcac813 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -75,7 +75,6 @@ void main() { components: {'test': component}, ); - // Schema invalidating the component (e.g., expecting type "Button") final schema = S.object( properties: { 'components': S.list( @@ -116,15 +115,14 @@ void main() { }, ); - surfaceDefinition.validate(schema); // Should not throw + surfaceDefinition.validate(schema); // Should not throw. }); - test('validate enforces primitive types ' - '(this will fail due to the bug)', () { + test('validate enforces primitive types ', () { final component = const Component( id: 'test', type: 'Text', - // BAD DATA: text is an int, but schema expects a string + // int instead of string. properties: {'text': 42}, ); final surfaceDefinition = SurfaceDefinition( @@ -138,17 +136,13 @@ void main() { items: S.object( properties: { 'component': S.string(constValue: 'Text'), - 'text': S.string(), // Validator should enforce this type! + 'text': S.string(), }, ), ), }, ); - // The schema validator should throw an exception because 'text' - // is an int. - // Since it silently ignores the 'type' keyword, this expect will FAIL, - // proving the bug exists. expect( () => surfaceDefinition.validate(schema), throwsA(isA()), @@ -159,7 +153,7 @@ void main() { final component = const Component( id: 'test', type: 'Checkbox', - properties: {'checked': 'true'}, // BAD: string instead of bool + properties: {'checked': 'true'}, // string instead of bool. ); final surfaceDefinition = SurfaceDefinition( surfaceId: 's1', @@ -190,12 +184,12 @@ void main() { final componentInt = const Component( id: 'test1', type: 'Slider', - properties: {'value': 42}, // Valid integer + properties: {'value': 42}, // Valid integer. ); final componentDouble = const Component( id: 'test2', type: 'Slider', - properties: {'value': 42.5}, // Valid number, invalid integer + properties: {'value': 42.5}, // Valid number, invalid integer. ); final schema = S.object( @@ -204,20 +198,18 @@ void main() { items: S.object( properties: { 'component': S.string(constValue: 'Slider'), - 'value': S.integer(), // Explicitly requires integer + 'value': S.integer(), }, ), ), }, ); - // Integer should pass SurfaceDefinition( surfaceId: 's1', components: {'test1': componentInt}, ).validate(schema); - // Double should fail because schema requires integer expect( () => SurfaceDefinition( surfaceId: 's2', @@ -231,7 +223,7 @@ void main() { final component = const Component( id: 'test', type: 'List', - properties: {'items': 42}, // BAD: int instead of array + properties: {'items': 42}, // int instead of array. ); final surfaceDefinition = SurfaceDefinition( surfaceId: 's1', From 07ce6f852eca749fd433107be7fed928c9620a16 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Wed, 3 Jun 2026 13:53:53 +0000 Subject: [PATCH 3/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/genui/lib/src/model/ui_models.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 7cf741b07..9b55ec78b 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -243,8 +243,8 @@ class SurfaceDefinition { if (schema case {'type': Object expectedType}) { final List types = expectedType is List - ? expectedType.cast() - : [expectedType as String]; + ? expectedType.map((e) => e.toString()).toList() + : [expectedType.toString()]; final bool isValid = types.any( (t) => switch (t) { From 34716ad87bc1019431eea51e4b01b9fa98d4c911 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 03:32:53 -0700 Subject: [PATCH 4/5] fix(genui): accept whole-valued doubles as integers in schema validator JSON Schema treats a number with a zero fractional part as an integer, and JSON decoding yields a Dart double for 42.0. The integer check used `instance is int` and rejected such values; accept a double whose remainder by 1 is zero, matching getJsonType in json_schema_builder. Change the type switch default from pass to reject. The seven JSON Schema types are exhaustive, so a value reaching the default is a malformed schema, and with `types.any` a passing default silently disabled type enforcement for an entire union. Add tests for a whole-valued double, int and double against number, and the type-mismatch message. --- packages/genui/lib/src/model/ui_models.dart | 8 +-- packages/genui/test/model/ui_models_test.dart | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 9b55ec78b..f027c91d2 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -250,12 +250,14 @@ class SurfaceDefinition { (t) => switch (t) { 'string' => instance is String, 'number' => instance is num, - 'integer' => instance is int, + // A whole-valued double such as 42.0 is a valid integer. + 'integer' => + instance is num && (instance is int || instance.remainder(1) == 0), 'boolean' => instance is bool, 'object' => instance is Map, 'array' => instance is List, - 'null' => false, // instance is guaranteed non-null here - _ => true, // Pass unknown constraints + 'null' => false, + _ => false, }, ); diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index 6bfcac813..e9bfb94f0 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -118,7 +118,7 @@ void main() { surfaceDefinition.validate(schema); // Should not throw. }); - test('validate enforces primitive types ', () { + test('validate enforces primitive types: string', () { final component = const Component( id: 'test', type: 'Text', @@ -145,7 +145,13 @@ void main() { expect( () => surfaceDefinition.validate(schema), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Type mismatch'), + ), + ), ); }); @@ -191,6 +197,13 @@ void main() { type: 'Slider', properties: {'value': 42.5}, // Valid number, invalid integer. ); + // JSON decodes whole numbers like 42.0 to a Dart double; per JSON + // Schema a double with a zero fractional part is still an integer. + final componentWholeDouble = const Component( + id: 'test3', + type: 'Slider', + properties: {'value': 42.0}, + ); final schema = S.object( properties: { @@ -210,6 +223,11 @@ void main() { components: {'test1': componentInt}, ).validate(schema); + SurfaceDefinition( + surfaceId: 's3', + components: {'test3': componentWholeDouble}, + ).validate(schema); + expect( () => SurfaceDefinition( surfaceId: 's2', @@ -219,6 +237,34 @@ void main() { ); }); + test('validate accepts both int and double for number type', () { + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Slider'), + 'value': S.number(), + }, + ), + ), + }, + ); + + for (final value in const [42, 42.5]) { + SurfaceDefinition( + surfaceId: 's1', + components: { + 'test': Component( + id: 'test', + type: 'Slider', + properties: {'value': value}, + ), + }, + ).validate(schema); + } + }); + test('validate enforces primitive types: array and object', () { final component = const Component( id: 'test', From 305aaa3fa088121a957ced03f6660519c6e89049 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Thu, 4 Jun 2026 04:44:43 -0700 Subject: [PATCH 5/5] fix(genui): reject explicit null against non-nullable types The early null guard let a present null such as {"text": null} pass validation even when the field's type excludes null. Run the type check before the guard so a null is rejected unless 'null' is an allowed type. --- packages/genui/lib/src/model/ui_models.dart | 13 ++++---- packages/genui/test/model/ui_models_test.dart | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index f027c91d2..05a9bb733 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -237,10 +237,6 @@ class SurfaceDefinition { Map schema, String path, ) { - if (instance == null) { - return; - } - if (schema case {'type': Object expectedType}) { final List types = expectedType is List ? expectedType.map((e) => e.toString()).toList() @@ -256,20 +252,25 @@ class SurfaceDefinition { 'boolean' => instance is bool, 'object' => instance is Map, 'array' => instance is List, - 'null' => false, + 'null' => instance == null, _ => false, }, ); if (!isValid) { + final Object actualType = instance?.runtimeType ?? 'null'; throw A2uiValidationException( - 'Type mismatch. Expected $types, got ${instance.runtimeType}', + 'Type mismatch. Expected $types, got $actualType', surfaceId: surfaceId, path: path, ); } } + if (instance == null) { + return; + } + if (schema case {'const': Object? constVal} when instance != constVal) { throw A2uiValidationException( 'Value mismatch. Expected $constVal, got $instance', diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index e9bfb94f0..8c0e7383c 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -265,6 +265,36 @@ void main() { } }); + test('validate rejects explicit null for a non-nullable type', () { + final component = const Component( + id: 'test', + type: 'Text', + properties: {'text': null}, // null where a string is required. + ); + final surfaceDefinition = SurfaceDefinition( + surfaceId: 's1', + components: {'test': component}, + ); + + final schema = S.object( + properties: { + 'components': S.list( + items: S.object( + properties: { + 'component': S.string(constValue: 'Text'), + 'text': S.string(), + }, + ), + ), + }, + ); + + expect( + () => surfaceDefinition.validate(schema), + throwsA(isA()), + ); + }); + test('validate enforces primitive types: array and object', () { final component = const Component( id: 'test',