Add EngineOverrides WebIDL AST pre-pass to js_interop_gen (#593) Adds `EngineOverrides` (`js_interop_gen/lib/src/engine_overrides.dart`) as an isolated pre-translation WebIDL AST pass for reconciling `@webref/idl` against shipping Blink, Gecko, and WebKit WebIDL trees, with zero diff in `web/`: - Rewrite `interfaces` (`kind: "interface"` to restore `dictionary` nodes rewritten by `w3c/webref` curation patches back to read-only `interface` nodes), `typedefs`, `properties`, and `operations` (`returnType`, `maxParameters`, `parameters`) in `EngineOverrides.applyToAst` before `_Library.add` or `elements.dart` run. - Apply concrete-interface member overrides to shallow-cloned `interface mixin` member nodes during `Translator._combineInterfacelikes` (`EngineOverrides.applyToMixin`) while rejecting direct `interface mixin` targets in `validateTypes`. - Suppress interfaces marked `generate: false` in `Translator._shouldGenerateInterface` after `BrowserCompatData.shouldGenerateInterface`, validate override target types via `Translator.resolveIdlTypeName`, and fail loudly via `StateError` in `verifyAllUsed` when any override in `engine_overrides.json` becomes unmatched or redundant. - Read `revisions` from `engine_overrides.json` in `web_generator/bin/update_idl_bindings.dart` to render the pinned browser engine baseline table in `web_generator/README.md`, and document the override roll workflow.
diff --git a/js_interop_gen/lib/src/dart_main.dart b/js_interop_gen/lib/src/dart_main.dart index 7200872..9583f91 100644 --- a/js_interop_gen/lib/src/dart_main.dart +++ b/js_interop_gen/lib/src/dart_main.dart
@@ -13,6 +13,7 @@ import 'package:yaml/yaml.dart'; import 'config.dart'; +import 'engine_overrides.dart'; import 'generate_bindings.dart'; import 'interop_gen/parser.dart'; import 'interop_gen/transform.dart'; @@ -48,6 +49,7 @@ languageVersion: Version.parse(languageVersionString), idlJsonPath: argResult['idl-json'] as String?, bcdJsonPath: bcdJsonPath, + engineOverridesPath: argResult['engine-overrides'] as String?, ); } else if (argResult.wasParsed('declaration')) { final Config config; @@ -130,6 +132,7 @@ required Version languageVersion, String? idlJsonPath, required String bcdJsonPath, + String? engineOverridesPath, }) async { if (input == null) { // parse dom library as normal @@ -139,6 +142,8 @@ final dir = p.dirname(p.fromUri(url)); final renameMapPath = p.join(dir, 'web_rename_map.json'); + EngineOverrides.activePath = + engineOverridesPath ?? p.join(dir, 'engine_overrides.json'); final (bindings, renameMap) = await generateBindings( packageRoot, librarySubDir, @@ -164,6 +169,7 @@ final allInputFiles = expandGlobs(input.toList(), extension: '.idl'); // parse individual files ensureDirectoryExists(output); + EngineOverrides.activePath = engineOverridesPath; final bindings = await generateBindingsForFiles( { @@ -250,4 +256,8 @@ ..addOption( 'bcd-json', help: 'Path to the browser-compat-data data.json file', + ) + ..addOption( + 'engine-overrides', + help: 'Path to the engine_overrides.json file', );
diff --git a/js_interop_gen/lib/src/engine_overrides.dart b/js_interop_gen/lib/src/engine_overrides.dart new file mode 100644 index 0000000..6b73b32 --- /dev/null +++ b/js_interop_gen/lib/src/engine_overrides.dart
@@ -0,0 +1,583 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'js/filesystem_api.dart'; +import 'js/webidl_api.dart' as idl; +import 'type_aliases.dart'; + +@JS('Object.defineProperty') +external void _defineProperty( + JSObject target, + JSString prop, + JSObject descriptor, +); + +@JS('Object.getPrototypeOf') +external JSObject? _getPrototypeOf(JSObject target); + +@JS('Object.getOwnPropertyDescriptors') +external JSObject _getOwnPropertyDescriptors(JSObject target); + +@JS('Object.create') +external JSObject _objectCreate(JSObject? proto, JSObject descriptors); + +JSObject _cloneJsObject(JSObject obj) => + _objectCreate(_getPrototypeOf(obj), _getOwnPropertyDescriptors(obj)); + +idl.Node _cloneMemberForOverride(idl.Node member) { + final cloned = _cloneJsObject(member as JSObject) as idl.Node; + if (member.type == 'operation') { + final args = (member as idl.Operation).arguments; + final clonedArgs = <idl.Argument>[ + for (var i = 0; i < args.length; i++) + _cloneJsObject(args[i] as JSObject) as idl.Argument, + ].toJS; + _setJsProperty(cloned as JSObject, 'arguments', clonedArgs); + } + return cloned; +} + +void _setJsProperty(JSObject target, String prop, JSAny? value) { + final desc = JSObject() + ..setProperty('value'.toJS, value) + ..setProperty('writable'.toJS, true.toJS) + ..setProperty('configurable'.toJS, true.toJS) + ..setProperty('enumerable'.toJS, true.toJS); + _defineProperty(target, prop.toJS, desc); +} + +bool _rawIdlMatchesOverride( + idl.IDLType idlType, + EngineTypeOverride override, { + bool? rawRequired, +}) { + if (override.required != null && + rawRequired != null && + override.required != rawRequired) { + return false; + } + if (idlType.union || idlType.nullable != override.nullable) return false; + if (idlType.generic.isNotEmpty) { + if (idlType.generic != override.type || override.typeParameter == null) { + return false; + } + final types = (idlType.idlType as JSArray<idl.IDLType>).toDart; + return types.length == 1 && + _rawIdlMatchesOverride(types[0], override.typeParameter!); + } + if (override.typeParameter != null) return false; + final rawName = (idlType.idlType as JSString).toDart; + final rawAlias = idlOrBuiltinToJsTypeAliases[rawName] ?? rawName; + final overrideAlias = + idlOrBuiltinToJsTypeAliases[override.type] ?? override.type; + return rawAlias == overrideAlias; +} + +idl.IDLType _createIdlType(EngineTypeOverride override) { + final obj = JSObject(); + obj.setProperty('type'.toJS, ''.toJS); + obj.setProperty('extAttrs'.toJS, <JSObject>[].toJS); + obj.setProperty('union'.toJS, false.toJS); + obj.setProperty('nullable'.toJS, override.nullable.toJS); + if (override.typeParameter != null) { + obj.setProperty('generic'.toJS, override.type.toJS); + obj.setProperty( + 'idlType'.toJS, + <idl.IDLType>[_createIdlType(override.typeParameter!)].toJS, + ); + } else { + obj.setProperty('generic'.toJS, ''.toJS); + obj.setProperty('idlType'.toJS, override.type.toJS); + } + return obj as idl.IDLType; +} + +void _convertDictionaryToReadonlyInterface(idl.Interfacelike interfacelike) { + _setJsProperty(interfacelike as JSObject, 'type', 'interface'.toJS); + final members = interfacelike.members; + for (var m = 0; m < members.length; m++) { + final member = members[m]; + if (member.type == 'field') { + final memberObj = member as JSObject; + _setJsProperty(memberObj, 'type', 'attribute'.toJS); + _setJsProperty(memberObj, 'readonly', true.toJS); + _setJsProperty(memberObj, 'special', ''.toJS); + _setJsProperty(memberObj, 'inherit', false.toJS); + } + } +} + +void _validateTargetNotMixin( + Map<String, idl.Node> typeToDeclaration, + String key, + String targetName, +) { + if (typeToDeclaration[targetName]?.type == 'interface mixin') { + throw StateError( + 'Engine override "$key" cannot target interface mixin "$targetName"; ' + 'target the concrete interface that includes it instead.', + ); + } +} + +void _validateTypeOverride( + Map<String, idl.Node> typeToDeclaration, + String Function(String) resolveIdlTypeName, + EngineTypeOverride override, +) { + if (override.typeParameter != null) { + _validateTypeOverride( + typeToDeclaration, + resolveIdlTypeName, + override.typeParameter!, + ); + } + final type = resolveIdlTypeName(override.type); + final decl = typeToDeclaration[type]; + final alias = idlOrBuiltinToJsTypeAliases[type]; + if (decl == null && alias == null) { + throw StateError('Unknown IDL override type: $type'); + } +} + +/// Declarative overrides reconciled from the Blink (Chrome), Gecko (Firefox), +/// and WebKit (Safari) browser engine WebIDL trees where `>= 2` shipping +/// engines disagree with `@webref/idl`. +class EngineOverrides { + static String? activePath; + + final Map<String, EngineInterfaceOverride> interfaces; + final Map<String, EngineTypeOverride> typedefs; + final Map<String, EngineTypeOverride> properties; + final Map<String, EngineOperationOverride> operations; + + // Override maps remain intact across translation because entries can match + // multiple times (repeated type references, partial interfaces, overloads) + // and are validated after `applyToAst` in `validateTypes`. + final Set<String> _seenInterfacesInAst = {}; + final Set<String> _usedInterfaces = {}; + final Set<String> _usedTypedefs = {}; + final Set<String> _usedProperties = {}; + final Set<String> _usedOperations = {}; + final Set<String> _matchedSubKeys = {}; + final Set<String> _changedSubKeys = {}; + + EngineOverrides({ + required this.interfaces, + required this.typedefs, + required this.properties, + required this.operations, + }); + + static EngineOverrides? read(String? path) { + if (path == null || !fs.existsSync(path.toJS).toDart) return null; + final content = (fs.readFileSync( + path.toJS, + JSReadFileOptions(encoding: 'utf8'.toJS), + ) as JSString).toDart; + final json = jsonDecode(content) as Map<String, dynamic>; + return EngineOverrides.fromJson(json); + } + + factory EngineOverrides.fromJson(Map<String, dynamic> json) { + final interfacesJson = + (json['interfaces'] as Map?)?.cast<String, dynamic>() ?? const {}; + final typedefsJson = + (json['typedefs'] as Map?)?.cast<String, dynamic>() ?? const {}; + final propertiesJson = + (json['properties'] as Map?)?.cast<String, dynamic>() ?? const {}; + final operationsJson = + (json['operations'] as Map?)?.cast<String, dynamic>() ?? const {}; + + return EngineOverrides( + interfaces: { + for (final entry in interfacesJson.entries) + entry.key: EngineInterfaceOverride.fromJson( + entry.value as Map<String, dynamic>, + ), + }, + typedefs: { + for (final entry in typedefsJson.entries) + entry.key: EngineTypeOverride.fromJson( + entry.value as Map<String, dynamic>, + ), + }, + properties: { + for (final entry in propertiesJson.entries) + entry.key: EngineTypeOverride.fromJson( + entry.value as Map<String, dynamic>, + ), + }, + operations: { + for (final entry in operationsJson.entries) + entry.key: EngineOperationOverride.fromJson( + entry.value as Map<String, dynamic>, + ), + }, + ); + } + + /// Mutates the parsed `webidl2` [ast] nodes in place before translation. + void applyToAst(JSArray<idl.Node> ast) { + for (var i = 0; i < ast.length; i++) { + final node = ast[i]; + switch (node.type) { + case 'typedef': + _applyTypedefOverride(node as idl.Typedef); + case 'interface': + case 'dictionary': + case 'namespace': + _applyInterfacelikeOverride(node as idl.Interfacelike); + } + } + } + + void _applyTypedefOverride(idl.Typedef typedefNode) { + final name = typedefNode.name; + final override = typedefs[name]; + if (override == null) return; + _usedTypedefs.add(name); + final subKey = 'typedefs.$name'; + _matchedSubKeys.add(subKey); + if (!_rawIdlMatchesOverride(typedefNode.idlType, override)) { + _changedSubKeys.add(subKey); + _setJsProperty( + typedefNode as JSObject, + 'idlType', + _createIdlType(override), + ); + } + } + + void _applyInterfacelikeOverride(idl.Interfacelike interfacelike) { + final interfaceName = interfacelike.name; + final ifaceOverride = interfaces[interfaceName]; + if (!interfacelike.partial) { + _seenInterfacesInAst.add(interfaceName); + if (ifaceOverride != null && ifaceOverride.kind != null) { + _usedInterfaces.add(interfaceName); + final subKey = 'interfaces.$interfaceName'; + _matchedSubKeys.add(subKey); + if (interfacelike.type != ifaceOverride.kind) { + _changedSubKeys.add(subKey); + } + } + } + if (ifaceOverride?.kind == 'interface' && + interfacelike.type == 'dictionary') { + _convertDictionaryToReadonlyInterface(interfacelike); + } + _applyMemberOverrides(interfaceName, interfacelike.members); + } + + /// Applies any member overrides targeting [targetInterfaceName] to a shallow + /// clone of [mixin] so other interfaces including [mixin] stay untouched. + idl.Interfacelike applyToMixin( + String targetInterfaceName, + idl.Interfacelike mixin, + ) { + final members = mixin.members; + if (!_hasMemberOverridesFor(targetInterfaceName, members)) return mixin; + final clonedMixin = _cloneJsObject(mixin as JSObject) as idl.Interfacelike; + final clonedMembers = <idl.Node>[ + for (var m = 0; m < members.length; m++) + _cloneMemberForOverride(members[m]), + ].toJS; + _setJsProperty(clonedMixin as JSObject, 'members', clonedMembers); + _applyMemberOverrides(targetInterfaceName, clonedMembers); + return clonedMixin; + } + + bool _hasMemberOverridesFor(String interfaceName, JSArray<idl.Node> members) { + for (var m = 0; m < members.length; m++) { + final member = members[m]; + switch (member.type) { + case 'attribute': + case 'field': + if (properties.containsKey('$interfaceName.${member.name}')) { + return true; + } + case 'operation': + final memberName = (member as idl.Operation).name; + if (memberName.isNotEmpty && + operations.containsKey('$interfaceName.$memberName')) { + return true; + } + } + } + return false; + } + + void _applyMemberOverrides(String interfaceName, JSArray<idl.Node> members) { + for (var m = 0; m < members.length; m++) { + final member = members[m]; + switch (member.type) { + case 'attribute': + case 'field': + _applyPropertyOverride(interfaceName, member); + case 'operation': + case 'constructor': + _applyOperationOverride(interfaceName, member); + } + } + } + + void _applyPropertyOverride(String interfaceName, idl.Node member) { + final isField = member.type == 'field'; + final key = '$interfaceName.${member.name}'; + final propOverride = properties[key]; + if (propOverride == null) return; + _usedProperties.add(key); + final subKey = 'properties.$key'; + _matchedSubKeys.add(subKey); + final rawIdlType = isField + ? (member as idl.Field).idlType + : (member as idl.Attribute).idlType; + final rawRequired = isField ? (member as idl.Field).required : null; + if (!_rawIdlMatchesOverride( + rawIdlType, + propOverride, + rawRequired: rawRequired, + )) { + _changedSubKeys.add(subKey); + } + final memberObj = member as JSObject; + _setJsProperty(memberObj, 'idlType', _createIdlType(propOverride)); + if (isField && propOverride.required != null) { + _setJsProperty(memberObj, 'required', propOverride.required!.toJS); + } + } + + void _applyOperationOverride(String interfaceName, idl.Node member) { + final isConstructor = member.type == 'constructor'; + final memberName = isConstructor + ? 'constructor' + : (member as idl.Operation).name; + if (memberName.isEmpty) return; + final key = '$interfaceName.$memberName'; + final opOverride = operations[key]; + if (opOverride == null) return; + _usedOperations.add(key); + if (!isConstructor && opOverride.returnType != null) { + final operation = member as idl.Operation; + final subKey = 'operations.$key.returnType'; + _matchedSubKeys.add(subKey); + if (!_rawIdlMatchesOverride(operation.idlType, opOverride.returnType!)) { + _changedSubKeys.add(subKey); + } + _setJsProperty( + member as JSObject, + 'idlType', + _createIdlType(opOverride.returnType!), + ); + } + final arguments = isConstructor + ? (member as idl.Constructor).arguments + : (member as idl.Operation).arguments; + final maxParams = opOverride.maxParameters; + if (maxParams != null) { + final subKey = 'operations.$key'; + _matchedSubKeys.add(subKey); + if (arguments.length > maxParams) { + _changedSubKeys.add(subKey); + arguments.length = maxParams; + } + } + _applyParameterOverrides(key, arguments, opOverride.parameters); + } + + void _applyParameterOverrides( + String key, + JSArray<idl.Argument> arguments, + Map<int, EngineTypeOverride> parameters, + ) { + for (final paramEntry in parameters.entries) { + final paramIndex = paramEntry.key; + final paramOverride = paramEntry.value; + if (paramIndex >= arguments.length) continue; + final subKey = 'operations.$key.parameters.$paramIndex'; + _matchedSubKeys.add(subKey); + final arg = arguments[paramIndex]; + if (!_rawIdlMatchesOverride(arg.idlType, paramOverride)) { + _changedSubKeys.add(subKey); + } + final argObj = arg as JSObject; + _setJsProperty(argObj, 'idlType', _createIdlType(paramOverride)); + if (paramOverride.name != null) { + _setJsProperty(argObj, 'name', paramOverride.name!.toJS); + } + } + } + + bool? shouldGenerateInterface(String name) { + final override = interfaces[name]; + if (override == null) return null; + if (_seenInterfacesInAst.contains(name)) { + _usedInterfaces.add(name); + } + return override.generate; + } + + /// Validates that overrides never target an `interface mixin` directly and + /// that all IDL types referenced by overrides resolve via + /// [resolveIdlTypeName] to an entry in [typeToDeclaration] or + /// [idlOrBuiltinToJsTypeAliases]. + void validateTypes( + Map<String, idl.Node> typeToDeclaration, + String Function(String) resolveIdlTypeName, + ) { + for (final name in interfaces.keys) { + _validateTargetNotMixin(typeToDeclaration, 'interfaces.$name', name); + } + for (final override in typedefs.values) { + _validateTypeOverride(typeToDeclaration, resolveIdlTypeName, override); + } + for (final entry in properties.entries) { + _validateTargetNotMixin( + typeToDeclaration, + 'properties.${entry.key}', + entry.key.split('.').first, + ); + _validateTypeOverride(typeToDeclaration, resolveIdlTypeName, entry.value); + } + for (final entry in operations.entries) { + _validateTargetNotMixin( + typeToDeclaration, + 'operations.${entry.key}', + entry.key.split('.').first, + ); + final override = entry.value; + if (override.returnType != null) { + _validateTypeOverride( + typeToDeclaration, + resolveIdlTypeName, + override.returnType!, + ); + } + for (final param in override.parameters.values) { + _validateTypeOverride(typeToDeclaration, resolveIdlTypeName, param); + } + } + } + + /// Verifies that every override in `engine_overrides.json` matched an active + /// IDL declaration during `package:web` generation and is not redundant. + void verifyAllUsed() { + final unused = <String>[ + ...interfaces.keys + .toSet() + .difference(_usedInterfaces) + .map((k) => 'interfaces.$k'), + ...typedefs.keys + .toSet() + .difference(_usedTypedefs) + .map((k) => 'typedefs.$k'), + ...properties.keys + .toSet() + .difference(_usedProperties) + .map((k) => 'properties.$k'), + ...operations.keys + .toSet() + .difference(_usedOperations) + .map((k) => 'operations.$k'), + for (final key in _usedOperations) + ...operations[key]!.parameters.keys + .map((i) => 'operations.$key.parameters.$i') + .toSet() + .difference(_matchedSubKeys), + ]; + if (unused.isNotEmpty) { + throw StateError( + 'Stale engine overrides in engine_overrides.json (not matched during ' + 'IDL translation):\n - ${unused.join('\n - ')}', + ); + } + final redundant = _matchedSubKeys.difference(_changedSubKeys).toList() + ..sort(); + if (redundant.isNotEmpty) { + throw StateError( + 'Redundant engine overrides in engine_overrides.json (@webref/idl now ' + 'natively matches the override; remove from engine_overrides.json):\n' + ' - ${redundant.join('\n - ')}', + ); + } + } +} + +class EngineInterfaceOverride { + final bool generate; + final String? kind; + + EngineInterfaceOverride({required this.generate, this.kind}); + + factory EngineInterfaceOverride.fromJson(Map<String, dynamic> json) => + EngineInterfaceOverride( + generate: json['generate'] as bool? ?? true, + kind: json['kind'] as String?, + ); +} + +class EngineTypeOverride { + final String type; + final bool nullable; + final EngineTypeOverride? typeParameter; + final String? name; + final bool? required; + + EngineTypeOverride({ + required this.type, + required this.nullable, + this.typeParameter, + this.name, + this.required, + }); + + factory EngineTypeOverride.fromJson(Map<String, dynamic> json) => + EngineTypeOverride( + type: json['type'] as String, + nullable: json['nullable'] as bool? ?? false, + typeParameter: json['typeParameter'] != null + ? EngineTypeOverride.fromJson( + json['typeParameter'] as Map<String, dynamic>, + ) + : null, + name: json['name'] as String?, + required: json['required'] as bool?, + ); +} + +class EngineOperationOverride { + final EngineTypeOverride? returnType; + final Map<int, EngineTypeOverride> parameters; + final int? maxParameters; + + EngineOperationOverride({ + this.returnType, + this.parameters = const {}, + this.maxParameters, + }); + + factory EngineOperationOverride.fromJson(Map<String, dynamic> json) { + final paramsJson = + (json['parameters'] as Map?)?.cast<String, dynamic>() ?? const {}; + return EngineOperationOverride( + returnType: json['returnType'] != null + ? EngineTypeOverride.fromJson( + json['returnType'] as Map<String, dynamic>, + ) + : null, + parameters: { + for (final entry in paramsJson.entries) + int.parse(entry.key): EngineTypeOverride.fromJson( + entry.value as Map<String, dynamic>, + ), + }, + maxParameters: json['maxParameters'] as int?, + ); + } +}
diff --git a/js_interop_gen/lib/src/engine_overrides.json b/js_interop_gen/lib/src/engine_overrides.json new file mode 100644 index 0000000..fc6aab5 --- /dev/null +++ b/js_interop_gen/lib/src/engine_overrides.json
@@ -0,0 +1,7 @@ +{ + "revisions": {}, + "interfaces": {}, + "typedefs": {}, + "properties": {}, + "operations": {} +}
diff --git a/js_interop_gen/lib/src/translator.dart b/js_interop_gen/lib/src/translator.dart index 6b28bdd..f3c6e56 100644 --- a/js_interop_gen/lib/src/translator.dart +++ b/js_interop_gen/lib/src/translator.dart
@@ -11,6 +11,7 @@ import 'bcd.dart'; import 'doc_provider.dart'; import 'elements.dart'; +import 'engine_overrides.dart'; import 'formatting.dart'; import 'js/webidl_api.dart' as idl; import 'js/webref_elements_api.dart'; @@ -167,6 +168,7 @@ late String _currentlyTranslatingUrl; late DocProvider docProvider; late BrowserCompatData browserCompatData; + late final EngineOverrides? engineOverrides; /// Singleton so that various helper methods can access info about the AST. static Translator? instance; @@ -187,8 +189,13 @@ generateAll: generateAll, path: bcdJsonPath, ); + engineOverrides = EngineOverrides.read(EngineOverrides.activePath); } + bool _shouldGenerateInterface(String name) => + browserCompatData.shouldGenerateInterface(name) && + (engineOverrides?.shouldGenerateInterface(name) ?? true); + void _addOrUpdateInterfaceLike(idl.Interfacelike interfacelike) { final name = interfacelike.name; if (_interfacelikes.containsKey(name)) { @@ -214,6 +221,7 @@ /// [markTypeAsUsed] because they don't have any compat data and are emitted /// only if used. void addInterfacesAndNamespaces() { + engineOverrides?.validateTypes(_typeToDeclaration, resolveIdlTypeName); for (final library in _libraries.values) { for (final interfacelike in library.interfacelikes) { final name = interfacelike.name; @@ -256,7 +264,10 @@ _typeToDeclaration[mixin] as idl.Interfacelike, ...?_typeToPartials[mixin], ]) { - _interfacelikes[interfacelikeName]!.update(interfacelike); + _interfacelikes[interfacelikeName]!.update( + engineOverrides?.applyToMixin(interfacelikeName, interfacelike) ?? + interfacelike, + ); } } } @@ -297,7 +308,7 @@ // Interfaces and namespaces can only be marked as used depending on // their compat data. final name = (decl as idl.Interfacelike).name; - if (browserCompatData.shouldGenerateInterface(name)) { + if (_shouldGenerateInterface(name)) { _usedTypes.add(decl); _combineInterfacelikes(name); return true; @@ -309,7 +320,7 @@ // https://github.com/mdn/browser-compat-data/blob/main/docs/data-guidelines/api.md#namespaces final namespace = decl as idl.Interfacelike; final name = namespace.name; - if (browserCompatData.shouldGenerateInterface(name) || + if (_shouldGenerateInterface(name) || namespace.members.toDart.every( (member) => member.type == 'const', )) { @@ -331,6 +342,8 @@ final libraryPath = '$_librarySubDir/${shortName.kebabToSnake}.dart'; assert(!_libraries.containsKey(libraryPath)); + engineOverrides?.applyToAst(ast); + final library = _Library(shortName, '${packageRoot ?? '.'}/$libraryPath'); for (var i = 0; i < ast.length; i++) { @@ -1289,7 +1302,12 @@ } if (_generateForWeb) { + if (!browserCompatData.generateAll) { + engineOverrides?.verifyAllUsed(); + } dartLibraries['dom.dart'] = generateRootImport(dartLibraries.keys); + } else { + engineOverrides?.verifyAllUsed(); } return dartLibraries;
diff --git a/js_interop_gen/test/engine_overrides_test.dart b/js_interop_gen/test/engine_overrides_test.dart new file mode 100644 index 0000000..270781d --- /dev/null +++ b/js_interop_gen/test/engine_overrides_test.dart
@@ -0,0 +1,331 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:js_interop_gen/src/cli.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'test_shared.dart'; + +void main() { + group('EngineOverrides AST pre-pass', () { + late Directory tempDir; + late String idlPath; + late String bcdPath; + + const sampleIdl = ''' +interface DroppedInterface { + attribute DOMString ignored; +}; + +interface mixin SharedScrollMixin { + readonly attribute long mixinOffset; + Promise<undefined> mixinAction(long step); +}; + +dictionary ReportBodyDict { + USVString documentURL; + unsigned short statusCode; +}; + +typedef (unsigned long or bigint) AddressValue; + +dictionary SampleInit { + long clientX = 0; + unsigned long? length; +}; + +interface SampleElement { + readonly attribute DroppedInterface? dropped; + readonly attribute long clientX; + attribute AddressValue address; + Promise<undefined> scroll(); + Promise<undefined> scroll(long x, optional boolean extra); + Promise<undefined> generateKeyFrame(); +}; +SampleElement includes SharedScrollMixin; + +interface MixinPeer { +}; +MixinPeer includes SharedScrollMixin; +'''; + + setUpAll(() async { + await compileBindingsGen(); + tempDir = await Directory.systemTemp.createTemp('engine_overrides_test_'); + idlPath = p.join(tempDir.path, 'sample.idl'); + bcdPath = p.join(tempDir.path, 'dummy_bcd.json'); + await File(idlPath).writeAsString(sampleIdl); + await File(bcdPath) + .writeAsString('{"api": {}, "webassembly": {"api": {}}}'); + }); + + tearDownAll(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test( + 'applies interface, typedef, property, and operation overrides', + () async { + final overridesPath = p.join(tempDir.path, 'valid_overrides.json'); + await File(overridesPath).writeAsString( + jsonEncode({ + 'interfaces': { + 'DroppedInterface': { + 'generate': false, + 'reason': 'Not shipped in >=2 engines.', + }, + 'ReportBodyDict': { + 'generate': true, + 'kind': 'interface', + 'reason': 'Shipped as read-only interface in all 3 engines.', + }, + }, + 'typedefs': { + 'AddressValue': {'type': 'unsigned long', 'nullable': false}, + }, + 'properties': { + 'SampleInit.clientX': {'type': 'double', 'nullable': false}, + 'SampleInit.length': { + 'type': 'unsigned long', + 'nullable': false, + 'required': true, + }, + 'SampleElement.clientX': {'type': 'double', 'nullable': false}, + 'SampleElement.mixinOffset': { + 'type': 'double', + 'nullable': false, + }, + }, + 'operations': { + 'SampleElement.scroll': { + 'returnType': {'type': 'undefined', 'nullable': false}, + 'maxParameters': 1, + 'parameters': { + '0': {'name': 'coord', 'type': 'double', 'nullable': false}, + }, + }, + 'SampleElement.mixinAction': { + 'returnType': {'type': 'undefined', 'nullable': false}, + 'parameters': { + '0': {'type': 'double', 'nullable': false}, + }, + }, + 'SampleElement.generateKeyFrame': { + 'returnType': { + 'type': 'Promise', + 'nullable': false, + 'typeParameter': { + 'type': 'unsigned long long', + 'nullable': false, + }, + }, + }, + }, + }), + ); + + final outDir = p.join(tempDir.path, 'out_valid'); + await Directory(outDir).create(recursive: true); + + final relOverrides = p.relative(overridesPath, from: bindingsGenPath); + await runNode([ + 'main.mjs', + '--idl', + '--input=${p.relative(idlPath, from: bindingsGenPath)}', + '--output=${p.relative(outDir, from: bindingsGenPath)}', + '--bcd-json=${p.relative(bcdPath, from: bindingsGenPath)}', + '--engine-overrides=$relOverrides', + ], workingDirectory: bindingsGenPath); + + final generated = await File(p.join(outDir, 'sample.dart')) + .readAsString(); + expect(generated, isNot(contains('DroppedInterface'))); + expect(generated, contains('external JSObject? get dropped;')); + expect( + generated, + contains('extension type ReportBodyDict._(JSObject _)'), + ); + expect(generated, contains('external String get documentURL;')); + expect(generated, contains('external int get statusCode;')); + expect(generated, isNot(contains('set documentURL'))); + expect(generated, isNot(contains('factory ReportBodyDict'))); + expect(generated, contains('typedef AddressValue = int;')); + expect( + generated, + contains( + 'external factory SampleInit({num clientX, required int length});', + ), + ); + expect(generated, contains('external double get clientX;')); + expect(generated, contains('external void scroll([num coord]);')); + expect( + generated, + contains('external JSPromise<JSNumber> generateKeyFrame();'), + ); + expect(generated, contains('external double get mixinOffset;')); + expect(generated, contains('external void mixinAction(num step);')); + // MixinPeer includes the same SharedScrollMixin without overrides, so + // its mixed-in members must retain their original WebIDL signatures. + expect(generated, contains('external int get mixinOffset;')); + expect( + generated, + contains('external JSPromise<JSAny?> mixinAction(int step);'), + ); + }, + ); + + test( + 'throws StateError when override targets an interface mixin', + () async { + final overridesPath = p.join(tempDir.path, 'mixin_overrides.json'); + await File(overridesPath).writeAsString( + jsonEncode({ + 'properties': { + 'SharedScrollMixin.mixinOffset': { + 'type': 'double', + 'nullable': false, + }, + }, + }), + ); + + final outDir = p.join(tempDir.path, 'out_mixin_target'); + await Directory(outDir).create(recursive: true); + + final relOverrides = p.relative(overridesPath, from: bindingsGenPath); + final process = await runNodeWithResult([ + 'main.mjs', + '--idl', + '--input=${p.relative(idlPath, from: bindingsGenPath)}', + '--output=${p.relative(outDir, from: bindingsGenPath)}', + '--bcd-json=${p.relative(bcdPath, from: bindingsGenPath)}', + '--engine-overrides=$relOverrides', + ], workingDirectory: bindingsGenPath); + + final stderr = (await process.stderr.transform(utf8.decoder).toList()) + .join(); + expect(await process.exitCode, isNot(0)); + expect( + stderr, + contains('cannot target interface mixin "SharedScrollMixin"'), + ); + }, + ); + + test('throws StateError on stale override', () async { + final overridesPath = p.join(tempDir.path, 'stale_overrides.json'); + await File(overridesPath).writeAsString( + jsonEncode({ + 'properties': { + 'SampleElement.nonExistent': {'type': 'double', 'nullable': false}, + }, + }), + ); + + final outDir = p.join(tempDir.path, 'out_stale'); + await Directory(outDir).create(recursive: true); + + final relOverrides = p.relative(overridesPath, from: bindingsGenPath); + final process = await runNodeWithResult([ + 'main.mjs', + '--idl', + '--input=${p.relative(idlPath, from: bindingsGenPath)}', + '--output=${p.relative(outDir, from: bindingsGenPath)}', + '--bcd-json=${p.relative(bcdPath, from: bindingsGenPath)}', + '--engine-overrides=$relOverrides', + ], workingDirectory: bindingsGenPath); + + final stderr = (await process.stderr.transform(utf8.decoder).toList()) + .join(); + expect(await process.exitCode, isNot(0)); + expect( + stderr, + contains('Stale engine overrides in engine_overrides.json'), + ); + expect(stderr, contains('properties.SampleElement.nonExistent')); + }); + + test('throws StateError on redundant override', () async { + final overridesPath = p.join(tempDir.path, 'redundant_overrides.json'); + await File(overridesPath).writeAsString( + jsonEncode({ + 'interfaces': { + 'SampleElement': {'generate': true, 'kind': 'interface'}, + }, + 'properties': { + 'SampleElement.clientX': {'type': 'long', 'nullable': false}, + }, + 'operations': { + 'SampleElement.generateKeyFrame': {'maxParameters': 0}, + }, + }), + ); + + final outDir = p.join(tempDir.path, 'out_redundant'); + await Directory(outDir).create(recursive: true); + + final relOverrides = p.relative(overridesPath, from: bindingsGenPath); + final process = await runNodeWithResult([ + 'main.mjs', + '--idl', + '--input=${p.relative(idlPath, from: bindingsGenPath)}', + '--output=${p.relative(outDir, from: bindingsGenPath)}', + '--bcd-json=${p.relative(bcdPath, from: bindingsGenPath)}', + '--engine-overrides=$relOverrides', + ], workingDirectory: bindingsGenPath); + + final stderr = (await process.stderr.transform(utf8.decoder).toList()) + .join(); + expect(await process.exitCode, isNot(0)); + expect( + stderr, + contains('Redundant engine overrides in engine_overrides.json'), + ); + expect(stderr, contains('interfaces.SampleElement')); + expect(stderr, contains('properties.SampleElement.clientX')); + expect(stderr, contains('operations.SampleElement.generateKeyFrame')); + }); + + test('throws StateError on unknown override type', () async { + final overridesPath = p.join(tempDir.path, 'unknown_type_overrides.json'); + await File(overridesPath).writeAsString( + jsonEncode({ + 'properties': { + 'SampleElement.clientX': { + 'type': 'UnknownIdlType', + 'nullable': false, + }, + }, + }), + ); + + final outDir = p.join(tempDir.path, 'out_unknown_type'); + await Directory(outDir).create(recursive: true); + + final relOverrides = p.relative(overridesPath, from: bindingsGenPath); + final process = await runNodeWithResult([ + 'main.mjs', + '--idl', + '--input=${p.relative(idlPath, from: bindingsGenPath)}', + '--output=${p.relative(outDir, from: bindingsGenPath)}', + '--bcd-json=${p.relative(bcdPath, from: bindingsGenPath)}', + '--engine-overrides=$relOverrides', + ], workingDirectory: bindingsGenPath); + + final stderr = (await process.stderr.transform(utf8.decoder).toList()) + .join(); + expect(await process.exitCode, isNot(0)); + expect(stderr, contains('Unknown IDL override type: UnknownIdlType')); + }); + }); +}
diff --git a/web_generator/README.md b/web_generator/README.md index 199315f..4818c8d 100644 --- a/web_generator/README.md +++ b/web_generator/README.md
@@ -28,6 +28,23 @@ Alternatively, manually edit `package.json` to use specific versions, and re-run `update_idl_bindings.dart`. +### Browser engine overrides (`engine_overrides.json`) + +`@webref/idl` tracks latest Editor's Drafts and applies curation patches that +occasionally diverge from what shipping browser engines (Blink, Gecko, WebKit) +implement. `js_interop_gen/lib/src/engine_overrides.json` pins targeted AST and +interface-visibility overrides before translation: + +1. **Self-pruning on roll**: `update_idl_bindings.dart` fails with a + `StateError` if any override in `engine_overrides.json` is unused or already + matches `@webref/idl` (having become a no-op). Delete those entries and + re-run. +2. **Reviewing `web/tool/api.txt`**: After rolling `@webref/idl` or + `@mdn/browser-compat-data`, inspect `git diff web/tool/api.txt` for + unintended signature widenings (e.g., concrete types widened to `JSAny`, + `void` changed to `JSPromise` before engines ship it, or single-engine + interfaces enabled in BCD) and add an entry with a `"reason"` if needed. + ### Public API golden `web/tool/api.txt` is a [`package:api_summary`](https://pub.dev/packages/api_summary)
diff --git a/web_generator/bin/update_idl_bindings.dart b/web_generator/bin/update_idl_bindings.dart index edda04b..76117c6 100644 --- a/web_generator/bin/update_idl_bindings.dart +++ b/web_generator/bin/update_idl_bindings.dart
@@ -170,6 +170,7 @@ final cssVersion = _packageLockVersion(_webRefCss); final elementsVersion = _packageLockVersion(_webRefElements); final idlVersion = _packageLockVersion(_webRefIdl); + final engineSection = _engineBaselineSection(); final versions = ''' $_startComment @@ -179,7 +180,7 @@ | `$_webRefCss` | [$cssVersion](https://www.npmjs.com/package/$_webRefCss/v/$cssVersion) | | `$_webRefElements` | [$elementsVersion](https://www.npmjs.com/package/$_webRefElements/v/$elementsVersion) | | `$_webRefIdl` | [$idlVersion](https://www.npmjs.com/package/$_webRefIdl/v/$idlVersion) | -'''; +$engineSection'''; final newContent = sourceContent.substring(0, sourceContent.indexOf(_startComment)) + @@ -215,6 +216,40 @@ return webRefIdl['version'] as String; } +String _engineBaselineSection() { + final overridesFile = File( + p.join(bindingsGeneratorPath, 'engine_overrides.json'), + ); + final data = + jsonDecode(overridesFile.readAsStringSync()) as Map<String, dynamic>; + final revisions = data['revisions'] as Map<String, dynamic>?; + if (revisions == null || revisions.isEmpty) return ''; + const engineLabels = {'blink': 'Blink', 'gecko': 'Gecko', 'webkit': 'WebKit'}; + final buffer = StringBuffer() + ..writeln() + ..writeln( + 'Browser engine WebIDL baselines ' + '(`js_interop_gen/lib/src/engine_overrides.json`):', + ) + ..writeln() + ..writeln('| Engine | Browser | Ref | Commit |') + ..writeln('| --- | --- | --- | --- |'); + for (final entry in engineLabels.entries) { + final rev = revisions[entry.key] as Map<String, dynamic>; + final browser = rev['browser'] as String; + final version = rev['version'] as String; + final repo = rev['repo'] as String; + final ref = rev['ref'] as String; + final sha = rev['sha'] as String; + final shortSha = sha.substring(0, 10); + buffer.writeln( + '| ${entry.value} | $browser $version | `$ref` | ' + '[`$shortSha`](https://github.com/$repo/commit/$sha) |', + ); + } + return buffer.toString(); +} + const _browserCompatData = '@mdn/browser-compat-data'; const _webRefCss = '@webref/css'; const _webRefElements = '@webref/elements';