fix(ci): fail closed on analyzer errors

This commit is contained in:
edde746
2026-07-12 08:42:22 +02:00
parent 242cc8616c
commit 5f8d263d63
4 changed files with 299 additions and 36 deletions
+1 -22
View File
@@ -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: |
+207
View File
@@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
const _analysisTimeout = Duration(minutes: 3);
final _allowedDiagnostics = <AnalyzerDiagnostic>{
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<void> 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 = <AnalyzerDiagnostic>[];
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 = <AnalyzerDiagnostic>{};
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<String> 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);
}
+6 -14
View File
@@ -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"
+85
View File
@@ -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'),
);
});
}