Enforce the platform UI seam with a shrinking migration allow-list

This commit is contained in:
cogwheel 2026-10-07 23:44:42 +05:30
parent 076ceae571
commit 66bb45eb1c
6 changed files with 3563 additions and 1 deletions

View file

@ -24,6 +24,7 @@ jobs:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
fetch-depth: 0
# Building needs only the path dependencies under third_party/;
# openwebui-src/ and hermes-src/ are reference sources, a few hundred
# MB that no step reads. They fetch while Flutter installs.
@ -54,6 +55,8 @@ jobs:
echo "::endgroup::"
fi
done
- name: Enforce platform UI seam
run: dart tool/platform_ui_lint.dart --base-ref "${{ github.event.pull_request.base.sha || github.event.before }}"
# Analysis and the Dart tests each need only the generated code, so
# they run side by side; the job fails once both finish if either
# failed. The analyzer skips the test runner's temporary

View file

@ -18,7 +18,7 @@ packages:
source: hosted
version: "0.3.18"
analyzer:
dependency: transitive
dependency: "direct dev"
description:
name: analyzer
sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e"

View file

@ -157,6 +157,7 @@ dependency_overrides:
objective_c: 9.6.0
dev_dependencies:
analyzer: ^13.3.0
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0

View file

@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import '../../tool/platform_ui_lint.dart';
void main() {
test('resolved controls, conditional imports and feature barrels cannot bypass the seam', () async {
final root = Directory.systemTemp.createTempSync('platform-ui-lint-');
addTearDown(() => root.deleteSync(recursive: true));
void write(String path, String source) {
File('${root.path}/$path')
..parent.createSync(recursive: true)
..writeAsStringSync(source);
}
write('pubspec.yaml', 'name: conduit\nenvironment:\n sdk: ^3.13.0\n');
write(
'.dart_tool/package_config.json',
jsonEncode({
'configVersion': 2,
'packages': [
{
'name': 'conduit',
'rootUri': '../',
'packageUri': 'lib/',
'languageVersion': '3.13',
},
{
'name': 'material_ui',
'rootUri': '../material/',
'packageUri': 'lib/',
'languageVersion': '3.13',
},
],
}),
);
write(
'material/lib/material_ui.dart',
'class ElevatedButton {}\nclass Theme {}\n',
);
write(
'lib/shared/widgets/platform_ui/vocabulary.dart',
"export 'package:material_ui/material_ui.dart' show Theme, ElevatedButton;\n",
);
write(
'lib/use_seam.dart',
"import 'shared/widgets/platform_ui/vocabulary.dart' as ui;\n"
'ui.Theme? theme;\nui.ElevatedButton? button;\n'
'typedef ButtonAlias = ui.ElevatedButton;\n'
'ButtonAlias? alias;\nfinal constructed = ui.ElevatedButton();\n',
);
write(
'lib/direct.dart',
"import 'package:material_ui/material_ui.dart' as m show Theme;\n"
"// import 'package:cupertino_ui/cupertino_ui.dart';\n",
);
write(
'lib/barrel.dart',
"export 'package:material_ui/material_ui.dart' hide ElevatedButton;\n",
);
write('lib/indirect.dart', "export 'barrel.dart';\n");
write(
'lib/conditional.dart',
"import 'shared/widgets/platform_ui/vocabulary.dart'\n"
" if (dart.library.io) 'package:flutter/cupertino.dart';\n",
);
write('lib/generated.g.dart', "import 'package:flutter/material.dart';\n");
final result = await Process.run('dart', [
File('tool/platform_ui_lint.dart').absolute.path,
'--root',
root.path,
'--inventory',
]);
expect(result.exitCode, 0, reason: result.stderr.toString());
final counts = jsonDecode(result.stdout as String);
expect(counts, {
'lib/barrel.dart|package|package:material_ui/material_ui.dart': 1,
'lib/conditional.dart|package|package:flutter/cupertino.dart': 1,
'lib/direct.dart|package|package:material_ui/material_ui.dart': 1,
'lib/indirect.dart|package|barrel:barrel.dart': 1,
'lib/use_seam.dart|symbol|ElevatedButton': 4,
});
}, timeout: const Timeout(Duration(minutes: 3)));
test('exceptions must match exact live counts and carry ownership', () {
const source = '''
version: 1
exceptions:
- path: lib/a.dart
symbol: Slider
count: 2
owner: settings
reason: Existing slider awaiting migration
removal: platform-ui-migration
''';
expect(checkAllowlist({'lib/a.dart|symbol|Slider': 2}, source), isEmpty);
expect(
checkAllowlistGrowth(
source,
source.replaceAll('count: 2', 'count: 3'),
).single,
startsWith('Expanded exception:'),
);
expect(
checkAllowlistGrowth(source, source.replaceAll('count: 2', 'count: 1')),
isEmpty,
);
expect(
checkAllowlist({'lib/a.dart|symbol|Slider': 3}, source).single,
startsWith('Expanded violation:'),
);
expect(checkAllowlist({}, source).single, startsWith('Stale exception:'));
expect(
checkAllowlist({
'lib/b.dart|symbol|Switch': 1,
}, 'version: 1\nexceptions: []\n').single,
startsWith('New violation:'),
);
expect(
checkAllowlist({}, source.replaceAll('lib/a.dart', 'lib/*.dart')).single,
startsWith('Invalid exception:'),
);
for (final field in ['owner', 'removal']) {
final value = field == 'owner' ? 'settings' : 'platform-ui-migration';
for (final invalid in ['', 'Settings', 'settings/UI']) {
expect(
checkAllowlist(
{},
source.replaceAll('$field: $value', '$field: $invalid'),
).single,
startsWith('Invalid exception:'),
);
}
}
expect(
checkAllowlist({
'lib/a.dart|symbol|Slider': 2,
}, '$source${source.substring(source.indexOf(' - path:'))}').single,
startsWith('Duplicate exception:'),
);
});
}

File diff suppressed because it is too large Load diff

392
tool/platform_ui_lint.dart Normal file
View file

@ -0,0 +1,392 @@
// Enforces platform UI imports and control use outside the shared facade.
import 'dart:convert';
import 'dart:io';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
const _seam = 'lib/shared/widgets/platform_ui/';
const _widgetPackages = {'material_ui', 'cupertino_ui'};
const _controls = {
'ElevatedButton',
'OutlinedButton',
'FilledButton',
'TextButton',
'IconButton',
'FloatingActionButton',
'Switch',
'SwitchListTile',
'Checkbox',
'CheckboxListTile',
'Slider',
'ActionChip',
'ChoiceChip',
'FilterChip',
'InputChip',
'Chip',
'SegmentedButton',
'ButtonSegment',
'CupertinoButton',
'CupertinoSwitch',
'CupertinoSlider',
'CupertinoSlidingSegmentedControl',
'CupertinoSegmentedControl',
'CircularProgressIndicator',
'LinearProgressIndicator',
'CupertinoActivityIndicator',
'Card',
'ExpansionTile',
'NavigationBar',
'NavigationDestination',
'Dialog',
'AlertDialog',
'AppBar',
'SimpleDialog',
'SimpleDialogOption',
'DropdownButton',
'DropdownButtonFormField',
'DropdownMenuItem',
'PopupMenuButton',
'PopupMenuDivider',
'PopupMenuEntry',
'PopupMenuItem',
'PopupMenuItemBuilder',
'PopupMenuItemSelected',
'showMenu',
};
bool _widgetUri(String uri) {
final parsed = Uri.tryParse(uri);
if (parsed == null ||
parsed.scheme != 'package' ||
parsed.pathSegments.isEmpty) {
return false;
}
final package = parsed.pathSegments.first;
return _widgetPackages.contains(package) ||
(package == 'flutter' &&
(parsed.path == 'flutter/material.dart' ||
parsed.path == 'flutter/cupertino.dart' ||
parsed.path.startsWith('flutter/src/material/') ||
parsed.path.startsWith('flutter/src/cupertino/')));
}
// These generators own their imports. Authored files cannot opt out by comment.
bool _generated(String path) =>
path.endsWith('.g.dart') ||
path.endsWith('.freezed.dart') ||
RegExp(r'^lib/l10n/app_localizations(?:_[a-z_]+)?\.dart$').hasMatch(path);
Future<void> main(List<String> args) async {
var root = Directory.current.path;
var allowlist = 'tool/platform_ui_allowlist.yaml';
var inventory = false;
String? baseRef;
for (var i = 0; i < args.length; i++) {
switch (args[i]) {
case '--root':
root = args[++i];
case '--allowlist':
allowlist = args[++i];
case '--inventory':
inventory = true;
case '--base-ref':
baseRef = args[++i];
default:
throw ArgumentError('Unknown argument: ${args[i]}');
}
}
root = p.normalize(p.absolute(root));
final counts = await inventoryViolations(root);
if (inventory) {
stdout.writeln(const JsonEncoder.withIndent(' ').convert(counts));
return;
}
final file = File(p.join(root, allowlist));
if (!file.existsSync()) {
stderr.writeln('Missing allow-list: ${file.path}');
exitCode = 1;
return;
}
final errors = checkAllowlist(counts, file.readAsStringSync());
if (baseRef == null &&
FileSystemEntity.typeSync(p.join(root, '.git')) !=
FileSystemEntityType.notFound) {
baseRef = 'HEAD';
}
if (baseRef != null) {
final previous = Process.runSync('git', [
'show',
'$baseRef:$allowlist',
], workingDirectory: root);
if (previous.exitCode == 0) {
errors.addAll(
checkAllowlistGrowth(
previous.stdout as String,
file.readAsStringSync(),
),
);
} else {
// Only initial introduction may lack the file. An invalid ref fails.
final validRef = Process.runSync('git', [
'rev-parse',
'--verify',
baseRef,
], workingDirectory: root);
if (validRef.exitCode != 0) errors.add('Invalid baseline ref: $baseRef');
}
}
for (final error in errors) {
stderr.writeln(error);
}
stdout.writeln(
'platform_ui: ${counts.length} violations, '
'${counts.values.fold(0, (a, b) => a + b)} occurrences; '
'${errors.length} errors',
);
if (errors.isNotEmpty) exitCode = 1;
}
Future<Map<String, int>> inventoryViolations(String root) async {
final counts = <String, int>{};
final files =
Directory(p.join(root, 'lib'))
.listSync(recursive: true, followLinks: false)
.whereType<File>()
.where((file) {
final path = p.relative(file.path, from: root);
return path.endsWith('.dart') &&
!path.startsWith(_seam) &&
!_generated(path);
})
.toList()
..sort((a, b) => a.path.compareTo(b.path));
final collection = AnalysisContextCollection(
includedPaths: [p.join(root, 'lib')],
);
final units = <String, CompilationUnit>{};
Set<String> widgetExports(String path, Set<String> visiting) {
if (p.relative(path, from: root).startsWith(_seam)) return {};
if (!visiting.add(path) || !File(path).existsSync()) return {};
final unit = units.putIfAbsent(
path,
() => parseString(content: File(path).readAsStringSync()).unit,
);
final result = <String>{};
for (final directive in unit.directives.whereType<ExportDirective>()) {
for (final uri in [
directive.uri.stringValue,
...directive.configurations.map((c) => c.uri.stringValue),
]) {
if (uri == null) continue;
if (_widgetUri(uri)) {
result.add(uri);
continue;
}
final target = _localPath(uri, path, root);
if (target != null) result.addAll(widgetExports(target, visiting));
}
}
visiting.remove(path);
return result;
}
try {
for (final file in files) {
final path = p.relative(file.path, from: root);
void record(String kind, String value) {
final key = '$path|$kind|$value';
counts.update(key, (n) => n + 1, ifAbsent: () => 1);
}
final parsed = parseString(
content: file.readAsStringSync(),
path: file.path,
);
if (parsed.errors.isNotEmpty) {
throw FormatException('Cannot lint invalid Dart: $path');
}
for (final directive
in parsed.unit.directives.whereType<UriBasedDirective>()) {
if (directive is! ImportDirective && directive is! ExportDirective) {
continue;
}
final uris = [
directive.uri.stringValue,
if (directive is NamespaceDirective)
...directive.configurations.map((c) => c.uri.stringValue),
];
for (final uri in uris.toSet()) {
if (uri == null) continue;
if (_widgetUri(uri)) {
record('package', uri);
continue;
}
final target = _localPath(uri, file.path, root);
if (target != null && widgetExports(target, {}).isNotEmpty) {
record('package', 'barrel:$uri');
}
}
}
final resolved = await collection
.contextFor(file.path)
.currentSession
.getResolvedUnit(file.path);
if (resolved is! ResolvedUnitResult) {
throw StateError('Cannot resolve $path: $resolved');
}
resolved.unit.accept(_ControlVisitor((name) => record('symbol', name)));
}
} finally {
await collection.dispose();
}
return Map.fromEntries(
counts.entries.toList()..sort((a, b) => a.key.compareTo(b.key)),
);
}
String? _localPath(String uri, String source, String root) {
if (uri.startsWith('package:conduit/')) {
final path = p.normalize(
p.join(root, 'lib', uri.substring('package:conduit/'.length)),
);
return p.isWithin(p.join(root, 'lib'), path) ? path : null;
}
if (Uri.parse(uri).hasScheme) return null;
final path = p.normalize(p.join(p.dirname(source), uri));
return p.isWithin(p.join(root, 'lib'), path) ? path : null;
}
class _ControlVisitor extends RecursiveAstVisitor<void> {
_ControlVisitor(this.record);
final void Function(String) record;
// Directive names are already accounted for by package violations. In
// particular, hiding a control is not a use of that control.
@override
void visitImportDirective(ImportDirective node) {}
@override
void visitExportDirective(ExportDirective node) {}
void check(Element? element) {
if (element is ConstructorElement) element = element.enclosingElement;
if (element is TypeAliasElement && element.aliasedType is InterfaceType) {
element = (element.aliasedType as InterfaceType).element;
}
final name = element?.name;
final uri = element?.library?.uri.toString();
if (name != null &&
_controls.contains(name) &&
uri != null &&
(_widgetUri(uri) || uri.startsWith('package:flutter/'))) {
record(name);
}
}
@override
void visitNamedType(NamedType node) {
check(node.element);
super.visitNamedType(node);
}
@override
void visitSimpleIdentifier(SimpleIdentifier node) {
if (!node.inDeclarationContext()) check(node.element);
super.visitSimpleIdentifier(node);
}
}
/// The reviewed baseline may shrink but may not gain or enlarge exceptions.
List<String> checkAllowlistGrowth(String baseline, String candidate) {
Map<String, int> entries(String source) {
final document = loadYaml(source) as YamlMap;
return {
for (final e in document['exceptions'] as YamlList)
'${e['path']}|${e.containsKey('package') ? 'package' : 'symbol'}|${e['package'] ?? e['symbol']}':
e['count'] as int,
};
}
final old = entries(baseline);
final current = entries(candidate);
return [
for (final entry in current.entries)
if (entry.value > (old[entry.key] ?? 0))
'Expanded exception: ${entry.key} (${old[entry.key] ?? 0} to ${entry.value})',
];
}
final _metadataName = RegExp(r'^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$');
List<String> checkAllowlist(Map<String, int> counts, String source) {
final errors = <String>[];
final document = loadYaml(source);
if (document is! YamlMap ||
document['version'] != 1 ||
document['exceptions'] is! YamlList) {
return ['Invalid allow-list schema'];
}
final seen = <String>{};
for (final entry in document['exceptions'] as YamlList) {
if (entry is! YamlMap ||
entry.keys.any(
(key) => !{
'path',
'package',
'symbol',
'count',
'owner',
'reason',
'removal',
}.contains(key),
)) {
errors.add('Invalid exception: $entry');
continue;
}
final path = entry['path'];
final kind = entry.containsKey('package') ? 'package' : 'symbol';
final value = entry[kind];
final count = entry['count'];
if (path is! String ||
!path.startsWith('lib/') ||
p.normalize(path) != path ||
RegExp(r'[*?\[\]]').hasMatch(path) ||
value is! String ||
value.isEmpty ||
entry.containsKey('package') == entry.containsKey('symbol') ||
count is! int ||
count <= 0 ||
['owner', 'reason', 'removal'].any(
(key) =>
entry[key] is! String || (entry[key] as String).trim().isEmpty,
) ||
[
'owner',
'removal',
].any((key) => !_metadataName.hasMatch(entry[key] as String))) {
errors.add('Invalid exception: $entry');
continue;
}
final key = '$path|$kind|$value';
if (!seen.add(key)) {
errors.add('Duplicate exception: $key');
continue;
}
final actual = counts[key];
if (actual == null || actual < count) {
errors.add('Stale exception: $key ($actual < $count)');
}
if (actual != null && actual > count) {
errors.add('Expanded violation: $key ($actual > $count)');
}
}
for (final key in counts.keys.where((key) => !seen.contains(key))) {
errors.add('New violation: $key (${counts[key]})');
}
return errors;
}