Found by dogfooding on nuwayze: `pipe queries lib` from the repo root
failed with
no resolved package graph at .dart_tool/package_config.json
remedy: run `flutter pub get` or `dart pub get` in .../nuwayze-w3/lib
It failed even with the package resolved, because the path was taken
as the project root: pipe looked for lib/.dart_tool/package_config.json,
which never exists, and the remedy sent the user to run pub get in a
directory that has no pubspec.
enclosingPackage() (project.dart) splits a path into the package that
contains it (the path itself when it holds a pubspec.yaml, else the
nearest ancestor that does) and the directory it names under that
root. `pipe queries` loads the package root and scans only that
directory, so `pipe queries lib/services` is `--dir lib/services` on
the package; an explicit --dir with such a path is relative to it.
Without a resolved graph the error now names the package root.
loadProject's own remedy uses the same lookup, so `pipe check lib`
(and the other commands, which still take the path as the root) now
says the path is inside the package at <root> and suggests passing
that with `--dir lib`, instead of pub get in lib.
Tests: enclosingPackage on a root, a nested directory, a nested
package and a missing path; loadProject's remedy for a directory
inside a package; and `pipe queries` on the fixture's lib/data
(root reported as the package, one file discovered), with
`lib --dir data`, and on an unresolved package where the remedy names
the root, not lib.
On nuwayze-w3: `pipe queries lib` exits 0 with 313 queries over 847
files, identical to `pipe queries .`; `pipe queries lib/services` scans
161 files and every one of its 212 queries sits under lib/services.
233 tests pass.
12 KiB
Firestore query inventory
pipe queries lists every Firestore query the scanned code can issue, as
typed, machine-readable records. It is an inventory only: no rules, no
findings, no baseline, and deliberately no index analysis. Deciding whether a
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.
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:
- cloud_firestore chains. Receivers are identified by static type and
library: an
InterfaceTypenamedFirebaseFirestore,CollectionReference,Query,DocumentReference,AggregateQuery,FilterorFieldPathwhose library URI starts withpackage:cloud_firestore/orpackage:cloud_firestore_platform_interface/. Nothing is matched by name, soIterable.where/take, or a project class calledQuery, never is. - One record per terminal call:
get()(realtime: false),snapshots()(realtime: true),count()/aggregate(...)(aggregate: count|sum|average) on aQueryorCollectionReference.DocumentReference.get/snapshotsare point reads that need no index: they are counted intotals.documentReadsand never listed. collection(path),collectionGroup(id)anddoc(...)segments becomescope,collection(the last collection id) and apathpattern such asstores/*/products. Document ids are expected to be dynamic and cost nothing; a dynamic collection id makes the recordpartial, and a fully dynamic one also setscollection: null.where(...)with every named operator (isEqualTo==,isNotEqualTo!=,isLessThan<,isLessThanOrEqualTo<=,isGreaterThan>,isGreaterThanOrEqualTo>=,arrayContainsarray-contains,arrayContainsAnyarray-contains-any,whereInin,whereNotInnot-in,isNull: true==null,isNull: false!=null); a field may be a string,FieldPath([...])(dotted) orFieldPath.documentId(__name__).where(Filter.or(...))/Filter.and(...)become acompositenode.orderBy(field, descending:),limit,limitToLast(limit.last: true), and any cursor (startAt,startAfter,endAt,endBeforeand their...Documentforms) ascursor: true.withConverterpasses through.- Strings resolve through literals, adjacent and interpolated strings (
*for each unknown part),+,constandfinalvariables with a literal initializer, and one-return path helpers (storePath(id) => 'stores/$id') with their arguments bound. - Built queries. A local
Queryvariable is the union of its definitions in its function body; a clause added under anif, ternary,switch, loop,catchor closure isconditional: true. Two cuts keep that union honest: an assignment textually after the terminal is ignored unless both sit in one loop, and anifthat encloses both the assignment and the terminal does not make the clause conditional. Fields are followed through their initializer, constructor initializers and assignments in their class. - Helpers. A getter, method or function in the project that returns a query
is summarised and inlined at its call sites with the call's arguments bound.
Methods are inlined one level deep; getters and fields take no arguments
and do not count against that. A query that arrives as a parameter, or from a
call pipe cannot follow, is
origin: unknown,confidence: unknown, with the clauses added locally still recorded. inLoop: truewhen the terminal is inside afor/for-in/while/dobody or a collectionfor, or inside a closure passed toIterable.map/forEach/expand,Stream.asyncMap, or withinFuture.wait(...). A fixedFuture.wait([a.get(), b.get()])is not a loop.
Schema
{
"version": 1,
"root": "relative path, or the directory name when outside the cwd",
"coverage": {"discovered": 827, "selected": 827, "resolved": 825, "failed": 0, "skipped": 2, "complete": false},
"totals": {
"queries": 372,
"byConfidence": {"exact": 336, "partial": 28, "unknown": 8},
"byScope": {"collection": 297, "collectionGroup": 66, "unknown": 9},
"byOrigin": {"chain": 189, "helper": 47, "unknown": 8, "variable": 128},
"realtime": 28, "inLoop": 37, "documentReads": 165,
"collections": {"bookings": 23, "...": 0}
},
"unresolvedFirestoreUris": [{"site": "lib/a.dart:3", "uri": "package:cloud_firestore/cloud_firestore.dart"}],
"notes": ["why the inventory may be incomplete; never empty when queries is"],
"queries": [{
"site": "lib/services/category_service.dart:23",
"owner": "CategoryService", "member": "getCategories",
"scope": "collection", "collection": "categories", "path": "categories",
"filters": [{"field": "isActive", "op": "==", "conditional": false, "valueType": "bool"}],
"orderBy": [{"field": "name", "dir": "asc", "conditional": false}],
"limit": {"kind": "none", "conditional": false},
"cursor": false, "realtime": false, "inLoop": false,
"confidence": "exact", "origin": "chain", "reasons": []
}]
}
filters[].valueType:string,int,double,num,bool,timestamp(DateTimeorTimestamp),null,list,map,reference, orunknown;inCountis the length of a literal list operand.composite(optional):{op: or|and, filters: [filter | {composite}]}.limit:{kind: literal|variable|none, value?, conditional, last?}.orderBy[].dir:asc,desc, orunknownwhendescending:is not a constant.confidence:exact,partial(the source is known but something is not: every cause is inreasons), orunknown(the query's source could not be followed).origin:chain,variable,helper,unknown,adapter.queriesis sorted by site. Status lines and notes go to stderr, so stdout is exactly the document.--format mdrenders the same data grouped by collection, with the coverage line first.
Injection policy. The analysed repository is untrusted (see
agent briefs). The inventory never quotes source: every
collection, path and field name must match ^[A-Za-z0-9_.*/\-]+$ or it is
replaced with * and a reason is added; owners and members are identifiers.
Unresolved packages. If cloud_firestore is not in the package graph, or
an import of it does not resolve, the output says so in notes (and
unresolvedFirestoreUris) rather than presenting an empty list as a clean
result; --require-firestore turns that into exit 2.
Adapter config
Server-side code often wraps Firestore in a declarative API that takes a query
as data, so there is no chain to follow. --adapter-config (YAML, or JSON,
which is a subset of YAML) tells pipe what such an API's arguments mean. The
resulting records use the same schema with origin: adapter. For a gateway
like this:
abstract class FirestoreGateway {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], WhereArrayContains? arrayContains,
List<WhereRange> ranges = const [], List<OrderBy> orderBy = const [],
String? startAfterId, int? limit});
Future<List<QueriedDoc>> queryCollectionGroup(String collectionId,
{List<WhereEquals> filters = const [], WhereIn? inFilter,
WhereArrayContainsAny? arrayContainsAny, List<WhereRange> ranges = const [],
List<OrderBy> orderBy = const [], int? limit});
}
abstract class FirestoreTransaction {
Future<List<QueriedDoc>> queryCollection(String collectionPath,
{List<WhereEquals> filters = const [], int? limit});
}
// WhereEquals(field, value), WhereIn(field, values), WhereArrayContains(field, value),
// WhereArrayContainsAny(field, values), WhereRange(field, {gte, gt, lte, lt}),
// OrderBy(field, {descending = false})
the config is:
{
"adapters": [{
"interface": ["FirestoreGateway", "FirestoreTransaction"],
"library": "package:nuwayze_functions_dart/src/data/firestore_gateway.dart",
"methods": {
"queryCollection": {"scope": "collection", "path": 0},
"queryCollectionGroup": {"scope": "collectionGroup", "path": 0}
},
"args": {
"filters": "filters", "arrayContains": "filters", "arrayContainsAny": "filters",
"inFilter": "filters", "ranges": "filters",
"orderBy": "orderBy", "limit": "limit", "startAfterId": "cursor"
},
"types": {
"WhereEquals": {"field": 0, "op": "==", "value": 1},
"WhereIn": {"field": 0, "op": "in", "value": 1},
"WhereArrayContains": {"field": 0, "op": "array-contains", "value": 1},
"WhereArrayContainsAny": {"field": 0, "op": "array-contains-any", "value": 1},
"WhereRange": {"field": 0, "ops": {"gte": ">=", "gt": ">", "lte": "<=", "lt": "<"}},
"OrderBy": {"kind": "orderBy", "field": 0, "descending": "descending"}
}
}]
}
interface(a name or a list) +library: a call matches when the invoked method's enclosing class is one of these interfaces from that library, or a class implementing one. Names alone never match.methods.<name>:scope(collection|collectionGroup),path(the argument holding the path or group id), optionalrealtime: true.args.<named argument>: a role -filtersandorderBy(a value object, a list literal withif/for/spread elements, a ternary, or a local list built withadd/addAll; elements under anifare conditional),limit, orcursor(any non-null value).types.<class>: how to read one value object, matched by class name and library (the adapter's, orlibrary:).fieldandvalueare a positional index or a named argument. The operator is either fixed (op), one per present named bound (ops), or read from an argument (opArg) and mapped throughopMap, keyed by enum constant name or string value ({"opArg": 1, "opMap": {"lessThan": "<", "greaterThan": ">"}}).kind: orderBywithdescendingreads an ordering instead of a filter.- Every operator must be one of
==,!=,<,<=,>,>=,array-contains,array-contains-any,in,not-in; a config that names anything else, an unknown role, or a bad scope is refused with exit 2.
Limits
- Flow-insensitive within a function, apart from the two cuts above: a clause on a path that returns early is still in the union (as conditional).
- Helper methods are inlined one level deep. A query-consuming wrapper - a
method that takes a
Queryparameter and callsget()itself - is reported at its own terminal asunknown; the clauses its callers pass in are not attributed to it. - A collection name taken from a loop variable over a constant list, a map
lookup or a runtime value is
*, with the recordpartial. - No index analysis, by design: the output carries what an index check needs
(scope, collection group, equality vs. range vs. array operators, order
directions,
insizes) and leaves the decision to the consumer.
Calibration
Calibrated on the NuWayze app (Flutter, lib/, 827 files) and its Dart backend
(functions-dart, 147 files, with the config above), 2026-09-23:
| package | extracted | independent count | exact / partial / unknown |
|---|---|---|---|
| app | 372 query terminals + 165 document reads | 372 get/snapshots/count/aggregate calls whose receiver resolves to Query/CollectionReference (336 + 36) |
336 / 28 / 8 |
| backend | 136 adapter calls | 136 queryCollection(/queryCollectionGroup( call sites by grep |
121 / 15 / 0 |
Every partial is a genuinely dynamic collection or field name; the 8
unknown are query-consuming wrappers (a Query parameter, or a wrapper that
applies caller-supplied builder closures). Zero false positives were found:
records exist only where the receiver type resolves into cloud_firestore.