From 5f8d263d634360d8e9d94b28ec2395095ecdf700 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 04:48:45 +0200 Subject: [PATCH] fix(ci): fail closed on analyzer errors --- .github/workflows/ci.yml | 23 +-- scripts/check_analyzer.dart | 207 ++++++++++++++++++++++++++ scripts/ci_checks.sh | 20 +-- test/scripts/check_analyzer_test.dart | 85 +++++++++++ 4 files changed, 299 insertions(+), 36 deletions(-) create mode 100644 scripts/check_analyzer.dart create mode 100644 test/scripts/check_analyzer_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7307e3..30c4d7b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,28 +49,7 @@ jobs: find "${paths[@]}" -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f -print0 | xargs -0 -r dart format --output=none --set-exit-if-changed - name: Analyze code - run: | - # Run flutter analyze - # Fails on errors or warnings only (info messages are allowed) - set +e - flutter analyze 2>&1 | tee analyze_output.txt - analyze_status=${PIPESTATUS[0]} - set -e - - # Check output for errors or warnings - if grep -q "error •" analyze_output.txt; then - echo "❌ Analysis failed with errors" - exit 1 - elif grep -q "warning •" analyze_output.txt; then - echo "⚠️ Analysis completed with warnings" - exit 1 - elif [ "$analyze_status" -ne 0 ] && ! grep -q "info •" analyze_output.txt; then - echo "❌ Analyzer failed before producing diagnostics (exit $analyze_status)" - exit "$analyze_status" - else - echo "✅ Analysis passed!" - exit 0 - fi + run: dart run scripts/check_analyzer.dart - name: Check for unused code run: | diff --git a/scripts/check_analyzer.dart b/scripts/check_analyzer.dart new file mode 100644 index 00000000..40e3a33e --- /dev/null +++ b/scripts/check_analyzer.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +const _analysisTimeout = Duration(minutes: 3); + +final _allowedDiagnostics = { + const AnalyzerDiagnostic( + severity: 'INFO', + type: 'LINT', + code: 'UNAWAITED_FUTURES', + path: 'test/navigation/profile_navigation_scope_test.dart', + line: 21, + column: 28, + length: 4, + message: "Missing an 'await' for the 'Future' computed by this expression.", + ), + const AnalyzerDiagnostic( + severity: 'INFO', + type: 'HINT', + code: 'UNNECESSARY_IMPORT', + path: 'test/widgets/video_controls_two_finger_double_tap_tracker_test.dart', + line: 1, + column: 8, + length: 31, + message: + "The import of 'package:flutter/material.dart' is unnecessary because " + 'all of the used elements are also provided by the import of ' + "'package:flutter_test/flutter_test.dart'.", + ), +}; + +Future main() async { + final scriptDirectory = File.fromUri(Platform.script).parent; + final root = scriptDirectory.parent.path; + late final Process process; + + try { + process = await Process.start( + Platform.resolvedExecutable, + const ['analyze', '--format', 'machine', '--fatal-infos'], + workingDirectory: root, + runInShell: false, + ); + } on ProcessException catch (error) { + stderr.writeln('Failed to start the analyzer: $error'); + exitCode = 1; + return; + } + + final analyzerStdoutFuture = process.stdout.transform(utf8.decoder).join(); + final analyzerStderrFuture = process.stderr.transform(utf8.decoder).join(); + + int analyzerExitCode; + try { + analyzerExitCode = await process.exitCode.timeout(_analysisTimeout); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await Future.wait([analyzerStdoutFuture, analyzerStderrFuture]); + stderr.writeln('Analyzer timed out after ${_analysisTimeout.inMinutes} minutes.'); + exitCode = 1; + return; + } + + final analyzerStdout = await analyzerStdoutFuture; + final analyzerStderr = await analyzerStderrFuture; + stdout.write(analyzerStdout); + stderr.write(analyzerStderr); + + final failure = validateAnalyzerResult( + analyzerExitCode: analyzerExitCode, + analyzerStdout: analyzerStdout, + analyzerStderr: analyzerStderr, + rootPath: root, + ); + if (failure != null) { + stderr.writeln('Analyzer check failed: $failure'); + exitCode = 1; + return; + } + + final diagnosticCount = analyzerStdout.trim().isEmpty ? 0 : const LineSplitter().convert(analyzerStdout).length; + stdout.writeln( + 'Analyzer passed with $diagnosticCount explicitly allowed info ' + 'diagnostic(s).', + ); +} + +String? validateAnalyzerResult({ + required int analyzerExitCode, + required String analyzerStdout, + required String analyzerStderr, + required String rootPath, +}) { + if (analyzerStderr.trim().isNotEmpty) { + return 'unexpected stderr output (exit $analyzerExitCode)'; + } + + final lines = const LineSplitter().convert(analyzerStdout); + final diagnostics = []; + for (final line in lines) { + if (line.isEmpty) { + return 'unexpected blank line in machine output'; + } + final diagnostic = AnalyzerDiagnostic.tryParse(line, rootPath: rootPath); + if (diagnostic == null) { + return 'malformed machine output: $line'; + } + diagnostics.add(diagnostic); + } + + final seen = {}; + for (final diagnostic in diagnostics) { + if (diagnostic.severity != 'INFO') { + return 'unexpected ${diagnostic.severity} diagnostic: ${diagnostic.code}'; + } + if (!_allowedDiagnostics.contains(diagnostic)) { + return 'info diagnostic is not explicitly allowed: ' + '${diagnostic.code} at ${diagnostic.path}:${diagnostic.line}'; + } + if (!seen.add(diagnostic)) { + return 'duplicate diagnostic: ' + '${diagnostic.code} at ${diagnostic.path}:${diagnostic.line}'; + } + } + + if (analyzerExitCode == 0) { + return diagnostics.isEmpty ? null : 'analyzer returned success despite --fatal-infos diagnostics'; + } + if (analyzerExitCode == 1 && diagnostics.isNotEmpty) { + return null; + } + return 'analyzer exited $analyzerExitCode without only allowed info diagnostics'; +} + +class AnalyzerDiagnostic { + const AnalyzerDiagnostic({ + required this.severity, + required this.type, + required this.code, + required this.path, + required this.line, + required this.column, + required this.length, + required this.message, + }); + + factory AnalyzerDiagnostic._fromParts(List parts, String rootPath) { + return AnalyzerDiagnostic( + severity: parts[0], + type: parts[1], + code: parts[2], + path: _relativePath(parts[3], rootPath), + line: int.parse(parts[4]), + column: int.parse(parts[5]), + length: int.parse(parts[6]), + message: parts.sublist(7).join('|'), + ); + } + + static AnalyzerDiagnostic? tryParse(String line, {required String rootPath}) { + final parts = line.split('|'); + if (parts.length < 8 || parts.take(8).any((part) => part.isEmpty)) { + return null; + } + if (int.tryParse(parts[4]) == null || int.tryParse(parts[5]) == null || int.tryParse(parts[6]) == null) { + return null; + } + return AnalyzerDiagnostic._fromParts(parts, rootPath); + } + + static String _relativePath(String path, String rootPath) { + final normalizedPath = _normalizePath(path); + final normalizedRoot = _normalizePath(rootPath); + final rootPrefix = '$normalizedRoot/'; + if (normalizedPath.toLowerCase().startsWith(rootPrefix.toLowerCase())) { + return normalizedPath.substring(rootPrefix.length); + } + return normalizedPath; + } + + static String _normalizePath(String path) => path.replaceAll(r'\\', '/').replaceAll(r'\', '/'); + + final String severity; + final String type; + final String code; + final String path; + final int line; + final int column; + final int length; + final String message; + + @override + bool operator ==(Object other) => + other is AnalyzerDiagnostic && + severity == other.severity && + type == other.type && + code == other.code && + path == other.path && + line == other.line && + column == other.column && + length == other.length && + message == other.message; + + @override + int get hashCode => Object.hash(severity, type, code, path, line, column, length, message); +} diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 6390981c..97b9d71c 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -83,22 +83,14 @@ else fi rm -f "$out" -# 3. flutter analyze (mirrors ci.yml "Analyze code") -section "flutter analyze" -out="$(mktemp)" -flutter analyze >"$out" 2>&1 || true -if grep -q "error •" "$out"; then - fail "errors" - grep -E "error •|warning •" "$out" | sed 's/^/ /' - FAILED=1 -elif grep -q "warning •" "$out"; then - fail "warnings (treated as failure, matching CI)" - grep "warning •" "$out" | sed 's/^/ /' - FAILED=1 +# 3. Dart analyzer (mirrors ci.yml "Analyze code") +section "Dart analyzer" +if dart run scripts/check_analyzer.dart; then + ok "no unapproved diagnostics" else - ok "no errors or warnings" + fail "analyzer errors, warnings, unexpected infos, or tool failure" + FAILED=1 fi -rm -f "$out" # 4. Unused code (mirrors ci.yml "Check for unused code") section "dart_code_linter: unused code" diff --git a/test/scripts/check_analyzer_test.dart b/test/scripts/check_analyzer_test.dart new file mode 100644 index 00000000..9567f641 --- /dev/null +++ b/test/scripts/check_analyzer_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../scripts/check_analyzer.dart'; + +const _root = r'C:\repo'; +const _allowedInfo = + r"INFO|LINT|UNAWAITED_FUTURES|C:\\repo\\test\\navigation\\profile_navigation_scope_test.dart|21|28|4|Missing an 'await' for the 'Future' computed by this expression."; + +void main() { + test('accepts a clean analyzer success', () { + expect( + validateAnalyzerResult(analyzerExitCode: 0, analyzerStdout: '', analyzerStderr: '', rootPath: _root), + isNull, + ); + }); + + test('accepts a nonzero exit caused only by an allowed info', () { + expect( + validateAnalyzerResult( + analyzerExitCode: 1, + analyzerStdout: '$_allowedInfo\n', + analyzerStderr: '', + rootPath: _root, + ), + isNull, + ); + }); + + test('rejects a crash after an allowed info', () { + expect( + validateAnalyzerResult( + analyzerExitCode: 1, + analyzerStdout: '$_allowedInfo\n', + analyzerStderr: 'Analyzer crash\nstack trace\n', + rootPath: _root, + ), + contains('unexpected stderr'), + ); + }); + + test('rejects a crash exit code even after an allowed info', () { + expect( + validateAnalyzerResult( + analyzerExitCode: 255, + analyzerStdout: '$_allowedInfo\n', + analyzerStderr: '', + rootPath: _root, + ), + contains('exited 255'), + ); + }); + + test('rejects unexpected output after an allowed info', () { + expect( + validateAnalyzerResult( + analyzerExitCode: 1, + analyzerStdout: '$_allowedInfo\nAnalyzer crash\n', + analyzerStderr: '', + rootPath: _root, + ), + contains('malformed machine output'), + ); + }); + + test('rejects warnings and unapproved infos', () { + const warning = 'WARNING|STATIC_WARNING|UNUSED_LOCAL_VARIABLE|/repo/lib/a.dart|1|1|1|Unused.'; + const newInfo = 'INFO|LINT|AVOID_PRINT|/repo/lib/a.dart|1|1|5|Avoid print calls.'; + + expect( + validateAnalyzerResult(analyzerExitCode: 1, analyzerStdout: '$warning\n', analyzerStderr: '', rootPath: '/repo'), + contains('unexpected WARNING'), + ); + expect( + validateAnalyzerResult(analyzerExitCode: 1, analyzerStdout: '$newInfo\n', analyzerStderr: '', rootPath: '/repo'), + contains('not explicitly allowed'), + ); + }); + + test('rejects a nonzero exit without diagnostics', () { + expect( + validateAnalyzerResult(analyzerExitCode: 1, analyzerStdout: '', analyzerStderr: '', rootPath: '/repo'), + contains('exited 1'), + ); + }); +}