merge/audit-and-state-map #7

Merged
trevon merged 15 commits from merge/audit-and-state-map into main 2026-09-25 00:07:04 +00:00
5 changed files with 282 additions and 0 deletions
Showing only changes of commit 17ff523a11 - Show all commits

fix(check): lower confidence where unresolved code can hide evidence

check never looked at analyzer errors, so a project that resolved only
partly was analysed as if it were whole. pipe reads names off the syntax
tree, and an unresolved `Foo()` is a method call rather than a
construction, so a dispatch, emission or construction in such a file
simply disappears - and every finding is an absence claim that the
missing evidence could refute.

Found live during this work: with the Flutter SDK unreadable, nuwayze
resolved with 40,079 analyzer errors (package:flutter unresolvable) and
check still reported 102 findings at full confidence, with no warning.

Now, when any analysed file has an unresolved-reference diagnostic
(uri_does_not_exist, undefined_*, creation_with_non_type, ...):
- check warns on stderr and adds a report note naming the first one;
- a finding is lowered one confidence step, with
  data.confidenceLowered = unresolved-references, when an unresolved file
  mentions one of its identity names (event, variant, union, owner,
  provider, member). pipe's evidence is name-based, so a file that never
  names X cannot hide evidence about X; lowering everything for one stray
  error would bury the signal (a first cut did exactly that, taking all
  102 nuwayze findings down for a single unresolved file).

Validated on mirrors of the dogfood repos with a readable SDK (0 analyzer
errors): nuwayze 102 findings identical to the original main, dex 0,
word_alive 1 and tick 1 (the intended riverpod change). The fixture
test adds an unresolved file that constructs NeverDispatched: its
dead-handler finding drops to medium while UnhandledTap, which that file
never names, stays high.
trvon 2026-09-22 14:04:32 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -100,6 +100,15 @@ the standalone SDK. If the project has never been resolved, `pipe` says so and
tells you to run `pub get` rather than silently degrading every `package:` import
to `dynamic`.
A project that resolves only partly is reported, not silently analysed: an
unresolved `Foo()` is a method call rather than a construction, so evidence in
that file is invisible. `check` warns on stderr, notes how many files have
unresolved references, and lowers by one step the confidence of every finding
whose names such a file mentions (`data.confidenceLowered`). An SDK that exists
but cannot be read (for example a Flutter SDK under a privacy-protected macOS
folder, which makes `package:flutter` unresolvable) is refused up front with a
`--sdk` remedy.
## CI and ratcheting
An existing codebase will have findings on day one. Accept them once, then fail

View file

@ -4,6 +4,8 @@ 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/physical_file_system.dart';
@ -35,6 +37,100 @@ class FileOutcome {
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,
);
}
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);
}
}
/// Owns the analyzer contexts for one target project.
///
/// Mirrors the lifecycle Flutter's own tooling uses: one collection rooted at

View file

@ -1,3 +1,6 @@
import 'dart:io';
import 'analysis.dart';
import 'index.dart';
import 'project.dart';
import 'rules.dart';
@ -42,6 +45,15 @@ Future<ProjectAnalysis> analyzeProject(
);
try {
final flow = buildStateFlow(project, run.outcomes);
final unresolved = findUnresolvedReferences(run.outcomes);
if (unresolved.any) {
stderr.writeln(
'pipe: WARNING ${unresolved.files.length} of ${unresolved.analysed} '
'analysed file(s) have unresolved references, so findings naming '
'anything they mention may be false and have lowered confidence. Run '
'`pipe index` to see why.',
);
}
final report = runChecks(
project: project,
outcomes: run.outcomes,

View file

@ -129,6 +129,23 @@ class Finding {
return file ?? fingerprint;
}
/// This finding one confidence step lower, recording why.
Finding lowered(String reason) => Finding(
rule: rule,
severity: severity,
confidence: switch (confidence) {
'high' => 'medium',
_ => 'low',
},
message: message,
file: file,
line: line,
evidence: evidence,
remediation: remediation,
data: {...data, 'confidenceLowered': reason},
baselined: baselined,
);
Finding asBaselined() => Finding(
rule: rule,
severity: severity,
@ -821,6 +838,34 @@ FindingReport runChecks({
if (note.isNotEmpty) notes.add(note);
}
// Every finding is a whole-program absence claim (never rendered, never
// dispatched, never notified), and an unresolved reference can hide the
// evidence that refutes it: an unresolved `Foo()` is a method call, not a
// construction. On a real app whose package:flutter could not be read,
// findings were reported at high confidence with no indication. pipe's
// evidence is name-based, so only a finding whose names an unresolved file
// mentions can have lost evidence there; lowering every finding for one
// stray error elsewhere would bury the signal.
final unresolved = findUnresolvedReferences(outcomes);
if (unresolved.any) {
var lowered = 0;
for (var i = 0; i < findings.length; i++) {
if (!_identityNames(findings[i]).any(unresolved.names.contains)) continue;
findings[i] = findings[i].lowered('unresolved-references');
lowered++;
}
final first = unresolved.first!;
notes.add(
'${unresolved.files.length} of ${unresolved.analysed} analysed file(s) '
'have unresolved references (first: '
'${first.replaceFirst('${project.root}/', '')}). An unresolved `Foo()` '
'is not seen as a construction, so evidence in those files can be '
'missed: $lowered finding(s) naming something they mention had their '
'confidence lowered one step. Run `pipe index` and resolve the project '
'(`pub get`, a readable SDK) for full confidence.',
);
}
final resolved = baseline == null
? findings
: [
@ -1022,6 +1067,31 @@ class _CalledNames extends RecursiveAstVisitor<void> {
}
}
/// The names a finding makes a claim about: the values of its identity keys.
Set<String> _identityNames(Finding finding) {
const keys = [
'union',
'variant',
'variants',
'group',
'event',
'provider',
'owner',
'member',
'members',
'stateType',
'valueType',
'valueTypes',
];
final names = <String>{};
for (final key in keys) {
final value = finding.data[key];
if (value is String) names.add(value);
if (value is List) names.addAll(value.whereType<String>());
}
return names;
}
/// `on<E>` for a bloc handler closure, so two handlers in one class get
/// distinct identities. As a bare `<closure>` they shared a fingerprint, and
/// the baseline dropped all but one of them.

95
test/unresolved_test.dart Normal file
View file

@ -0,0 +1,95 @@
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
/// The fixture plus one file whose references do not resolve: it constructs
/// `NeverDispatched` without importing it, and imports a file that is missing.
String _corpusWithUnresolvedFile() {
final from = Directory('test/fixtures/stuck_loading');
final to = Directory.systemTemp.createTempSync('pipe-unresolved');
for (final entity in from.listSync(recursive: true)) {
final relative = entity.path.substring(from.path.length + 1);
final target = '${to.path}/$relative';
if (entity is Directory) {
Directory(target).createSync(recursive: true);
} else if (entity is File) {
File(target)
..createSync(recursive: true)
..writeAsBytesSync(entity.readAsBytesSync());
}
}
File('${to.path}/lib/unresolved_ui.dart').writeAsStringSync('''
import 'widgets_that_do_not_exist.dart';
// Unresolved, `NeverDispatched()` parses as a method call rather than a
// construction, so the dispatch below is invisible to the analysis.
void tapNever(dynamic bloc) => bloc.add(NeverDispatched());
''');
return to.path;
}
void main() {
test(
'a finding an unresolved file could refute is not high confidence',
() async {
// Every pipe finding is a whole-program absence claim, and an unresolved
// reference anywhere can hide the evidence that refutes it. On a real app
// whose package:flutter could not be read (40k analyzer errors), check
// reported findings at high confidence with no warning at all.
final analysis = await analyzeProject(
await loadProject(_corpusWithUnresolvedFile()),
concurrency: 1,
);
try {
final findings = analysis.report.findings;
final dead = findings.singleWhere(
(f) =>
f.rule == ruleDeadHandler && f.data['event'] == 'NeverDispatched',
);
expect(dead.confidence, 'medium');
expect(dead.data['confidenceLowered'], 'unresolved-references');
// The unresolved file never mentions UnhandledTap, so it cannot hide
// evidence about it: one stray error must not bury every finding.
final untouched = findings.singleWhere(
(f) =>
f.rule == ruleEventWithoutHandler &&
f.data['event'] == 'UnhandledTap',
);
expect(untouched.confidence, 'high');
expect(untouched.data.containsKey('confidenceLowered'), isFalse);
expect(
analysis.report.notes.join('\n'),
allOf(
contains('unresolved references'),
contains('unresolved_ui.dart'),
contains('1 finding(s)'),
),
);
} finally {
await analysis.dispose();
}
},
);
test('a fully resolved project keeps its confidence', () async {
final analysis = await analyzeProject(
await loadProject('test/fixtures/stuck_loading'),
concurrency: 1,
);
try {
expect(
analysis.report.findings.where((f) => f.confidence == 'high'),
isNotEmpty,
);
expect(
analysis.report.findings.where(
(f) => f.data.containsKey('confidenceLowered'),
),
isEmpty,
);
} finally {
await analysis.dispose();
}
});
}