fix(ci): fail closed on analyzer errors
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user