scripts/ had ~80 flat files. Entry points (ci_*.sh, codegen.sh, run_tests.sh, format_native.sh, setup_hooks.sh, upload-symbols.*) and the shared pubspec_version.py stay at the root; checkers, generators, maestro tooling and release tooling move into subdirectories with their tests. Updated every reference: workflow steps, guard-test glob, Docker COPY paths and .dockerignore whitelist, website audit path, dart test imports, and regenerated the five outputs whose headers embed generator paths.
208 lines
7.3 KiB
Dart
208 lines
7.3 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:analyzer/dart/analysis/utilities.dart';
|
|
import 'package:analyzer/dart/ast/ast.dart';
|
|
import 'package:analyzer/dart/ast/visitor.dart';
|
|
import 'package:analyzer/source/line_info.dart';
|
|
|
|
const _canonicalAppIconPath = 'lib/widgets/app_icon.dart';
|
|
const _generatedSuffixes = ['.g.dart', '.freezed.dart', '.gen.dart'];
|
|
final _generatedHeader = RegExp(
|
|
r'^\s*///?\s*(?:auto-)?generated\b.*\bdo not (?:edit|modify)\b',
|
|
caseSensitive: false,
|
|
multiLine: true,
|
|
);
|
|
|
|
void main(List<String> arguments) {
|
|
final scriptDirectory = File.fromUri(Platform.script).absolute.parent;
|
|
late final Directory root;
|
|
if (arguments.isEmpty) {
|
|
root = scriptDirectory.parent.parent;
|
|
} else if (arguments.length == 2 && arguments.first == '--root') {
|
|
root = Directory(arguments.last).absolute;
|
|
} else {
|
|
stderr.writeln('Usage: dart run scripts/checks/check_icon_consistency.dart [--root <path>]');
|
|
exitCode = 64;
|
|
return;
|
|
}
|
|
final libDirectory = Directory('${root.path}${Platform.pathSeparator}lib');
|
|
if (!libDirectory.existsSync()) {
|
|
stderr.writeln('lib:1:1: lib directory not found');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
final files =
|
|
libDirectory
|
|
.listSync(recursive: true, followLinks: false)
|
|
.whereType<File>()
|
|
.where((file) => file.path.endsWith('.dart'))
|
|
.toList()
|
|
..sort((a, b) => a.path.compareTo(b.path));
|
|
|
|
final failures = <_Failure>[];
|
|
var scannedFileCount = 0;
|
|
for (final file in files) {
|
|
final relativePath = _relativePath(file.path, root.path);
|
|
final source = file.readAsStringSync();
|
|
if (_isGenerated(relativePath, source)) continue;
|
|
|
|
scannedFileCount++;
|
|
final parseResult = parseString(content: source, path: file.path, throwIfDiagnostics: false);
|
|
final unit = parseResult.unit;
|
|
unit.accept(
|
|
_IconConsistencyVisitor(
|
|
path: relativePath,
|
|
lineInfo: parseResult.lineInfo,
|
|
allowFlutterIcon: relativePath == _canonicalAppIconPath,
|
|
flutterIconPrefixes: _importPrefixes(unit, const {
|
|
'package:flutter/cupertino.dart',
|
|
'package:flutter/material.dart',
|
|
'package:flutter/widgets.dart',
|
|
}),
|
|
materialPrefixes: _importPrefixes(unit, const {'package:flutter/material.dart'}),
|
|
symbolsPrefixes: _importPrefixes(unit, const {
|
|
'package:material_symbols_icons/material_symbols_icons.dart',
|
|
'package:material_symbols_icons/symbols.dart',
|
|
}),
|
|
failures: failures,
|
|
),
|
|
);
|
|
}
|
|
|
|
failures.sort((a, b) {
|
|
final pathComparison = a.path.compareTo(b.path);
|
|
if (pathComparison != 0) return pathComparison;
|
|
final lineComparison = a.line.compareTo(b.line);
|
|
if (lineComparison != 0) return lineComparison;
|
|
final columnComparison = a.column.compareTo(b.column);
|
|
if (columnComparison != 0) return columnComparison;
|
|
return a.message.compareTo(b.message);
|
|
});
|
|
|
|
if (failures.isNotEmpty) {
|
|
for (final failure in failures) {
|
|
stderr.writeln(failure);
|
|
}
|
|
stderr.writeln('Icon consistency check failed with ${failures.length} violation(s).');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
stdout.writeln('Icon consistency check passed ($scannedFileCount non-generated files scanned).');
|
|
}
|
|
|
|
bool _isGenerated(String relativePath, String source) {
|
|
final fileName = relativePath.split('/').last;
|
|
if (_generatedSuffixes.any(fileName.endsWith)) return true;
|
|
if (relativePath.contains('/generated/')) return true;
|
|
|
|
final headerLength = source.length < 1024 ? source.length : 1024;
|
|
return _generatedHeader.hasMatch(source.substring(0, headerLength));
|
|
}
|
|
|
|
String _relativePath(String path, String rootPath) {
|
|
final normalizedPath = path.replaceAll(r'\', '/');
|
|
final normalizedRoot = rootPath.replaceAll(r'\', '/');
|
|
return normalizedPath.substring(normalizedRoot.length + 1);
|
|
}
|
|
|
|
Set<String> _importPrefixes(CompilationUnit unit, Set<String> uris) {
|
|
final prefixes = <String>{};
|
|
for (final directive in unit.directives.whereType<ImportDirective>()) {
|
|
final prefix = directive.prefix;
|
|
if (prefix != null && uris.contains(directive.uri.stringValue)) {
|
|
prefixes.add(prefix.name);
|
|
}
|
|
}
|
|
return prefixes;
|
|
}
|
|
|
|
class _IconConsistencyVisitor extends RecursiveAstVisitor<void> {
|
|
_IconConsistencyVisitor({
|
|
required this.path,
|
|
required this.lineInfo,
|
|
required this.allowFlutterIcon,
|
|
required this.flutterIconPrefixes,
|
|
required this.materialPrefixes,
|
|
required this.symbolsPrefixes,
|
|
required this.failures,
|
|
});
|
|
|
|
final String path;
|
|
final LineInfo lineInfo;
|
|
final bool allowFlutterIcon;
|
|
final Set<String> flutterIconPrefixes;
|
|
final Set<String> materialPrefixes;
|
|
final Set<String> symbolsPrefixes;
|
|
final List<_Failure> failures;
|
|
|
|
@override
|
|
void visitInstanceCreationExpression(InstanceCreationExpression node) {
|
|
if (!allowFlutterIcon && node.constructorName.type.name.lexeme == 'Icon') {
|
|
_report(node.constructorName.type, 'Flutter Icon construction is forbidden; use AppIcon instead');
|
|
}
|
|
super.visitInstanceCreationExpression(node);
|
|
}
|
|
|
|
@override
|
|
void visitConstructorReference(ConstructorReference node) {
|
|
if (!allowFlutterIcon && node.constructorName.type.name.lexeme == 'Icon') {
|
|
_report(node.constructorName.type, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
super.visitConstructorReference(node);
|
|
}
|
|
|
|
@override
|
|
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
|
final prefix = node.prefix.name;
|
|
final member = node.identifier.name;
|
|
if (prefix == 'Icons') {
|
|
_report(node, 'Icons.$member is forbidden; use a rounded Symbols member');
|
|
} else if (prefix == 'Symbols' && !member.endsWith('_rounded')) {
|
|
_report(node, 'Symbols.$member must use its _rounded counterpart');
|
|
} else if (!allowFlutterIcon && prefix == 'Icon' && member == 'new') {
|
|
_report(node, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
super.visitPrefixedIdentifier(node);
|
|
}
|
|
|
|
@override
|
|
void visitPropertyAccess(PropertyAccess node) {
|
|
final target = node.target;
|
|
if (target is PrefixedIdentifier) {
|
|
final importPrefix = target.prefix.name;
|
|
final typeName = target.identifier.name;
|
|
final member = node.propertyName.name;
|
|
if (typeName == 'Icons' && materialPrefixes.contains(importPrefix)) {
|
|
_report(node, '$importPrefix.Icons.$member is forbidden; use a rounded Symbols member');
|
|
} else if (typeName == 'Symbols' && symbolsPrefixes.contains(importPrefix) && !member.endsWith('_rounded')) {
|
|
_report(node, '$importPrefix.Symbols.$member must use its _rounded counterpart');
|
|
} else if (!allowFlutterIcon &&
|
|
typeName == 'Icon' &&
|
|
member == 'new' &&
|
|
flutterIconPrefixes.contains(importPrefix)) {
|
|
_report(node, 'Flutter Icon constructor tear-offs are forbidden; use AppIcon instead');
|
|
}
|
|
}
|
|
super.visitPropertyAccess(node);
|
|
}
|
|
|
|
void _report(AstNode node, String message) {
|
|
final location = lineInfo.getLocation(node.offset);
|
|
failures.add(_Failure(path: path, line: location.lineNumber, column: location.columnNumber, message: message));
|
|
}
|
|
}
|
|
|
|
class _Failure {
|
|
const _Failure({required this.path, required this.line, required this.column, required this.message});
|
|
|
|
final String path;
|
|
final int line;
|
|
final int column;
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => '$path:$line:$column: $message';
|
|
}
|