fix/nuwayze-dogfood-findings #11
13 changed files with 604 additions and 28 deletions
|
|
@ -40,7 +40,7 @@ In CI, accept today's findings once and fail only on new ones:
|
||||||
```bash
|
```bash
|
||||||
pipe check . --write-baseline .pipe-baseline.json
|
pipe check . --write-baseline .pipe-baseline.json
|
||||||
pipe check . --baseline .pipe-baseline.json --fail-on warning
|
pipe check . --baseline .pipe-baseline.json --fail-on warning
|
||||||
pipe verify . --baseline .pipe-baseline.json --expect-resolved # after a fix
|
pipe verify . --expect-resolved # after a fix; reads .pipe-baseline.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ findings, no baseline, and deliberately no index analysis. Deciding whether a
|
||||||
query needs a composite index, and probing a live project, belong to the
|
query needs a composite index, and probing a live project, belong to the
|
||||||
consumer of this output, which can compare it against `firestore.indexes.json`.
|
consumer of this output, which can compare it against `firestore.indexes.json`.
|
||||||
|
|
||||||
|
The path may be a package root or a directory inside one: `pipe queries lib/data`
|
||||||
|
resolves the package that contains `lib/data` (the nearest `pubspec.yaml`) and
|
||||||
|
scans only that directory, as `--dir lib/data` would. `--dir` given with such a
|
||||||
|
path is relative to it.
|
||||||
|
|
||||||
What it recognises:
|
What it recognises:
|
||||||
|
|
||||||
- **cloud_firestore chains.** Receivers are identified by **static type and
|
- **cloud_firestore chains.** Receivers are identified by **static type and
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
| `pipe check <path>` | Ranked findings with evidence and remediation. |
|
| `pipe check <path>` | Ranked findings with evidence and remediation. |
|
||||||
| `pipe suggest <path>` | Findings collapsed to root causes and ranked, globally and per file. |
|
| `pipe suggest <path>` | Findings collapsed to root causes and ranked, globally and per file. |
|
||||||
| `pipe guide <path>` | A prompt-sized brief for an agent; `--yams` persists it to the yams corpus. |
|
| `pipe guide <path>` | A prompt-sized brief for an agent; `--yams` persists it to the yams corpus. |
|
||||||
| `pipe verify <path> --baseline F` | Which accepted findings were resolved, remain, or are new. |
|
| `pipe verify <path> [--baseline F]` | Which accepted findings were resolved, remain, or are new. |
|
||||||
| `pipe recall --root <dir>` | How many labelled known bugs pipe detects (see [rules](rules.md#recall)). |
|
| `pipe recall --root <dir>` | How many labelled known bugs pipe detects (see [rules](rules.md#recall)). |
|
||||||
| `pipe queries <path>` | Every Firestore query the code can issue, as typed records; an inventory, no rules (see [queries](queries.md)). |
|
| `pipe queries <path>` | Every Firestore query the code can issue, as typed records; an inventory, no rules (see [queries](queries.md)). |
|
||||||
|
|
||||||
|
|
@ -109,6 +109,10 @@ RESOLVED
|
||||||
+ lib/flag_cubit.dart:25 loadUnguarded [loading-flag-not-reset]
|
+ lib/flag_cubit.dart:25 loadUnguarded [loading-flag-not-reset]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Without `--baseline`, `verify` reads `<path>/.pipe-baseline.json`, the file the
|
||||||
|
CI recipe above writes, and says so on stderr; with neither, it fails and names
|
||||||
|
both. So `pipe verify .` is enough in a repo that follows the recipe.
|
||||||
|
|
||||||
`--expect-resolved` exits non-zero while anything accepted is still reported or
|
`--expect-resolved` exits non-zero while anything accepted is still reported or
|
||||||
was not scanned. `verify` only calls a finding resolved when it looked for it:
|
was not scanned. `verify` only calls a finding resolved when it looked for it:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ export 'src/facts.dart';
|
||||||
export 'src/provider_rules.dart' show providerRuleNote;
|
export 'src/provider_rules.dart' show providerRuleNote;
|
||||||
export 'src/recall.dart'
|
export 'src/recall.dart'
|
||||||
show RecallCase, RecallOutcome, RecallReport, loadRecallCases, runRecall;
|
show RecallCase, RecallOutcome, RecallReport, loadRecallCases, runRecall;
|
||||||
export 'src/project.dart' show Project, loadProject;
|
export 'src/project.dart'
|
||||||
|
show PackageTarget, Project, enclosingPackage, loadProject;
|
||||||
export 'src/sdk.dart' show SdkResolution, resolveSdk;
|
export 'src/sdk.dart' show SdkResolution, resolveSdk;
|
||||||
export 'src/rules.dart'
|
export 'src/rules.dart'
|
||||||
show
|
show
|
||||||
|
|
|
||||||
|
|
@ -337,9 +337,18 @@ class GraphCommand extends Command<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> run() async {
|
Future<int> run() async {
|
||||||
final results = argResults!;
|
final results = argResults!;
|
||||||
|
// `pipe queries lib` means "the queries under lib of this package": the
|
||||||
|
// package graph lives at the package root, and the path is the scope.
|
||||||
|
final target = enclosingPackage(_target(results));
|
||||||
|
final dirs = results['dir'] as List<String>;
|
||||||
|
final scope = target.scope;
|
||||||
final project = await loadProject(
|
final project = await loadProject(
|
||||||
_target(results),
|
target.root,
|
||||||
directories: results['dir'] as List<String>,
|
directories: scope == null
|
||||||
|
? dirs
|
||||||
|
: results.wasParsed('dir')
|
||||||
|
? [for (final dir in dirs) p.join(scope, dir)]
|
||||||
|
: [scope],
|
||||||
);
|
);
|
||||||
final run = await resolveProject(
|
final run = await resolveProject(
|
||||||
project,
|
project,
|
||||||
|
|
@ -393,9 +402,18 @@ class StateCommand extends Command<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> run() async {
|
Future<int> run() async {
|
||||||
final results = argResults!;
|
final results = argResults!;
|
||||||
|
// `pipe queries lib` means "the queries under lib of this package": the
|
||||||
|
// package graph lives at the package root, and the path is the scope.
|
||||||
|
final target = enclosingPackage(_target(results));
|
||||||
|
final dirs = results['dir'] as List<String>;
|
||||||
|
final scope = target.scope;
|
||||||
final project = await loadProject(
|
final project = await loadProject(
|
||||||
_target(results),
|
target.root,
|
||||||
directories: results['dir'] as List<String>,
|
directories: scope == null
|
||||||
|
? dirs
|
||||||
|
: results.wasParsed('dir')
|
||||||
|
? [for (final dir in dirs) p.join(scope, dir)]
|
||||||
|
: [scope],
|
||||||
);
|
);
|
||||||
final run = await resolveProject(
|
final run = await resolveProject(
|
||||||
project,
|
project,
|
||||||
|
|
@ -625,6 +643,36 @@ class RecallCommand extends Command<int> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The conventional baseline file name, at the project root. It is what the
|
||||||
|
/// README's CI recipe writes, and what `verify` reads when `--baseline` is
|
||||||
|
/// omitted.
|
||||||
|
const String defaultBaselineName = '.pipe-baseline.json';
|
||||||
|
|
||||||
|
/// The baseline `verify` compares against: [explicit] when given, else
|
||||||
|
/// [defaultBaselineName] under the project [target] when that exists. Using
|
||||||
|
/// the default is announced on [log] (stderr), so a run never silently picks a
|
||||||
|
/// file the user did not name.
|
||||||
|
String resolveVerifyBaseline(
|
||||||
|
String? explicit,
|
||||||
|
String target, {
|
||||||
|
StringSink? log,
|
||||||
|
}) {
|
||||||
|
if (explicit != null) return explicit;
|
||||||
|
final conventional = p.join(target, defaultBaselineName);
|
||||||
|
if (File(conventional).existsSync()) {
|
||||||
|
(log ?? stderr).writeln('pipe: no --baseline given; using $conventional');
|
||||||
|
return conventional;
|
||||||
|
}
|
||||||
|
throw PipeException(
|
||||||
|
'verify needs a baseline: no --baseline given and no $defaultBaselineName '
|
||||||
|
'at the project root ($conventional)',
|
||||||
|
remedy:
|
||||||
|
'pass --baseline <file> with the findings you fixed, or write '
|
||||||
|
'$conventional with `pipe check $target --write-baseline '
|
||||||
|
'$conventional` before the fix',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Report which accepted findings are gone after a fix.
|
/// Report which accepted findings are gone after a fix.
|
||||||
class VerifyCommand extends Command<int> {
|
class VerifyCommand extends Command<int> {
|
||||||
VerifyCommand() {
|
VerifyCommand() {
|
||||||
|
|
@ -632,7 +680,9 @@ class VerifyCommand extends Command<int> {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'baseline',
|
'baseline',
|
||||||
help: 'Baseline of accepted findings to verify against (required).',
|
help:
|
||||||
|
'Baseline of accepted findings to verify against '
|
||||||
|
'(default: <path>/$defaultBaselineName when it exists).',
|
||||||
)
|
)
|
||||||
..addMultiOption('rule', help: 'Limit to specific rules (repeatable).')
|
..addMultiOption('rule', help: 'Limit to specific rules (repeatable).')
|
||||||
..addOption('top', defaultsTo: '25', help: 'Rows per section.')
|
..addOption('top', defaultsTo: '25', help: 'Rows per section.')
|
||||||
|
|
@ -653,13 +703,10 @@ class VerifyCommand extends Command<int> {
|
||||||
@override
|
@override
|
||||||
Future<int> run() async {
|
Future<int> run() async {
|
||||||
final results = argResults!;
|
final results = argResults!;
|
||||||
final baselinePath = results['baseline'] as String?;
|
final baselinePath = resolveVerifyBaseline(
|
||||||
if (baselinePath == null) {
|
results['baseline'] as String?,
|
||||||
throw PipeException(
|
_target(results),
|
||||||
'verify needs --baseline',
|
);
|
||||||
remedy: 'pass the baseline that contains the findings you fixed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Verifying a partial scan is unsound, and silently so: with --limit the
|
// Verifying a partial scan is unsound, and silently so: with --limit the
|
||||||
// findings that were never scanned are absent from the fresh report, which
|
// findings that were never scanned are absent from the fresh report, which
|
||||||
// `verify` reports as RESOLVED - claiming fixes that were never made.
|
// `verify` reports as RESOLVED - claiming fixes that were never made.
|
||||||
|
|
@ -980,9 +1027,18 @@ class QueriesCommand extends Command<int> {
|
||||||
final adapters = configPath == null
|
final adapters = configPath == null
|
||||||
? const <QueryAdapter>[]
|
? const <QueryAdapter>[]
|
||||||
: parseAdapterConfig(_readText(configPath, 'adapter config'));
|
: parseAdapterConfig(_readText(configPath, 'adapter config'));
|
||||||
|
// `pipe queries lib` means "the queries under lib of this package": the
|
||||||
|
// package graph lives at the package root, and the path is the scope.
|
||||||
|
final target = enclosingPackage(_target(results));
|
||||||
|
final dirs = results['dir'] as List<String>;
|
||||||
|
final scope = target.scope;
|
||||||
final project = await loadProject(
|
final project = await loadProject(
|
||||||
_target(results),
|
target.root,
|
||||||
directories: results['dir'] as List<String>,
|
directories: scope == null
|
||||||
|
? dirs
|
||||||
|
: results.wasParsed('dir')
|
||||||
|
? [for (final dir in dirs) p.join(scope, dir)]
|
||||||
|
: [scope],
|
||||||
);
|
);
|
||||||
final run = await resolveProject(
|
final run = await resolveProject(
|
||||||
project,
|
project,
|
||||||
|
|
|
||||||
|
|
@ -373,22 +373,49 @@ class _ReadCallCollector extends RecursiveAstVisitor<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects member reads (`x.someGetter`, `x.someField`) per file, so a public
|
/// Collects member reads (`x.someGetter`, `x.someField`) per scope, so a public
|
||||||
/// notifier getter that nothing reads can be identified as dead state.
|
/// notifier getter that nothing reads can be identified as dead state.
|
||||||
|
///
|
||||||
|
/// Reads inside a `ChangeNotifier` class body are kept under that class's own
|
||||||
|
/// scope ([memberUseScope]) and everything else under the file, so the rule can
|
||||||
|
/// discount a notifier reading itself without discounting the widget declared
|
||||||
|
/// next to it in the same file.
|
||||||
class _MemberUseCollector extends RecursiveAstVisitor<void> {
|
class _MemberUseCollector extends RecursiveAstVisitor<void> {
|
||||||
_MemberUseCollector(this.uses);
|
_MemberUseCollector(this.relativePath, this.uses);
|
||||||
|
|
||||||
final Set<String> uses;
|
final String relativePath;
|
||||||
|
final Map<String, Set<String>> uses;
|
||||||
|
String? _notifier;
|
||||||
|
|
||||||
|
Set<String> get _scope => uses.putIfAbsent(
|
||||||
|
_notifier == null ? relativePath : memberUseScope(relativePath, _notifier!),
|
||||||
|
() => <String>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void visitClassDeclaration(ClassDeclaration node) {
|
||||||
|
if (!_NotifierCollector._isChangeNotifier(node)) {
|
||||||
|
super.visitClassDeclaration(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final outer = _notifier;
|
||||||
|
_notifier = node.namePart.typeName.lexeme;
|
||||||
|
try {
|
||||||
|
super.visitClassDeclaration(node);
|
||||||
|
} finally {
|
||||||
|
_notifier = outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void visitPropertyAccess(PropertyAccess node) {
|
void visitPropertyAccess(PropertyAccess node) {
|
||||||
uses.add(node.propertyName.name);
|
_scope.add(node.propertyName.name);
|
||||||
super.visitPropertyAccess(node);
|
super.visitPropertyAccess(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
void visitPrefixedIdentifier(PrefixedIdentifier node) {
|
||||||
uses.add(node.identifier.name);
|
_scope.add(node.identifier.name);
|
||||||
super.visitPrefixedIdentifier(node);
|
super.visitPrefixedIdentifier(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -532,9 +559,9 @@ NotifierModel collectNotifierModel(
|
||||||
unit.accept(
|
unit.accept(
|
||||||
_ProviderDeclarationCollector(relative, lineInfo, declarations),
|
_ProviderDeclarationCollector(relative, lineInfo, declarations),
|
||||||
);
|
);
|
||||||
final uses = <String>{};
|
final uses = <String, Set<String>>{relative: <String>{}};
|
||||||
unit.accept(_MemberUseCollector(uses));
|
unit.accept(_MemberUseCollector(relative, uses));
|
||||||
memberUses[relative] = uses;
|
memberUses.addAll(uses);
|
||||||
unit.accept(_ReadInBuildCollector(relative, lineInfo, reads));
|
unit.accept(_ReadInBuildCollector(relative, lineInfo, reads));
|
||||||
unit.accept(_ProviderInBuildCollector(relative, lineInfo, createdInBuild));
|
unit.accept(_ProviderInBuildCollector(relative, lineInfo, createdInBuild));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,11 @@ class ProviderCreation {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The [NotifierModel.memberUses] key for reads inside [notifier]'s own class
|
||||||
|
/// body in [file]. A file key ends in `.dart`, so the two never collide, and a
|
||||||
|
/// class name is unique within its library.
|
||||||
|
String memberUseScope(String file, String notifier) => '$file#$notifier';
|
||||||
|
|
||||||
/// Everything the notifier/provider rules judge.
|
/// Everything the notifier/provider rules judge.
|
||||||
class NotifierModel {
|
class NotifierModel {
|
||||||
NotifierModel({
|
NotifierModel({
|
||||||
|
|
@ -397,7 +402,11 @@ class NotifierModel {
|
||||||
final Map<String, List<String>> consumedBy;
|
final Map<String, List<String>> consumedBy;
|
||||||
final List<ProviderDeclaration> declarations;
|
final List<ProviderDeclaration> declarations;
|
||||||
|
|
||||||
/// Project-relative file -> member names read in it.
|
/// Scope -> member names read in it. A scope is a project-relative file for
|
||||||
|
/// reads outside any notifier class, or [memberUseScope] for reads inside
|
||||||
|
/// one, so a notifier's reads of itself can be told apart from reads by the
|
||||||
|
/// rest of its file (a private controller and the widget that renders it
|
||||||
|
/// usually share one).
|
||||||
final Map<String, Set<String>> memberUses;
|
final Map<String, Set<String>> memberUses;
|
||||||
final List<NonSubscribingRead> nonSubscribingReads;
|
final List<NonSubscribingRead> nonSubscribingReads;
|
||||||
final List<ProvidersCreatedInBuild> createdInBuild;
|
final List<ProvidersCreatedInBuild> createdInBuild;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,40 @@ bool _isHidden(String path, String root) {
|
||||||
.any((segment) => segment.startsWith('.') && segment != '.');
|
.any((segment) => segment.startsWith('.') && segment != '.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A path split into the package that contains it and the directory under
|
||||||
|
/// that package's root it names.
|
||||||
|
class PackageTarget {
|
||||||
|
const PackageTarget(this.root, this.scope);
|
||||||
|
|
||||||
|
/// The package root: the path itself when it holds a `pubspec.yaml`, else
|
||||||
|
/// the nearest ancestor that does, else the path unchanged.
|
||||||
|
final String root;
|
||||||
|
|
||||||
|
/// The path relative to [root] when it named a directory inside the
|
||||||
|
/// package (`lib`, `lib/data`), or null when it named the root itself.
|
||||||
|
final String? scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The package enclosing [inputPath].
|
||||||
|
///
|
||||||
|
/// `pipe queries lib`, run from a package root, used to treat `lib` as the
|
||||||
|
/// project: it looked for `lib/.dart_tool/package_config.json` and told the
|
||||||
|
/// user to run `pub get` in `lib`. The package graph and the remedy belong to
|
||||||
|
/// the package root, and `lib` is only the scope within it.
|
||||||
|
PackageTarget enclosingPackage(String inputPath) {
|
||||||
|
final path = p.normalize(p.absolute(inputPath));
|
||||||
|
if (!Directory(path).existsSync() ||
|
||||||
|
File(p.join(path, 'pubspec.yaml')).existsSync()) {
|
||||||
|
return PackageTarget(path, null);
|
||||||
|
}
|
||||||
|
for (var dir = p.dirname(path); ; dir = p.dirname(dir)) {
|
||||||
|
if (File(p.join(dir, 'pubspec.yaml')).existsSync()) {
|
||||||
|
return PackageTarget(dir, p.relative(path, from: dir));
|
||||||
|
}
|
||||||
|
if (p.dirname(dir) == dir) return PackageTarget(path, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Load and validate a target project without touching the filesystem.
|
/// Load and validate a target project without touching the filesystem.
|
||||||
///
|
///
|
||||||
/// Fails loudly when the project has not been resolved, because analyzing an
|
/// Fails loudly when the project has not been resolved, because analyzing an
|
||||||
|
|
@ -74,9 +108,16 @@ Future<Project> loadProject(
|
||||||
p.join(root, '.dart_tool', 'package_config.json'),
|
p.join(root, '.dart_tool', 'package_config.json'),
|
||||||
);
|
);
|
||||||
if (!packageConfigFile.existsSync()) {
|
if (!packageConfigFile.existsSync()) {
|
||||||
|
// A directory inside a package has no graph of its own; resolving there
|
||||||
|
// would not help, so point at the package that owns it.
|
||||||
|
final package = enclosingPackage(root);
|
||||||
throw PipeException(
|
throw PipeException(
|
||||||
'no resolved package graph at .dart_tool/package_config.json',
|
'no resolved package graph at .dart_tool/package_config.json',
|
||||||
remedy: 'run `flutter pub get` or `dart pub get` in $root',
|
remedy: package.scope == null
|
||||||
|
? 'run `flutter pub get` or `dart pub get` in $root'
|
||||||
|
: '$root is inside the package at ${package.root}: pass that path '
|
||||||
|
'(with `--dir ${package.scope}` to narrow the scan), and run '
|
||||||
|
'`flutter pub get` or `dart pub get` there if it has not been',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,14 @@ String runProviderRules({
|
||||||
if (wantFieldUnread) {
|
if (wantFieldUnread) {
|
||||||
for (final notifier in notifiers) {
|
for (final notifier in notifiers) {
|
||||||
if ((consumedBy[notifier.name] ?? const <String>[]).isEmpty) continue;
|
if ((consumedBy[notifier.name] ?? const <String>[]).isEmpty) continue;
|
||||||
|
// Only the class's own body is discounted. Excluding its whole file
|
||||||
|
// reported a private controller whose widget, in the same file, renders
|
||||||
|
// every one of its getters.
|
||||||
|
final ownScope = memberUseScope(notifier.file, notifier.name);
|
||||||
final unread = <String>[];
|
final unread = <String>[];
|
||||||
for (final member in notifier.apiMembers) {
|
for (final member in notifier.apiMembers) {
|
||||||
final read = memberUses.entries
|
final read = memberUses.entries
|
||||||
.where((entry) => entry.key != notifier.file)
|
.where((entry) => entry.key != ownScope)
|
||||||
.any((entry) => entry.value.contains(member));
|
.any((entry) => entry.value.contains(member));
|
||||||
if (!read) unread.add(member);
|
if (!read) unread.add(member);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
205
test/field_unread_scope_test.dart
Normal file
205
test/field_unread_scope_test.dart
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:pipe/pipe.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// notifier-field-unread discounts reads inside the notifier's own class body,
|
||||||
|
/// and nothing else. It used to discount the notifier's whole file, so a
|
||||||
|
/// private controller rendered by the widget declared next to it was reported
|
||||||
|
/// as unread (nuwayze's `_AgentHubController`, eight getters, all rendered).
|
||||||
|
///
|
||||||
|
/// Each case uses member names no other case reads, because a read is matched
|
||||||
|
/// by name anywhere in the scan.
|
||||||
|
const Map<String, String> _files = {
|
||||||
|
'lib/support.dart': '''
|
||||||
|
class ChangeNotifier {
|
||||||
|
void notifyListeners() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BuildContext {
|
||||||
|
T watch<T>() => throw UnimplementedError();
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
// A private controller and the widget that renders it, in one file.
|
||||||
|
'lib/same_file.dart': '''
|
||||||
|
import 'support.dart';
|
||||||
|
|
||||||
|
class _SameFileController extends ChangeNotifier {
|
||||||
|
int _sameCount = 0;
|
||||||
|
String? _sameLoadError;
|
||||||
|
|
||||||
|
int get sameCount => _sameCount;
|
||||||
|
String? get sameLoadError => _sameLoadError;
|
||||||
|
|
||||||
|
void refresh() {
|
||||||
|
_sameCount++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SameFileView {
|
||||||
|
String build(BuildContext context) {
|
||||||
|
final controller = context.watch<_SameFileController>();
|
||||||
|
return '\${controller.sameCount} \${controller.sameLoadError}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
// A controller whose only reader is a part of its library.
|
||||||
|
'lib/part_host.dart': '''
|
||||||
|
import 'support.dart';
|
||||||
|
|
||||||
|
part 'part_host_view.dart';
|
||||||
|
|
||||||
|
class PartController extends ChangeNotifier {
|
||||||
|
int _partTotal = 0;
|
||||||
|
|
||||||
|
int get partTotal => _partTotal;
|
||||||
|
String get partStatus => 'ok';
|
||||||
|
|
||||||
|
void bump() {
|
||||||
|
_partTotal++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
'lib/part_host_view.dart': '''
|
||||||
|
part of 'part_host.dart';
|
||||||
|
|
||||||
|
class PartView {
|
||||||
|
String build(BuildContext context) {
|
||||||
|
final controller = context.watch<PartController>();
|
||||||
|
return '\${controller.partTotal} \${controller.partStatus}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
// `unreadHiddenCount` is read nowhere, though its sibling is rendered next
|
||||||
|
// door: the true positive the same-file fix must keep.
|
||||||
|
'lib/unread.dart': '''
|
||||||
|
import 'support.dart';
|
||||||
|
|
||||||
|
class UnreadController extends ChangeNotifier {
|
||||||
|
int _unreadShown = 0;
|
||||||
|
int _unreadHiddenCount = 0;
|
||||||
|
|
||||||
|
int get unreadShown => _unreadShown;
|
||||||
|
int get unreadHiddenCount => _unreadHiddenCount;
|
||||||
|
|
||||||
|
void bump() {
|
||||||
|
_unreadShown++;
|
||||||
|
_unreadHiddenCount++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnreadView {
|
||||||
|
String build(BuildContext context) =>
|
||||||
|
'\${context.watch<UnreadController>().unreadShown}';
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
// `selfErrorMessage` is read only by the class itself, which is not a
|
||||||
|
// render.
|
||||||
|
'lib/self_read.dart': '''
|
||||||
|
import 'support.dart';
|
||||||
|
|
||||||
|
class SelfReadController extends ChangeNotifier {
|
||||||
|
String? _selfErrorMessage;
|
||||||
|
|
||||||
|
String get selfLabel => 'label';
|
||||||
|
String? get selfErrorMessage => _selfErrorMessage;
|
||||||
|
|
||||||
|
// ignore: unnecessary_this
|
||||||
|
String describe() => 'error: \${this.selfErrorMessage}';
|
||||||
|
|
||||||
|
bool sameErrorAs(SelfReadController other) =>
|
||||||
|
other.selfErrorMessage == _selfErrorMessage;
|
||||||
|
|
||||||
|
void fail(String message) {
|
||||||
|
_selfErrorMessage = message;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SelfReadView {
|
||||||
|
String build(BuildContext context) =>
|
||||||
|
context.watch<SelfReadController>().selfLabel;
|
||||||
|
}
|
||||||
|
''',
|
||||||
|
};
|
||||||
|
|
||||||
|
String _project() {
|
||||||
|
final root = Directory.systemTemp.createTempSync('pipe-field-unread');
|
||||||
|
File('${root.path}/pubspec.yaml').writeAsStringSync('''
|
||||||
|
name: field_unread
|
||||||
|
publish_to: none
|
||||||
|
environment:
|
||||||
|
sdk: ^3.0.0
|
||||||
|
''');
|
||||||
|
File('${root.path}/.dart_tool/package_config.json')
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync('''
|
||||||
|
{
|
||||||
|
"configVersion": 2,
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "field_unread",
|
||||||
|
"rootUri": "../",
|
||||||
|
"packageUri": "lib/",
|
||||||
|
"languageVersion": "3.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
''');
|
||||||
|
for (final entry in _files.entries) {
|
||||||
|
File('${root.path}/${entry.key}')
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync(entry.value);
|
||||||
|
}
|
||||||
|
return root.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late String root;
|
||||||
|
late ProjectAnalysis analysis;
|
||||||
|
late Map<String, List<Object?>> unreadByOwner;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
root = _project();
|
||||||
|
analysis = await analyzeProject(
|
||||||
|
await loadProject(root),
|
||||||
|
concurrency: 1,
|
||||||
|
enabledRules: {ruleNotifierFieldUnread},
|
||||||
|
);
|
||||||
|
unreadByOwner = {
|
||||||
|
for (final finding in analysis.report.findings)
|
||||||
|
if (finding.rule == ruleNotifierFieldUnread)
|
||||||
|
finding.data['owner']! as String: finding.data['members']! as List,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await analysis.dispose();
|
||||||
|
Directory(root).deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the scan resolved every file', () {
|
||||||
|
// Otherwise a missing finding below could be an analysis failure.
|
||||||
|
expect(analysis.report.coverage!.resolved, _files.length);
|
||||||
|
expect(analysis.report.notes.join('\n'), isNot(contains('unresolved')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads by a widget in the notifier\'s own file count', () {
|
||||||
|
expect(unreadByOwner, isNot(contains('_SameFileController')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads in a part of the notifier\'s library count', () {
|
||||||
|
expect(unreadByOwner, isNot(contains('PartController')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a member read nowhere is still reported', () {
|
||||||
|
expect(unreadByOwner['UnreadController'], ['unreadHiddenCount']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a member read only inside its own class is still reported', () {
|
||||||
|
expect(unreadByOwner['SelfReadController'], ['selfErrorMessage']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -602,5 +602,61 @@ adapters:
|
||||||
expect(strict.exitCode, 2);
|
expect(strict.exitCode, 2);
|
||||||
expect(strict.stderr as String, contains('--require-firestore'));
|
expect(strict.stderr as String, contains('--require-firestore'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a directory inside the package is a scope, not a project', () async {
|
||||||
|
// `pipe queries lib` looked for lib/.dart_tool/package_config.json and
|
||||||
|
// failed. The graph is the package's; the path only narrows the scan.
|
||||||
|
final result = await pipe(['$fixturePath/lib/data']);
|
||||||
|
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||||
|
final json = jsonDecode(result.stdout as String) as Map<String, Object?>;
|
||||||
|
expect(json['root'], fixturePath);
|
||||||
|
final coverage = json['coverage'] as Map<String, Object?>;
|
||||||
|
expect(coverage['discovered'], 1);
|
||||||
|
expect(coverage['complete'], isTrue);
|
||||||
|
// lib/data holds only the gateway, so the queries the rest of lib issues
|
||||||
|
// are out of scope.
|
||||||
|
final sites = [
|
||||||
|
for (final query in json['queries'] as List<Object?>)
|
||||||
|
(query as Map<String, Object?>)['site'] as String,
|
||||||
|
];
|
||||||
|
expect(sites, everyElement(startsWith('lib/data/')));
|
||||||
|
expect(model.queries, isNotEmpty);
|
||||||
|
expect(json['unresolvedFirestoreUris'], isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('--dir under a scoped path is relative to that path', () async {
|
||||||
|
final result = await pipe(['$fixturePath/lib', '--dir', 'data']);
|
||||||
|
expect(result.exitCode, 0, reason: '${result.stderr}');
|
||||||
|
final json = jsonDecode(result.stdout as String) as Map<String, Object?>;
|
||||||
|
expect((json['coverage'] as Map)['discovered'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unresolved package is reported at its root, not the scope', () {
|
||||||
|
final copy = Directory.systemTemp.createTempSync('pipe-queries-fresh');
|
||||||
|
try {
|
||||||
|
File('${copy.path}/pubspec.yaml').writeAsStringSync('name: fresh\n');
|
||||||
|
File('${copy.path}/lib/fresh.dart')
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync('void main() {}\n');
|
||||||
|
final root = copy.resolveSymbolicLinksSync();
|
||||||
|
expect(
|
||||||
|
() => PipeRunner().run(['queries', '$root/lib']),
|
||||||
|
throwsA(
|
||||||
|
isA<PipeException>()
|
||||||
|
.having((e) => e.message, 'message', contains('package graph'))
|
||||||
|
.having(
|
||||||
|
(e) => e.remedy,
|
||||||
|
'remedy',
|
||||||
|
allOf(
|
||||||
|
endsWith('pub get` in $root'),
|
||||||
|
isNot(contains('$root/lib')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
copy.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
test/package_target_test.dart
Normal file
64
test/package_target_test.dart
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pipe/pipe.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late String root;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
root = Directory.systemTemp
|
||||||
|
.createTempSync('pipe-package-target')
|
||||||
|
.resolveSymbolicLinksSync();
|
||||||
|
File(p.join(root, 'pubspec.yaml')).writeAsStringSync('name: outer\n');
|
||||||
|
Directory(p.join(root, 'lib', 'data')).createSync(recursive: true);
|
||||||
|
File(p.join(root, 'lib', 'data', 'a.dart')).writeAsStringSync('');
|
||||||
|
File(p.join(root, 'nested', 'pubspec.yaml'))
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsStringSync('name: nested\n');
|
||||||
|
Directory(p.join(root, 'nested', 'lib')).createSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => Directory(root).deleteSync(recursive: true));
|
||||||
|
|
||||||
|
group('enclosingPackage', () {
|
||||||
|
test('a package root is its own package, with no scope', () {
|
||||||
|
final target = enclosingPackage(root);
|
||||||
|
expect(target.root, root);
|
||||||
|
expect(target.scope, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a directory inside a package resolves to the package root', () {
|
||||||
|
final target = enclosingPackage(p.join(root, 'lib', 'data'));
|
||||||
|
expect(target.root, root);
|
||||||
|
expect(target.scope, p.join('lib', 'data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the nearest pubspec wins', () {
|
||||||
|
final target = enclosingPackage(p.join(root, 'nested', 'lib'));
|
||||||
|
expect(target.root, p.join(root, 'nested'));
|
||||||
|
expect(target.scope, 'lib');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing path is returned unchanged for loadProject to reject', () {
|
||||||
|
final missing = p.join(root, 'nope');
|
||||||
|
expect(enclosingPackage(missing).root, missing);
|
||||||
|
expect(enclosingPackage(missing).scope, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadProject on a directory inside a package names the package', () {
|
||||||
|
// `pipe check lib` used to say "run `dart pub get` in .../lib".
|
||||||
|
expect(
|
||||||
|
() => loadProject(p.join(root, 'lib')),
|
||||||
|
throwsA(
|
||||||
|
isA<PipeException>().having(
|
||||||
|
(e) => e.remedy,
|
||||||
|
'remedy',
|
||||||
|
allOf(contains('inside the package at $root'), contains('--dir lib')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
104
test/verify_default_baseline_test.dart
Normal file
104
test/verify_default_baseline_test.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:pipe/pipe.dart';
|
||||||
|
import 'package:pipe/src/cli.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
/// A copy of the rule fixture, so a baseline can be written at its root.
|
||||||
|
String _fixtureCopy() {
|
||||||
|
final from = Directory('test/fixtures/stuck_loading');
|
||||||
|
final to = Directory.systemTemp.createTempSync('pipe-verify-default');
|
||||||
|
for (final entity in from.listSync(recursive: true)) {
|
||||||
|
final target = p.join(to.path, p.relative(entity.path, from: from.path));
|
||||||
|
if (entity is Directory) {
|
||||||
|
Directory(target).createSync(recursive: true);
|
||||||
|
} else if (entity is File) {
|
||||||
|
File(target)
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsBytesSync(entity.readAsBytesSync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return to.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// `pipe verify .` used to fail with "verify needs --baseline" even in a repo
|
||||||
|
// whose baseline sits at the conventional path the README's CI recipe
|
||||||
|
// writes (nuwayze's scripts/testing/pipe_state_flow_gate.sh).
|
||||||
|
group('resolveVerifyBaseline', () {
|
||||||
|
late Directory temp;
|
||||||
|
|
||||||
|
setUp(() => temp = Directory.systemTemp.createTempSync('pipe-verify-bl'));
|
||||||
|
tearDown(() => temp.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
test('an explicit --baseline wins, with nothing announced', () {
|
||||||
|
File(p.join(temp.path, defaultBaselineName)).writeAsStringSync('{}');
|
||||||
|
final log = StringBuffer();
|
||||||
|
expect(
|
||||||
|
resolveVerifyBaseline('other.json', temp.path, log: log),
|
||||||
|
'other.json',
|
||||||
|
);
|
||||||
|
expect(log.toString(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without --baseline the conventional file is used and announced', () {
|
||||||
|
final conventional = p.join(temp.path, defaultBaselineName);
|
||||||
|
File(conventional).writeAsStringSync('{}');
|
||||||
|
final log = StringBuffer();
|
||||||
|
expect(resolveVerifyBaseline(null, temp.path, log: log), conventional);
|
||||||
|
expect(log.toString(), contains('using $conventional'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without either, the error names both remedies', () {
|
||||||
|
expect(
|
||||||
|
() => resolveVerifyBaseline(null, temp.path, log: StringBuffer()),
|
||||||
|
throwsA(
|
||||||
|
isA<PipeException>()
|
||||||
|
.having((e) => e.message, 'message', contains('--baseline'))
|
||||||
|
.having(
|
||||||
|
(e) => e.remedy,
|
||||||
|
'remedy',
|
||||||
|
allOf(
|
||||||
|
contains('--baseline'),
|
||||||
|
contains(p.join(temp.path, defaultBaselineName)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pipe verify <path> reads <path>/.pipe-baseline.json', () async {
|
||||||
|
final root = _fixtureCopy();
|
||||||
|
try {
|
||||||
|
final baseline = p.join(root, defaultBaselineName);
|
||||||
|
expect(
|
||||||
|
await PipeRunner().run([
|
||||||
|
'check',
|
||||||
|
root,
|
||||||
|
'--write-baseline',
|
||||||
|
baseline,
|
||||||
|
'-c',
|
||||||
|
'1',
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
// Nothing changed since the baseline, so every accepted finding remains
|
||||||
|
// and --expect-resolved fails: proof the default baseline was read,
|
||||||
|
// since an empty one would have nothing to expect.
|
||||||
|
expect(
|
||||||
|
await PipeRunner().run([
|
||||||
|
'verify',
|
||||||
|
root,
|
||||||
|
'--expect-resolved',
|
||||||
|
'-c',
|
||||||
|
'1',
|
||||||
|
]),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
Directory(root).deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue