Found by dogfooding on nuwayze main: `pipe verify` against the ratchet
baseline printed
resolved 1 no longer reported - these fixes are confirmed
+ service_browse_bloc.dart:20 LoadServicesByArtist [dead-handler]
but nothing was fixed. nuwayze commit ccca9bd1e ("delete dead firestore
query code") deleted the event and its handler. Under pipe's default
wire-up policy, deleting an unreachable handler is the exception that
needs approval, so calling it a confirmed fix overstated what happened.
verify now separates the two. A gone finding is REMOVED when its
subject no longer exists:
- its file is not among the project's files;
- for rules labelled by a declaration (the event/state/union/provider or
owner class), that name is no longer declared anywhere;
- for dead-handler, the on<Event> registration itself is gone.
Everything else stays RESOLVED. Member-labelled rules (a method or
build) are classed by file only, never guessed. Removed entries still
count as gone for --expect-resolved; they are only named honestly, in
text ("removed N gone because the code was deleted - not wired up",
a REMOVED section) and JSON (removed, removedEntries).
On nuwayze main the entry now reads REMOVED. Tests: a unit test over
hand-built entries (a wired handler stays resolved, a deleted handler
and a deleted file are removed, a member label is not guessed), and an
end-to-end test that deletes a fixture's dead handler and runs
verifyAnalysis. 216 tests pass.
379 lines
12 KiB
Dart
379 lines
12 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:analyzer/dart/analysis/analysis_context.dart';
|
|
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
|
|
import 'package:analyzer/dart/analysis/results.dart';
|
|
import 'package:analyzer/dart/analysis/session.dart';
|
|
import 'package:analyzer/dart/ast/ast.dart';
|
|
import 'package:analyzer/dart/ast/visitor.dart';
|
|
import 'package:analyzer/diagnostic/diagnostic.dart';
|
|
import 'package:analyzer/file_system/file_system.dart' as fs;
|
|
import 'package:analyzer/file_system/physical_file_system.dart';
|
|
|
|
import 'errors.dart';
|
|
import 'project.dart';
|
|
import 'sdk.dart';
|
|
|
|
/// Outcome of attempting to resolve a single Dart file.
|
|
class FileOutcome {
|
|
FileOutcome({
|
|
required this.path,
|
|
required this.micros,
|
|
this.unit,
|
|
this.failure,
|
|
this.skipped = false,
|
|
});
|
|
|
|
final String path;
|
|
final int micros;
|
|
final ResolvedUnitResult? unit;
|
|
|
|
/// Why resolution did not produce a usable unit, when that happened.
|
|
final String? failure;
|
|
|
|
/// True when the file is not part of any analysis context.
|
|
final bool skipped;
|
|
|
|
bool get resolved => unit != null;
|
|
|
|
List<Diagnostic> get diagnostics => unit?.diagnostics ?? const <Diagnostic>[];
|
|
}
|
|
|
|
/// Diagnostic codes meaning a name or import did not resolve. Unlike a type
|
|
/// error, these change the syntax tree pipe reads: an unresolved `Foo()` is a
|
|
/// method call rather than a construction, so a dispatch or emission of `Foo`
|
|
/// disappears from the analysis.
|
|
const Set<String> unresolvedReferenceCodes = {
|
|
'uri_does_not_exist',
|
|
'undefined_class',
|
|
'undefined_identifier',
|
|
'undefined_method',
|
|
'undefined_function',
|
|
'undefined_getter',
|
|
'undefined_setter',
|
|
'undefined_super_member',
|
|
'undefined_prefixed_name',
|
|
'creation_with_non_type',
|
|
'extends_non_class',
|
|
'implements_non_class',
|
|
'mixin_of_non_class',
|
|
'non_type_as_type_argument',
|
|
};
|
|
|
|
/// Resolved files that contain at least one unresolved reference.
|
|
class UnresolvedReferences {
|
|
UnresolvedReferences({
|
|
required this.files,
|
|
required this.analysed,
|
|
this.first,
|
|
this.names = const {},
|
|
});
|
|
|
|
/// Every identifier those files mention. pipe's evidence is name-based, so an
|
|
/// unresolved file can only hide evidence about a name it mentions.
|
|
final Set<String> names;
|
|
|
|
/// Paths (absolute) of resolved files with an unresolved reference.
|
|
final List<String> files;
|
|
|
|
/// How many files resolved at all.
|
|
final int analysed;
|
|
|
|
/// The first such diagnostic as `path:line: message`, for the reader.
|
|
final String? first;
|
|
|
|
bool get any => files.isNotEmpty;
|
|
}
|
|
|
|
UnresolvedReferences findUnresolvedReferences(List<FileOutcome> outcomes) {
|
|
final files = <String>[];
|
|
final names = <String>{};
|
|
String? first;
|
|
var analysed = 0;
|
|
for (final outcome in outcomes) {
|
|
final unit = outcome.unit;
|
|
if (unit == null) continue;
|
|
analysed++;
|
|
for (final diagnostic in unit.diagnostics) {
|
|
if (!unresolvedReferenceCodes.contains(
|
|
diagnostic.diagnosticCode.lowerCaseName,
|
|
)) {
|
|
continue;
|
|
}
|
|
files.add(outcome.path);
|
|
final line = unit.lineInfo.getLocation(diagnostic.offset).lineNumber;
|
|
first ??= '${outcome.path}:$line: ${diagnostic.message}';
|
|
final collector = _Names();
|
|
unit.unit.accept(collector);
|
|
names.addAll(collector.names);
|
|
break;
|
|
}
|
|
}
|
|
return UnresolvedReferences(
|
|
files: files,
|
|
analysed: analysed,
|
|
first: first,
|
|
names: names,
|
|
);
|
|
}
|
|
|
|
/// Every type, enum constant and top-level name declared in the resolved units.
|
|
/// `verify` uses it to tell a finding that went away because its subject was
|
|
/// deleted from one that went away because the code was fixed.
|
|
Set<String> declaredNamesOf(List<FileOutcome> outcomes) {
|
|
final names = <String>{};
|
|
for (final outcome in outcomes) {
|
|
final unit = outcome.unit;
|
|
if (unit == null) continue;
|
|
for (final declaration in unit.unit.declarations) {
|
|
switch (declaration) {
|
|
case ClassDeclaration():
|
|
names.add(declaration.namePart.typeName.lexeme);
|
|
case MixinDeclaration():
|
|
names.add(declaration.name.lexeme);
|
|
case EnumDeclaration():
|
|
names.add(declaration.namePart.typeName.lexeme);
|
|
for (final constant in declaration.body.constants) {
|
|
names.add(constant.name.lexeme);
|
|
}
|
|
case FunctionDeclaration():
|
|
names.add(declaration.name.lexeme);
|
|
case TopLevelVariableDeclaration():
|
|
for (final variable in declaration.variables.variables) {
|
|
names.add(variable.name.lexeme);
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
class _Names extends RecursiveAstVisitor<void> {
|
|
final Set<String> names = {};
|
|
|
|
@override
|
|
void visitSimpleIdentifier(SimpleIdentifier node) {
|
|
names.add(node.name);
|
|
super.visitSimpleIdentifier(node);
|
|
}
|
|
|
|
@override
|
|
void visitNamedType(NamedType node) {
|
|
names.add(node.name.lexeme);
|
|
super.visitNamedType(node);
|
|
}
|
|
}
|
|
|
|
/// How much of a project an analysis actually examined.
|
|
///
|
|
/// Every report carries this, because a finding count means nothing without it:
|
|
/// "0 findings" from 3 of 40 files is not a clean project, and a reader (or an
|
|
/// agent) handed only the findings cannot tell.
|
|
class ScanCoverage {
|
|
const ScanCoverage({
|
|
required this.discovered,
|
|
required this.selected,
|
|
required this.resolved,
|
|
required this.failed,
|
|
required this.skipped,
|
|
});
|
|
|
|
factory ScanCoverage.fromJson(Map<String, Object?> json) => ScanCoverage(
|
|
discovered: json['discovered']! as int,
|
|
selected: json['selected']! as int,
|
|
resolved: json['resolved']! as int,
|
|
failed: json['failed']! as int,
|
|
skipped: json['skipped']! as int,
|
|
);
|
|
|
|
factory ScanCoverage.of({
|
|
required int discovered,
|
|
required List<FileOutcome> outcomes,
|
|
}) {
|
|
var resolved = 0;
|
|
var failed = 0;
|
|
var skipped = 0;
|
|
for (final outcome in outcomes) {
|
|
if (outcome.resolved) {
|
|
resolved++;
|
|
} else if (outcome.skipped) {
|
|
skipped++;
|
|
} else {
|
|
failed++;
|
|
}
|
|
}
|
|
return ScanCoverage(
|
|
discovered: discovered,
|
|
selected: outcomes.length,
|
|
resolved: resolved,
|
|
failed: failed,
|
|
skipped: skipped,
|
|
);
|
|
}
|
|
|
|
/// Dart files found under the scanned directories.
|
|
final int discovered;
|
|
|
|
/// Files handed to the analyzer, after --no-include-generated and --limit.
|
|
final int selected;
|
|
final int resolved;
|
|
final int failed;
|
|
|
|
/// Files the analyzer did not cover: excluded by analysis_options, or
|
|
/// outside every analysis context.
|
|
final int skipped;
|
|
|
|
bool get complete => resolved == discovered;
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'discovered': discovered,
|
|
'selected': selected,
|
|
'resolved': resolved,
|
|
'failed': failed,
|
|
'skipped': skipped,
|
|
'complete': complete,
|
|
};
|
|
|
|
/// One line, for the top of a brief or plan.
|
|
String describe() {
|
|
final notSelected = discovered - selected;
|
|
return 'Coverage: $resolved of $discovered discovered file(s) analysed '
|
|
'($failed failed, $skipped skipped by analysis_options or context'
|
|
'${notSelected > 0 ? ', $notSelected not selected' : ''})'
|
|
'${complete ? '.' : ' - results are PARTIAL.'}';
|
|
}
|
|
}
|
|
|
|
/// Owns the analyzer contexts for one target project.
|
|
///
|
|
/// Mirrors the lifecycle Flutter's own tooling uses: one collection rooted at
|
|
/// the project, an explicit SDK path, and a fresh session after an
|
|
/// [InconsistentAnalysisException].
|
|
class Analyzer {
|
|
Analyzer._(this.collection, this.sdk);
|
|
|
|
final AnalysisContextCollection collection;
|
|
final SdkResolution sdk;
|
|
|
|
int get contextCount => collection.contexts.length;
|
|
|
|
/// Build a collection for [project], resolving `dart:` libraries with the
|
|
/// Flutter-bundled SDK when the target is a Flutter project.
|
|
static Analyzer create(Project project, {String? sdkOverride}) {
|
|
final resolution = resolveSdk(
|
|
explicit: sdkOverride,
|
|
flutterRootHint: project.flutterRoot,
|
|
);
|
|
requireReadableSdk(resolution);
|
|
final AnalysisContextCollection collection;
|
|
try {
|
|
collection = AnalysisContextCollection(
|
|
includedPaths: [project.root],
|
|
resourceProvider: PhysicalResourceProvider.INSTANCE,
|
|
sdkPath: resolution.sdkPath,
|
|
);
|
|
} on fs.FileSystemException catch (error) {
|
|
// The analyzer throws its own FileSystemException, not dart:io's, so the
|
|
// catch in bin/pipe.dart does not see it. The usual cause is a project
|
|
// whose package_config still names a Flutter SDK that has moved.
|
|
throw PipeException(
|
|
'cannot read the Dart SDK at ${resolution.sdkPath}: ${error.message} '
|
|
'(${error.path})',
|
|
remedy:
|
|
'run `flutter pub get` in the project so its package_config '
|
|
'names the current SDK, or pass --sdk <dart-sdk-path>',
|
|
);
|
|
}
|
|
return Analyzer._(collection, resolution);
|
|
}
|
|
|
|
/// Resolve a single file, measuring wall time for that file.
|
|
Future<FileOutcome> resolve(String path) async {
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
final AnalysisContext context;
|
|
try {
|
|
context = collection.contextFor(path);
|
|
} on StateError {
|
|
stopwatch.stop();
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
skipped: true,
|
|
failure: 'not covered by any analysis context',
|
|
);
|
|
}
|
|
|
|
// A session is bound to one metadata state; if files changed under us the
|
|
// analyzer throws and re-reading currentSession gives a fresh view.
|
|
for (var attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
final result = await context.currentSession.getResolvedUnit(path);
|
|
stopwatch.stop();
|
|
if (result is ResolvedUnitResult) {
|
|
// An unreadable file is handed back as a unit with EMPTY content and no
|
|
// declarations - not an error and not a failure. Treating that as a
|
|
// successful resolution meant a project whose files could not be read
|
|
// reported "12/12 resolved, 0 diagnostics" and `check` reported zero
|
|
// findings, indistinguishable from a genuinely clean project. The
|
|
// unit's content is only available here, so the check lives here.
|
|
if (result.content.isEmpty) {
|
|
var onDisk = 0;
|
|
try {
|
|
onDisk = File(path).lengthSync();
|
|
} on FileSystemException {
|
|
onDisk = -1;
|
|
}
|
|
// A genuinely empty file is fine; a file with bytes that produced no
|
|
// content was not read.
|
|
if (onDisk != 0) {
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
failure:
|
|
'no content returned for a file of $onDisk bytes '
|
|
'(unreadable or removed?)',
|
|
);
|
|
}
|
|
}
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
unit: result,
|
|
);
|
|
}
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
failure: 'no unit result (${result.runtimeType})',
|
|
);
|
|
} on InconsistentAnalysisException {
|
|
if (attempt == 0) continue;
|
|
stopwatch.stop();
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
failure: 'inconsistent analysis session',
|
|
);
|
|
} on Object catch (error) {
|
|
stopwatch.stop();
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
failure: error.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
stopwatch.stop();
|
|
return FileOutcome(
|
|
path: path,
|
|
micros: stopwatch.elapsedMicroseconds,
|
|
failure: 'exhausted resolution attempts',
|
|
);
|
|
}
|
|
|
|
Future<void> dispose() => collection.dispose();
|
|
}
|