Add `pipe queries`, which lists every Firestore query a project can issue: scope, collection, path pattern, filters with operators and value types, composites, orderBy, limit, cursors, realtime, aggregation, in-loop, plus a confidence and the reasons it is not exact. Receivers are matched by static type and cloud_firestore library URI, never by name. Queries are followed through chains, local variables (flow-insensitive with two cuts), fields, getters and one level of helper methods with arguments bound. An --adapter-config (YAML or JSON) maps declarative query APIs such as a server-side gateway onto the same schema. Inventory only: no rules, findings or baseline changes. Every emitted name is validated against a safe character set so no source text reaches the output.
202 lines
6.2 KiB
Dart
202 lines
6.2 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:args/args.dart';
|
|
import 'package:pipe/pipe.dart';
|
|
import 'package:pipe/src/cli.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
/// Options every project-targeting command reads out of its own ArgResults.
|
|
///
|
|
/// A command whose body reads an option it never registered does not fail with a
|
|
/// usage message - it dies on an unhandled `ArgResults` exception, exit 255.
|
|
/// `pipe graph` was exactly that: it read `--dir`, `--sdk`, `--concurrency`,
|
|
/// `--include-generated`, `--limit` and `--format`, but its class had no
|
|
/// constructor, so it registered nothing but `--help` and every invocation
|
|
/// crashed. This test is the guard for that whole class of mistake.
|
|
const _commonOptions = <String>[
|
|
'concurrency',
|
|
'dir',
|
|
'format',
|
|
'include-generated',
|
|
'limit',
|
|
'sdk',
|
|
];
|
|
|
|
void main() {
|
|
test('every project command registers the common options it reads', () {
|
|
final runner = PipeRunner();
|
|
|
|
for (final entry in runner.commands.entries) {
|
|
final name = entry.key;
|
|
final options = entry.value.argParser.options.keys;
|
|
|
|
// The built-in help command takes no project.
|
|
if (name == 'help') continue;
|
|
|
|
// `recall` deliberately targets a corpus root plus its own manifest
|
|
// rather than a project path, and registers its own equivalents.
|
|
if (name == 'recall') {
|
|
for (final option in [
|
|
'concurrency',
|
|
'format',
|
|
'manifest',
|
|
'root',
|
|
'sdk',
|
|
]) {
|
|
expect(
|
|
options,
|
|
contains(option),
|
|
reason: 'recall must register --$option',
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (final option in _commonOptions) {
|
|
expect(
|
|
options,
|
|
contains(option),
|
|
reason: '$name reads --$option, so it must register it',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('queries registers its own options on top of the common ones', () {
|
|
final queries = PipeRunner().commands['queries']!;
|
|
final options = queries.argParser.options;
|
|
expect(options.keys, containsAll(['adapter-config', 'require-firestore']));
|
|
expect(options['format']!.allowed, ['json', 'md']);
|
|
expect(options['format']!.defaultsTo, 'json');
|
|
});
|
|
|
|
test('the fixture project is a valid target for every command parser', () {
|
|
final runner = PipeRunner();
|
|
for (final entry in runner.commands.entries) {
|
|
if (entry.key == 'help') continue;
|
|
expect(
|
|
() => entry.value.argParser.parse(['.', '--format', 'json']),
|
|
returnsNormally,
|
|
reason: '${entry.key} rejected a plain JSON invocation',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('graph runs end to end, not just through its parser', () async {
|
|
// Registration alone is not enough: this asserts the command completes.
|
|
// Before the fix GraphCommand threw
|
|
// `ArgumentError: Could not find an option named "--dir"` at run time, so
|
|
// a parser-only test plus this one are the pair that pins it down.
|
|
final runner = PipeRunner();
|
|
final code = await runner.run([
|
|
'graph',
|
|
'test/fixtures/stuck_loading',
|
|
'--format',
|
|
'text',
|
|
'-c',
|
|
'1',
|
|
]);
|
|
expect(code, 0);
|
|
});
|
|
|
|
group('numeric options', () {
|
|
ArgResults parse(String name, String value) {
|
|
final parser = ArgParser()..addOption(name);
|
|
return parser.parse(['--$name', value]);
|
|
}
|
|
|
|
test('reject anything that is not a positive integer', () {
|
|
// These used to be read as `int.tryParse(...) ?? default`, so `--top 2o`
|
|
// silently produced the default number of rows. A user typo must not be
|
|
// answered with a different question's answer.
|
|
for (final bad in ['abc', '2o', '0', '-3', '', '1.5']) {
|
|
expect(
|
|
() => readPositiveInt(parse('top', bad), 'top', fallback: 25),
|
|
throwsA(isA<PipeException>()),
|
|
reason: '--top "$bad" must be rejected, not silently defaulted',
|
|
);
|
|
}
|
|
});
|
|
|
|
test('accept a positive integer, and fall back only when absent', () {
|
|
expect(readPositiveInt(parse('top', '7'), 'top', fallback: 25), 7);
|
|
|
|
final absent = (ArgParser()..addOption('top')).parse([]);
|
|
expect(readPositiveInt(absent, 'top', fallback: 25), 25);
|
|
});
|
|
|
|
test('a real command rejects a bad value instead of defaulting', () async {
|
|
final runner = PipeRunner();
|
|
await expectLater(
|
|
runner.run([
|
|
'check',
|
|
'test/fixtures/stuck_loading',
|
|
'--top',
|
|
'abc',
|
|
'-c',
|
|
'1',
|
|
]),
|
|
throwsA(isA<PipeException>()),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'a numeric option is rejected on every format, not only on the branch that reads it',
|
|
() async {
|
|
// `suggest` used to read each numeric option inside the one output branch
|
|
// that uses it - --top in the text branch, --max-actions in the agent
|
|
// branch - so a malformed value was silently ignored on the other paths.
|
|
final runner = PipeRunner();
|
|
await expectLater(
|
|
runner.run([
|
|
'suggest',
|
|
'test/fixtures/stuck_loading',
|
|
'--format',
|
|
'agent',
|
|
'--top',
|
|
'abc',
|
|
'-c',
|
|
'1',
|
|
]),
|
|
throwsA(isA<PipeException>()),
|
|
);
|
|
await expectLater(
|
|
runner.run([
|
|
'suggest',
|
|
'test/fixtures/stuck_loading',
|
|
'--format',
|
|
'text',
|
|
'--max-actions',
|
|
'abc',
|
|
'-c',
|
|
'1',
|
|
]),
|
|
throwsA(isA<PipeException>()),
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
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'),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|