pipe/lib/src/analysis.dart
trvon 4f3f22acad
Some checks failed
Lint Commits / commitlint (pull_request) Successful in 15s
Tests / Static Analysis (pull_request) Has been cancelled
Tests / Tests (pull_request) Has been cancelled
feat(verify): report a deleted subject as removed, not resolved
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.
2026-09-24 20:04:52 -06:00

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();
}