fix: stop verify, reports and briefs from claiming more than pipe checked #1

Merged
trevon merged 12 commits from fix/verify-soundness-and-injection into main 2026-09-22 18:24:06 +00:00
6 changed files with 124 additions and 6 deletions
Showing only changes of commit ae412a7964 - Show all commits

fix(cli): reject a missing --dir and report filesystem errors cleanly

With several --dir values, one that did not exist was skipped silently: the
no-files guard only fires when every directory is empty, so
`--dir lib --dir tset` scanned lib and reported as though it had covered what
was asked for. A --dir that does not exist is now an error naming it.

A directory that could not be listed, or a baseline that could not be read or
written, escaped as an uncaught FileSystemException: exit 255 and a stack
trace. Those paths now raise a PipeException with the OS reason (exit 2), and
bin/pipe.dart catches any remaining FileSystemException the same way. The exit
codes are documented in bin/pipe.dart: 0 success, 1 a gate failed, 2 pipe could
not do what was asked, 64 usage.

rule_scope_claim_test widened the fixture with a test/ directory it does not
have, relying on exactly the silent skip; it now builds a project that has one.
trvon 2026-09-22 11:57:36 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -4,6 +4,14 @@ import 'package:args/command_runner.dart';
import 'package:pipe/src/cli.dart';
import 'package:pipe/src/errors.dart';
/// Exit codes:
///
/// - 0: success.
/// - 1: a gate failed: `check --fail-on`, `verify --expect-resolved`,
/// `recall --strict`, or `guide --yams` could not persist.
/// - 2: pipe could not do what was asked (a [PipeException], or a filesystem
/// error such as an unreadable or unwritable path).
/// - 64: invalid command-line usage.
Future<void> main(List<String> arguments) async {
final runner = PipeRunner();
try {
@ -11,6 +19,15 @@ Future<void> main(List<String> arguments) async {
} on PipeException catch (error) {
stderr.writeln(error);
exitCode = 2;
} on FileSystemException catch (error) {
// Any filesystem failure not already turned into a PipeException still gets
// a one-line message and a documented exit code, not a stack trace and 255.
stderr.writeln(
'pipe: ${error.message}'
'${error.path == null ? '' : ': ${error.path}'}'
'${error.osError == null ? '' : ' (${error.osError!.message})'}',
);
exitCode = 2;
} on UsageException catch (error) {
stderr.writeln(error.message);
stderr.writeln(error.usage);

View file

@ -93,6 +93,18 @@ int readPositiveInt(ArgResults results, String name, {required int fallback}) {
return value;
}
/// Read [path], turning a filesystem failure into a clean [PipeException]
/// (exit 2) instead of an uncaught exception (exit 255 and a stack trace).
String _readText(String path, String what) {
try {
return File(path).readAsStringSync();
} on FileSystemException catch (error) {
throw PipeException(
'cannot read $what $path: ${error.osError?.message ?? error.message}',
);
}
}
Set<String>? _readBaseline(ArgResults results) {
final path = results['baseline'] as String?;
if (path == null) return null;
@ -103,13 +115,21 @@ Set<String>? _readBaseline(ArgResults results) {
remedy: 'create one with --write-baseline $path',
);
}
return decodeBaseline(file.readAsStringSync());
return decodeBaseline(_readText(path, 'baseline'));
}
void _writeBaseline(ArgResults results, String root, List<Finding> findings) {
final path = results['write-baseline'] as String?;
if (path == null) return;
File(path).writeAsStringSync(encodeBaseline(root, findings));
try {
File(path).writeAsStringSync(encodeBaseline(root, findings));
} on FileSystemException catch (error) {
throw PipeException(
'cannot write baseline $path: '
'${error.osError?.message ?? error.message}',
remedy: 'check that the directory exists and is writable',
);
}
// Status goes to stderr: after `--format json` stdout is the JSON document,
// and a trailing status line made it unparseable.
stderr.writeln(
@ -511,7 +531,9 @@ class VerifyCommand extends Command<int> {
if (!baselineFile.existsSync()) {
throw PipeException('baseline file not found: $baselinePath');
}
final entries = decodeBaselineEntries(baselineFile.readAsStringSync());
final entries = decodeBaselineEntries(
_readText(baselinePath, 'baseline'),
);
final selected = _readRules(results);
final project = await loadProject(

View file

@ -106,8 +106,27 @@ Future<Project> loadProject(
final files = <String>[];
for (final directory in directories) {
final dir = Directory(p.join(root, directory));
if (!dir.existsSync()) continue;
for (final entity in dir.listSync(recursive: true, followLinks: false)) {
// A directory that does not exist is a mistake, not an empty scope. With
// one --dir the no-files guard below caught it, but with several a typo in
// one was skipped silently and the scan reported on the rest as if it were
// what was asked for.
if (!dir.existsSync()) {
throw PipeException(
'--dir $directory does not exist under $root',
remedy: 'check the spelling; --dir is relative to the project root',
);
}
final List<FileSystemEntity> entities;
try {
entities = dir.listSync(recursive: true, followLinks: false);
} on FileSystemException catch (error) {
throw PipeException(
'cannot list --dir $directory: '
'${error.osError?.message ?? error.message} (${error.path ?? dir.path})',
remedy: 'check that the directory is readable',
);
}
for (final entity in entities) {
if (entity is! File) continue;
if (!entity.path.endsWith('.dart')) continue;
if (_isHidden(entity.path, root)) continue;

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
@ -166,4 +168,27 @@ void main() {
},
);
});
test('an unwritable baseline path is a clean error, not a crash', () async {
// writeAsStringSync threw an uncaught FileSystemException: exit 255 and a
// stack trace. It is now a PipeException, which exits 2 with one line.
final missing = Directory.systemTemp.createTempSync('pipe-nowrite');
await expectLater(
PipeRunner().run([
'check',
'test/fixtures/stuck_loading',
'--write-baseline',
'${missing.path}/no/such/dir/baseline.json',
'-c',
'1',
]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('cannot write baseline'),
),
),
);
});
}

View file

@ -49,6 +49,25 @@ void main() {
},
);
test('one mistyped --dir among several is an error, not skipped', () async {
// With a single --dir the no-files guard caught a typo, but with several a
// missing one was skipped silently and the scan reported on the others as
// though it covered what was asked for.
await expectLater(
loadProject(
'test/fixtures/stuck_loading',
directories: const ['lib', 'tset'],
),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('--dir tset does not exist'),
),
),
);
});
test('a real project still loads', () async {
final project = await loadProject('test/fixtures/stuck_loading');
expect(project.dartFiles, isNotEmpty);

View file

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:test/test.dart';
@ -47,8 +48,23 @@ void main() {
final defaulted = await loadProject('test/fixtures/stuck_loading');
expect(defaulted.directories, const ['lib']);
// The fixture has no test/ directory, and a --dir that does not exist is
// now an error rather than skipped, so widen a copy that has one.
final temp = Directory.systemTemp.createTempSync('pipe-widened');
Directory('${temp.path}/.dart_tool').createSync();
File('${temp.path}/.dart_tool/package_config.json').writeAsStringSync(
File(
'test/fixtures/stuck_loading/.dart_tool/package_config.json',
).readAsStringSync(),
);
File('${temp.path}/lib/a.dart')
..createSync(recursive: true)
..writeAsStringSync('class A {}\n');
File('${temp.path}/test/a_test.dart')
..createSync(recursive: true)
..writeAsStringSync('void main() {}\n');
final widened = await loadProject(
'test/fixtures/stuck_loading',
temp.path,
directories: const ['lib', 'test'],
);
expect(widened.directories, const ['lib', 'test']);