From af253abd8dbd9a5f8a7eee93231527588842eb50 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 17:35:24 -0400 Subject: [PATCH] fix(ack): discover bundled custom schema dialects --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 2 + packages/ack/CHANGELOG.md | 3 + .../src/json_schema/json_schema_compiler.dart | 124 +++++++++++- .../json_schema_bundled_dialect_test.dart | 176 ++++++++++++++++++ 5 files changed, 300 insertions(+), 6 deletions(-) create mode 100644 packages/ack/test/json_schema_bundled_dialect_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fca741dd..791df094 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,7 @@ jobs: test/schemas/extensions/list_schema_extensions_test.dart \ test/utils/collection_utils_test.dart \ test/json_schema_import_test.dart \ + test/json_schema_bundled_dialect_test.dart \ test/json_schema_vocabulary_contract_test.dart \ test/json_schema_import_api_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb4088c..e071fd01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline * Reject duration inputs that cannot preserve their original milliseconds. * Preserve custom JSON Schema vocabulary semantics, inert annotation payloads, explicit format assertions, and fluent null policies through export. +* Resolve custom dialects bundled under active schema keywords while leaving + unrelated annotation data untouched. * Preserve schema-first alias metadata and strict/passthrough precedence; diagnose unsupported shape transformations instead of emitting wrong fields. * Resolve same-build value-model and getter references, nullable nested Sets, diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 06cba4a3..5e9239aa 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -15,6 +15,9 @@ 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. +* Discover selected custom dialects bundled beneath their own active schema + keywords, without interpreting inactive keyword payloads or annotation data + as schemas. ## 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 7664a50e..153d2bae 100644 --- a/packages/ack/lib/src/json_schema/json_schema_compiler.dart +++ b/packages/ack/lib/src/json_schema/json_schema_compiler.dart @@ -460,12 +460,117 @@ final class _JsonSchemaCompiler { _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); + // A selected dialect can be bundled beneath its own applicator keywords. + // Discover that resource before reporting an unresolved dependency, but + // only when its vocabulary makes the containing locations schemas. + if (!progressed && + !_pendingChildren + .toList(growable: false) + .any(_discoverBundledDialect)) { + _configureDialect(_pendingChildren.first); + } } } + bool _discoverBundledDialect(_ImportedNode node) { + final wanted = node.dialectUri; + if (resources.containsKey(wanted)) return true; + + bool visit(_ImportedNode parent, List<(_ImportedNode, String)> path) { + final source = parent.source; + if (source is! Map) return false; + + bool visitChild(Object? value, String keyword, String suffix) { + if (value is! Map) return false; + var base = parent.baseUri; + var dialect = parent.dialectUri; + try { + if (value.containsKey(r'$id')) { + final id = value[r'$id']; + if (id is! String) return false; + base = base.resolve(id); + if (base.fragment.isNotEmpty) return false; + base = base.removeFragment(); + } + if (value.containsKey(r'$schema')) { + final declared = value[r'$schema']; + if (declared is! String) return false; + dialect = base.resolve(declared); + if (dialect.fragment.isNotEmpty) return false; + dialect = dialect.removeFragment(); + } + } on FormatException { + // Probing must not validate unrelated, potentially inert payloads. + return false; + } + final child = _ImportedNode( + value, + parent.documentUri, + '${parent.pointer}/$suffix', + base, + dialect, + ); + final childPath = [...path, (parent, keyword)]; + if (value.containsKey(r'$id') && base == wanted) { + if (value[r'$vocabulary'] is! Map) return false; + for (final (scope, key) in childPath) { + if (scope.dialectUri != wanted && + !resources.containsKey(scope.dialectUri) && + !draft7Dialects.contains(scope.dialectUri)) { + return false; + } + _configureDialect(scope, bundledDialect: child); + if (!scope.isKeywordActive(key)) return false; + } + _index( + value, + child.documentUri, + child.pointer, + parent.baseUri, + dialect: parent.dialectUri, + ); + return true; + } + return visit(child, childPath); + } + + for (final entry in source.entries) { + final key = entry.key; + final value = entry.value; + if (mapKeywords.contains(key) && value is Map) { + for (final child in value.entries) { + if (visitChild( + child.value, + key, + '$key/${escapeJsonPointerToken(child.key)}', + )) { + return true; + } + } + } else if (listKeywords.contains(key) && value is List) { + for (final (index, child) in value.indexed) { + if (visitChild(child, key, '$key/$index')) return true; + } + } else if (childKeywords.contains(key) && visitChild(value, key, key)) { + return true; + } + } + return false; + } + + // Probe detached nodes so unsuccessful candidates cannot alter compilation. + return visit( + _ImportedNode( + node.source, + node.documentUri, + node.pointer, + node.baseUri, + node.dialectUri, + ), + [], + ); + } + Never _invalidSchema(_ImportedNode node, String key) => _fail(node, key, 'Expected a schema object or boolean.'); @@ -546,8 +651,10 @@ final class _JsonSchemaCompiler { _checkProductiveCycles(nodes); } - void _configureDialect(_ImportedNode node) { - final dialect = resources[node.dialectUri]; + void _configureDialect(_ImportedNode node, {_ImportedNode? bundledDialect}) { + final dialect = bundledDialect?.baseUri == node.dialectUri + ? bundledDialect + : resources[node.dialectUri]; if (dialect == null) { if (draft7Dialects.contains(node.dialectUri)) { node.applicatorVocabulary = true; @@ -575,7 +682,12 @@ final class _JsonSchemaCompiler { code: 'unsupported_dialect', ); } - _checkRequiredVocabularies(node, r'$schema', vocabulary); + // A discovery candidate may live in an inactive keyword's annotation data. + // Check its requirements only after discovery proves it is a schema and + // normal indexing registers it as a resource. + if (bundledDialect == null) { + _checkRequiredVocabularies(node, r'$schema', vocabulary); + } node.applicatorVocabulary = vocabulary.containsKey(applicatorVocabularyUri); node.unevaluatedVocabulary = vocabulary.containsKey( unevaluatedVocabularyUri, diff --git a/packages/ack/test/json_schema_bundled_dialect_test.dart b/packages/ack/test/json_schema_bundled_dialect_test.dart new file mode 100644 index 00000000..612daa42 --- /dev/null +++ b/packages/ack/test/json_schema_bundled_dialect_test.dart @@ -0,0 +1,176 @@ +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/'; +const _customDialect = 'https://example.test/meta/custom'; + +Map _dialect({bool applicator = true}) => { + r'$id': _customDialect, + r'$schema': _standardDialect, + r'$vocabulary': { + '${_vocabularyPrefix}core': true, + if (applicator) '${_vocabularyPrefix}applicator': true, + '${_vocabularyPrefix}validation': true, + }, +}; + +void _expectStringRoundTrip(Map document) { + final schema = Ack.fromJsonSchema(document); + final exported = schema.toJsonSchema(); + final restored = Ack.fromJsonSchema(exported); + for (final candidate in [schema, restored]) { + expect(candidate.safeParse('ok').isOk, isTrue); + expect(candidate.safeParse(1).isFail, isTrue); + expect(candidate.safeParse(null).isFail, isTrue); + } + expect(schema.toJsonSchema(), exported); +} + +void main() { + for (final container in ['allOf', 'properties', 'items']) { + test('discovers a selected dialect bundled under $container', () { + _expectStringRoundTrip({ + r'$schema': _customDialect, + 'type': 'string', + container: switch (container) { + 'allOf' => [_dialect()], + 'properties' => {'meta': _dialect()}, + _ => _dialect(), + }, + }); + }); + } + + test('resolves a bundled dialect through nested relative resource IDs', () { + _expectStringRoundTrip({ + r'$id': 'https://example.test/root', + r'$schema': _customDialect, + 'type': 'string', + 'allOf': [ + { + r'$id': 'meta/', + 'allOf': [ + {..._dialect(), r'$id': 'custom'}, + ], + }, + ], + }); + }); + + test('honors a different dialect along the resource discovery path', () { + _expectStringRoundTrip({ + r'$schema': _customDialect, + 'type': 'string', + 'allOf': [ + { + r'$id': 'https://example.test/standard-resource', + r'$schema': _standardDialect, + // Enabled by this resource's dialect, but not the root's dialect. + 'unevaluatedProperties': _dialect(), + }, + ], + }); + }); + + test( + 'discovery leaves unrelated inactive keywords and annotations inert', + () { + final annotation = { + r'$id': _customDialect, + r'$schema': 'https://example.test/not-supplied', + r'$ref': 'https://example.test/not-supplied', + }; + final source = { + r'$schema': _customDialect, + 'type': 'string', + 'default': annotation, + 'contentSchema': annotation, + 'unevaluatedProperties': { + ..._dialect(), + r'$vocabulary': { + '${_vocabularyPrefix}core': true, + 'https://example.test/unsupported-vocabulary': true, + }, + }, + 'unevaluatedItems': {r'$id': _customDialect}, + 'allOf': [_dialect()], + }; + _expectStringRoundTrip(source); + final exported = Ack.fromJsonSchema(source).toJsonSchema(); + expect(exported['default'], annotation); + expect(exported['contentSchema'], annotation); + expect( + exported['unevaluatedProperties'], + source['unevaluatedProperties'], + ); + expect(exported['unevaluatedItems'], source['unevaluatedItems']); + }, + ); + + for (final container in ['default', 'examples', 'x-annotation']) { + test('does not discover dialect IDs inside $container data', () { + expect( + () => Ack.fromJsonSchema({ + r'$schema': _customDialect, + container: container == 'examples' ? [_dialect()] : _dialect(), + }), + throwsA(isA()), + ); + }); + } + + test('does not discover a dialect beneath a keyword it disables', () { + expect( + () => Ack.fromJsonSchema({ + r'$schema': _customDialect, + 'allOf': [_dialect(applicator: false)], + }), + throwsA(isA()), + ); + }); + + test('reports duplicate active bundled dialect identifiers', () { + expect( + () => Ack.fromJsonSchema({ + r'$schema': _customDialect, + 'allOf': [_dialect(), _dialect()], + }), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + contains('Duplicate schema identifier'), + ), + ), + ); + }); + + test( + 'still rejects unknown required vocabularies of discovered dialects', + () { + expect( + () => Ack.fromJsonSchema({ + r'$schema': _customDialect, + 'allOf': [ + { + ..._dialect(), + r'$vocabulary': { + '${_vocabularyPrefix}core': true, + '${_vocabularyPrefix}applicator': true, + 'https://example.test/unsupported-vocabulary': true, + }, + }, + ], + }), + throwsA( + isA().having( + (error) => error.toString(), + 'diagnostic', + contains('Unknown required vocabulary'), + ), + ), + ); + }, + ); +}