indexwright 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +201 -0
  3. package/README.md +184 -0
  4. package/SPEC.md +386 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.d.ts.map +1 -0
  7. package/dist/args.js +170 -0
  8. package/dist/args.js.map +1 -0
  9. package/dist/cli.d.ts +12 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +96 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/collections.d.ts +6 -0
  14. package/dist/collections.d.ts.map +1 -0
  15. package/dist/collections.js +25 -0
  16. package/dist/collections.js.map +1 -0
  17. package/dist/format/github.d.ts +13 -0
  18. package/dist/format/github.d.ts.map +1 -0
  19. package/dist/format/github.js +63 -0
  20. package/dist/format/github.js.map +1 -0
  21. package/dist/format/inline.d.ts +11 -0
  22. package/dist/format/inline.d.ts.map +1 -0
  23. package/dist/format/inline.js +15 -0
  24. package/dist/format/inline.js.map +1 -0
  25. package/dist/format/json.d.ts +7 -0
  26. package/dist/format/json.d.ts.map +1 -0
  27. package/dist/format/json.js +25 -0
  28. package/dist/format/json.js.map +1 -0
  29. package/dist/format/text.d.ts +3 -0
  30. package/dist/format/text.d.ts.map +1 -0
  31. package/dist/format/text.js +65 -0
  32. package/dist/format/text.js.map +1 -0
  33. package/dist/index.d.ts +18 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +17 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/key.d.ts +35 -0
  38. package/dist/key.d.ts.map +1 -0
  39. package/dist/key.js +76 -0
  40. package/dist/key.js.map +1 -0
  41. package/dist/lint.d.ts +22 -0
  42. package/dist/lint.d.ts.map +1 -0
  43. package/dist/lint.js +110 -0
  44. package/dist/lint.js.map +1 -0
  45. package/dist/parse.d.ts +11 -0
  46. package/dist/parse.d.ts.map +1 -0
  47. package/dist/parse.js +88 -0
  48. package/dist/parse.js.map +1 -0
  49. package/dist/rules/explicit-name-field.d.ts +13 -0
  50. package/dist/rules/explicit-name-field.d.ts.map +1 -0
  51. package/dist/rules/explicit-name-field.js +35 -0
  52. package/dist/rules/explicit-name-field.js.map +1 -0
  53. package/dist/rules/field-order-variant.d.ts +10 -0
  54. package/dist/rules/field-order-variant.d.ts.map +1 -0
  55. package/dist/rules/field-order-variant.js +60 -0
  56. package/dist/rules/field-order-variant.js.map +1 -0
  57. package/dist/rules/index.d.ts +13 -0
  58. package/dist/rules/index.d.ts.map +1 -0
  59. package/dist/rules/index.js +25 -0
  60. package/dist/rules/index.js.map +1 -0
  61. package/dist/rules/quota-headroom.d.ts +10 -0
  62. package/dist/rules/quota-headroom.d.ts.map +1 -0
  63. package/dist/rules/quota-headroom.js +40 -0
  64. package/dist/rules/quota-headroom.js.map +1 -0
  65. package/dist/rules/scope-mismatch.d.ts +10 -0
  66. package/dist/rules/scope-mismatch.d.ts.map +1 -0
  67. package/dist/rules/scope-mismatch.js +45 -0
  68. package/dist/rules/scope-mismatch.js.map +1 -0
  69. package/dist/types.d.ts +104 -0
  70. package/dist/types.d.ts.map +1 -0
  71. package/dist/types.js +14 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/version.d.ts +2 -0
  74. package/dist/version.d.ts.map +1 -0
  75. package/dist/version.js +11 -0
  76. package/dist/version.js.map +1 -0
  77. package/package.json +54 -0
package/SPEC.md ADDED
@@ -0,0 +1,386 @@
1
+ # indexwright — Specification
2
+
3
+ Linter and query-coverage checker for Firestore composite indexes.
4
+
5
+ Status: **v0.1.0 (draft)**. Pre-1.0: rules and CLI surface may change between minor versions.
6
+
7
+ ---
8
+
9
+ ## 1. Why this exists
10
+
11
+ Firestore composite indexes are declared as data (`firestore.indexes.json`) and applied as
12
+ whole-state. Three properties of the platform make that declaration hard to get right, and none of
13
+ them are addressed by existing tooling.
14
+
15
+ **The emulator does not enforce composite indexes.** The Firestore emulator "does not track
16
+ composite indexes and instead executes any valid query." A query that will fail in production with
17
+ `FAILED_PRECONDITION` passes locally. Strict indexing in the emulator is not a planned feature.
18
+
19
+ **Firestore exposes no per-index usage metrics.** Index entry reads are billed but do not appear in
20
+ the usage dashboard, and there is no index-level metric. You cannot ask the platform which of your
21
+ indexes are actually used, so you cannot safely identify unused ones.
22
+
23
+ **The index-matching rule is undocumented.** Google's documentation states that field ordering must
24
+ be specified, but does not define how field order maps to query shape — in particular whether the
25
+ relative order of equality and `array-contains` fields before the range fields affects matching.
26
+ This means index changes cannot be validated against documentation; only empirically.
27
+
28
+ The asymmetry is stark: security rules have an official test harness
29
+ (`@firebase/rules-unit-testing`) and can be verified in the emulator. **Composite indexes have no
30
+ equivalent.** A survey of GitHub and npm found no linter for `firestore.indexes.json`; the only
31
+ adjacent projects are three abandoned index *generators* (0–6 stars, last pushed 2021–2025).
32
+
33
+ Consequently, index declarations drift into states that are detectable by inspection but that
34
+ nothing inspects: inconsistent `queryScope` within a collection, near-duplicate indexes that differ
35
+ only in field order, artifacts of round-tripping through live exports, and silent approach to the
36
+ per-database index quota.
37
+
38
+ ## 2. What this is not
39
+
40
+ **indexwright cannot determine whether an index is needed.** That question requires the queries,
41
+ which live in application code, not in the index declaration. No rule in this specification asserts
42
+ that an index is unnecessary, and no output should be read as authorising a deletion.
43
+
44
+ Deleting a composite index is not symmetric with adding one: an addition does not disturb existing
45
+ queries while it backfills, but a deletion breaks its queries the moment it takes effect, and
46
+ restoring it requires a fresh backfill. **A linter that only sees the declaration must never be the
47
+ basis for a deletion.**
48
+
49
+ What indexwright asserts is narrower and safer: *these declarations are structurally inconsistent
50
+ with each other, and a human should look.*
51
+
52
+ ## 3. Scope
53
+
54
+ ### v0.1.0
55
+
56
+ Static analysis of index declaration files. Four rules (§5). No network access, no credentials, no
57
+ Firestore connection.
58
+
59
+ ### Planned
60
+
61
+ - **v0.2 — query capture.** The Firestore emulator speaks the public Firestore v1 gRPC API in
62
+ plaintext on a local port. An intercepting proxy can decode `RunQuery` requests and record the
63
+ observed `StructuredQuery` shapes, yielding a query corpus harvested from execution rather than
64
+ hand-written. This is language- and framework-independent, because it operates on the wire
65
+ protocol rather than on source code.
66
+ - **v0.3 — coverage check.** Replay a captured corpus against a throwaway Firestore database that
67
+ has the candidate index set applied, and report queries that fail with `FAILED_PRECONDITION`.
68
+ The oracle is Firestore itself; indexwright does not reimplement the undocumented matching rule.
69
+
70
+ The v0.2/v0.3 split is deliberate: capture is cheap and offline, while the coverage decision is
71
+ delegated to the platform. Reimplementing index matching would risk emitting false
72
+ `FAILED_PRECONDITION` verdicts and blocking development on a rule that is not published.
73
+
74
+ **Packaging of v0.2/v0.3.** Capture needs a gRPC stack — `@grpc/grpc-js` and protobuf definitions
75
+ for the Firestore v1 API — and hand-writing a decoder for a wire format owned by someone else is
76
+ not a cost worth paying. That collides with §7: `record` and `check` run inside an adopter's
77
+ project, so their dependencies land in an adopter's tree, and the build-time carve-out does not
78
+ reach them.
79
+
80
+ They therefore ship as a separate package, `@indexwright/record`, which depends on `indexwright`
81
+ for the index model and the `json` contract. **`indexwright` itself acquires no runtime dependency,
82
+ in any version.**
83
+
84
+ The split is not a workaround; it puts each cost where it is cheapest. `lint` runs on every push,
85
+ in every CI job, in projects that may never touch Firestore from a server — that is where a
86
+ transitive dependency tree is least welcome. `record` runs against a local emulator, in a project
87
+ that is already talking to Firestore server-side and therefore already resolves `@grpc/grpc-js`
88
+ transitively through `@google-cloud/firestore`. The dependency is added where it is very likely
89
+ already resolved, and is absent where it would be new.
90
+
91
+ The cost is a second package to discover. `indexwright record` in an installation that has only the
92
+ linter must say where the verb lives, not report an unknown command.
93
+
94
+ **Known limit of v0.2/v0.3:** coverage is bounded by what actually exercises the proxy. A query that
95
+ no test issues is not observed, and absence of observation is not evidence that an index is unused.
96
+ This limit is inherent, not an implementation gap.
97
+
98
+ ## 4. CLI
99
+
100
+ ```
101
+ indexwright lint <file...> [options]
102
+
103
+ Options:
104
+ --format <fmt> text (default) | json | github
105
+ --max-warnings <n> exit 1 if warnings exceed n (default: unlimited → always exit 0)
106
+ --rule <id> run only the given rule; repeatable
107
+ --disable <id> skip the given rule; repeatable
108
+ --quota <n> per-database composite index limit for R4 (default: 1000)
109
+ --quota-threshold <p> warn above this fraction of the limit (default: 0.8)
110
+ ```
111
+
112
+ ### Exit codes
113
+
114
+ | Code | Meaning |
115
+ |-----:|:--------|
116
+ | 0 | Completed. Warnings may have been emitted (default policy). |
117
+ | 1 | Warning count exceeded `--max-warnings`. |
118
+ | 2 | Usage error, unreadable file, or malformed input. |
119
+
120
+ **The default is exit 0 even with findings.** A linter whose rules have unmeasured false-positive
121
+ rates must not gate a pipeline by default. Adopters opt into enforcement with `--max-warnings`
122
+ once they have measured their own noise level.
123
+
124
+ ### Input
125
+
126
+ Any file in the `firestore.indexes.json` shape:
127
+
128
+ ```jsonc
129
+ {
130
+ "indexes": [
131
+ {
132
+ "collectionGroup": "…",
133
+ "queryScope": "COLLECTION" | "COLLECTION_GROUP",
134
+ "fields": [ { "fieldPath": "…", "order": "ASCENDING" | "DESCENDING" }
135
+ | { "fieldPath": "…", "arrayConfig": "CONTAINS" }
136
+ | { "fieldPath": "…", "vectorConfig": { … } } ],
137
+ "density": "…" // optional, passed through
138
+ }
139
+ ],
140
+ "fieldOverrides": [ … ] // parsed, not analysed in v0.1.0
141
+ }
142
+ ```
143
+
144
+ Multiple files may be passed; each is analysed independently. Rules are not applied across files.
145
+
146
+ ### Validation
147
+
148
+ A file is **malformed** when it is not valid JSON, when the top level is not an object, when
149
+ `indexes` is absent or is not an array, when an index lacks `collectionGroup`, `queryScope`, or a
150
+ non-empty `fields`, or when a field lacks `fieldPath` or declares none — or more than one — of
151
+ `order`, `arrayConfig`, and `vectorConfig`.
152
+
153
+ Nothing beyond that is refused. The *values* of `queryScope` and `order` are not checked against an
154
+ enumeration, unknown keys anywhere are ignored, and a declaration that repeats a `fieldPath` within
155
+ one index — which does occur in live exports — is carried through rather than rejected. Refusing to
156
+ analyse a file is the harshest outcome available to a tool that otherwise only warns, and it costs
157
+ the reader every other index in the file, so it is reserved for input that cannot be read as an
158
+ index declaration at all.
159
+
160
+ Malformed input does not abort the run. Each file is read and analysed on its own: files that parse
161
+ are linted and their findings reported, files that do not are reported as errors, and the process
162
+ exits 2 once every file has been handled. Reporting only the first bad file would hide findings in
163
+ the files that were fine.
164
+
165
+ ## 5. Rules
166
+
167
+ Every rule emits **warnings**, never errors. Each finding carries: rule id, file, a canonical index
168
+ key, and a one-line reason. A finding that concerns the file as a whole rather than any particular
169
+ index carries a **null** key.
170
+
171
+ A rule whose subject is a *set* of indexes emits one grouped finding for that set, not one finding
172
+ per member. The finding's `key` is the lexicographically smallest member key and `related` holds the
173
+ remaining member keys, sorted ascending and de-duplicated. This keeps the warning count proportional
174
+ to the number of problems rather than to the number of indexes, which matters because
175
+ `--max-warnings` is counted in findings.
176
+
177
+ ### Canonical index key
178
+
179
+ ```
180
+ <collectionGroup>::<queryScope>::<fieldPath>:<direction>|<fieldPath>:<direction>|…
181
+ ```
182
+
183
+ where `direction` is `ASCENDING`, `DESCENDING`, `CONTAINS`, or — for a `vectorConfig` field —
184
+ `VECTOR(<dimension>)`, written `VECTOR(?)` when no dimension is declared. A trailing implicit
185
+ `__name__` entry is stripped before forming the key, so that declarations that differ only in
186
+ whether the document key is written explicitly resolve to the same resource.
187
+
188
+ ### The implicit `__name__` direction
189
+
190
+ Firestore appends the document key to every composite index, and its direction is not declared, so
191
+ stripping it requires knowing which direction it would have had. indexwright defines that direction
192
+ as **the `order` of the last preceding field that carries an `order`, or `ASCENDING` when no
193
+ preceding field carries one** — the latter arising when the index ends with an `arrayConfig` field.
194
+
195
+ This is not published behaviour. It is read off live exports, which render the key explicitly:
196
+ `[type ASC, createdAt DESC, __name__ DESC]`, `[public ASC, startAt ASC, __name__ ASC]`, and
197
+ `[isRecommended ASC, tags CONTAINS, __name__ ASC]` are all shapes an export produces.
198
+
199
+ The definition is used only to decide whether a written `__name__` is redundant, and the decision is
200
+ deliberately one-sided: a trailing `__name__` is stripped **only** when its direction equals the
201
+ value above, and one that differs — `[totalNbUses DESC, __name__ ASC]` occurs in real exports — is
202
+ treated as meaningful and kept. A wrong definition can therefore fail to merge two spellings of one
203
+ index, but can never merge two indexes that are actually distinct.
204
+
205
+ ---
206
+
207
+ ### R1 · `scope-mismatch`
208
+
209
+ **Detects.** Within one `collectionGroup`, more than one distinct `queryScope` is declared. One
210
+ finding is emitted per affected `collectionGroup`, reporting how many indexes each scope holds.
211
+
212
+ The scope with the fewest indexes is named as the minority, and the finding's keys are that scope's
213
+ indexes. When no scope holds a strict minority — an even split — the finding says so instead of
214
+ naming one, and reports the keys of the lexicographically first scope. It still fires: the detection
215
+ condition is that the scopes disagree, and an even split is a disagreement. Suppressing it would
216
+ narrow the rule to a shape the rule does not claim.
217
+
218
+ **Rationale.** A `COLLECTION`-scoped index does not serve a collection-group query, and vice versa.
219
+ When every other index on a collection uses one scope and a newly added one uses the other, the new
220
+ declaration is frequently a mistake that will surface only as a production `FAILED_PRECONDITION`.
221
+
222
+ **False positives.** Legitimate when an application queries the same collection both as a single
223
+ collection and as a collection group. This is why the rule warns rather than fails.
224
+
225
+ ---
226
+
227
+ ### R2 · `field-order-variant`
228
+
229
+ **Detects.** Two or more indexes that share `collectionGroup`, `queryScope`, and the same *set* of
230
+ `fieldPath:direction` pairs, but declare them in different orders.
231
+
232
+ "Same set" means the same *multiset*: a `fieldPath` that repeats within one index is unusual but not
233
+ rejected (§4), and comparing multisets keeps the grouping well defined when it does. The comparison
234
+ runs on the canonicalised field list, so an index that writes `__name__` explicitly groups with one
235
+ that does not. Two indexes that are byte-identical do not constitute different orders and do not
236
+ fire this rule.
237
+
238
+ **Rationale.** Firestore treats a different field order as a different index. Each variant consumes
239
+ write amplification, storage, and quota independently. Variants proliferate easily: a query change
240
+ that reorders fields adds a new index without removing the old one, and round-tripping through a
241
+ live export can reintroduce an ordering that was intended to be replaced.
242
+
243
+ **False positives.** Legitimate when distinct queries genuinely require distinct orderings — for
244
+ example, two queries that order by the same two fields in opposite directions. The finding asks for
245
+ that justification to be recorded, not for one variant to be removed.
246
+
247
+ **Output.** All members of the variant group are listed together, so the reader can judge the set
248
+ rather than one member at a time.
249
+
250
+ ---
251
+
252
+ ### R3 · `explicit-name-field`
253
+
254
+ **Detects.** An index whose **last** `fields` entry is an explicit `__name__` carrying the implicit
255
+ default direction (§5, *The implicit `__name__` direction*). A `__name__` written anywhere other
256
+ than last, or written last with a direction other than the default, is not flagged.
257
+
258
+ **Rationale.** Firestore appends the document key implicitly; live exports render it explicitly.
259
+ An explicit `__name__` in a hand-maintained declaration is therefore a signature of a value that
260
+ round-tripped through an export rather than being authored directly. It does not change the
261
+ resource identity, but it makes the file inconsistent and can mask genuine duplicates from
262
+ naive text comparison.
263
+
264
+ **False positives.** Explicit `__name__` with a direction that differs from the implicit default is
265
+ meaningful and should not be flagged. The rule only fires on a trailing `__name__` matching the
266
+ implicit default.
267
+
268
+ ---
269
+
270
+ ### R4 · `quota-headroom`
271
+
272
+ **Detects.** The number of composite indexes in the file exceeds `--quota-threshold` of `--quota`.
273
+
274
+ **Rationale.** Composite indexes are capped per database. The limit is reached gradually and
275
+ silently; the first symptom is a failed index creation at deploy time, which is a poor moment to
276
+ discover it. Reporting headroom continuously makes the trend visible.
277
+
278
+ The comparison is a strict `count > quota × threshold`, so the defaults (1000, 0.8) fire at 801.
279
+
280
+ **False positives.** None in principle; the threshold is configurable because appropriate headroom
281
+ depends on the rate of index growth.
282
+
283
+ **Output.** The finding is about the file, not about any one index, so its `key` is `null` and its
284
+ `related` is empty. One finding per file at most.
285
+
286
+ ## 6. Output formats
287
+
288
+ **`text`** — human-readable, grouped by rule, intended for a terminal.
289
+
290
+ **`json`** — a stable machine-readable shape:
291
+
292
+ ```jsonc
293
+ {
294
+ "version": "0.1.0",
295
+ "files": ["…"],
296
+ "summary": { "warnings": 0, "errors": 0, "byRule": { "scope-mismatch": 0, … } },
297
+ "findings": [
298
+ {
299
+ "rule": "scope-mismatch",
300
+ "file": "…",
301
+ "key": "…", // null for a finding about the file as a whole
302
+ "message": "…",
303
+ "related": ["…"] // other keys in the same finding group; [] when there are none
304
+ }
305
+ ],
306
+ "errors": [
307
+ { "file": "…", "message": "…" } // files that could not be read or parsed
308
+ ]
309
+ }
310
+ ```
311
+
312
+ `files` lists every file the run was given, whether or not it parsed, sorted by path. `byRule` holds
313
+ one entry for every rule that ran after `--rule` and `--disable` were applied, including rules that
314
+ found nothing; a rule that did not run is absent rather than zero. `findings` is sorted by file,
315
+ then by rule in the order of §5, then by key with a null key sorting first. `related` is sorted
316
+ ascending. `errors` is sorted by file. Every field is always present: `related` is `[]` rather than
317
+ omitted, and `errors` is `[]` on a clean run.
318
+
319
+ **`github`** — GitHub Actions workflow commands (`::warning file=…::`) plus a Markdown summary
320
+ suitable for `$GITHUB_STEP_SUMMARY`.
321
+
322
+ ## 7. Design principles
323
+
324
+ **Warn, do not fail.** The rules encode heuristics whose false-positive rates are, at v0.1.0,
325
+ unmeasured. Shipping them as blocking checks would teach users to suppress the tool. Enforcement is
326
+ opt-in and per-adopter.
327
+
328
+ **Never authorise a deletion.** See §2. No output phrasing may suggest that an index is unused or
329
+ safe to remove.
330
+
331
+ **No dependencies in `indexwright`.** A linter that pulls a dependency tree into a build pipeline
332
+ undermines its own purpose. Argument parsing and formatting are implemented in-tree, and the
333
+ published package declares no runtime dependencies. This is a property of the package rather than
334
+ an aspiration of the project: it holds in every version, and a test asserts it.
335
+
336
+ The principle is about what lands in an adopter's tree, so build- and test-time tooling that never
337
+ ships is out of its scope. It is not a claim that no part of indexwright may depend on anything.
338
+ Where a verb needs a library it cannot reasonably write — the gRPC stack behind `record` (§3) —
339
+ that verb ships as its own package instead of as a dependency of the linter. What the principle
340
+ forbids is making every adopter of `lint` pay for it.
341
+
342
+ **No network, no credentials, in `lint`.** Static analysis must be runnable in any environment,
343
+ including a sandboxed CI step with no cloud access. Network use is confined to the planned
344
+ `record`/`check` verbs, which are separate commands in a separate package (§3).
345
+
346
+ **Delegate undocumented semantics to the platform.** Where Firestore's behaviour is not published —
347
+ principally index matching — indexwright measures rather than models. This bounds what the tool can
348
+ claim, which is the point.
349
+
350
+ **Deterministic output.** Findings are emitted in a stable sort order (file, rule, key), so that
351
+ output can be diffed across runs.
352
+
353
+ ## 8. Testing
354
+
355
+ Rules are tested against small hand-written fixtures that isolate one condition each, with explicit
356
+ positive and negative cases.
357
+
358
+ **Assertions are never written against a real project's index file.** A test that asserts a finding
359
+ count over live data fails whenever that data legitimately changes, which trains maintainers to
360
+ edit the test rather than read it. Fixtures encode the invariant; real files are for manual
361
+ exploration only.
362
+
363
+ ## 9. Compatibility
364
+
365
+ - Node.js ≥ 22, ESM.
366
+ - Input schema follows the Firebase CLI's `firestore.indexes.json`. Unknown keys are preserved and
367
+ ignored rather than rejected, so that a newer field does not break linting.
368
+ - Semantic versioning. Pre-1.0, rule additions and message changes may land in minor releases;
369
+ the `json` output shape is the stable contract and changes only in major releases after 1.0.
370
+ - The package also exports a JavaScript API, so the rules can be run without spawning a process.
371
+ That API is **provisional**: it is not part of the stable contract before 1.0 and may change in
372
+ any minor release. Only the `json` output shape carries the compatibility promise.
373
+ - indexwright is published as a family: `indexwright`, the linter, which carries no runtime
374
+ dependencies, and — from v0.2 — `@indexwright/record`, capture and coverage, which depends on the
375
+ linter and on a gRPC stack (§3). They version independently; `@indexwright/record` declares the
376
+ range of `indexwright` whose `json` contract it reads.
377
+
378
+ ## 10. Toward 1.0
379
+
380
+ 1.0 requires, at minimum:
381
+
382
+ - Measured false-positive rates for R1 and R2 across more than one real project.
383
+ - At least one adopter outside the project of origin.
384
+ - A decision, informed by that data, on whether any rule should default to failing.
385
+
386
+ Until then the version stays below 1.0 and the README states plainly that the rules are provisional.
package/dist/args.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { OutputFormat, RuleId } from './types.js';
2
+ /** Anything the user could have typed differently. Mapped to exit code 2. */
3
+ export declare class UsageError extends Error {
4
+ readonly name = "UsageError";
5
+ }
6
+ export interface LintCommand {
7
+ kind: 'lint';
8
+ files: string[];
9
+ format: OutputFormat;
10
+ maxWarnings: number;
11
+ rules: RuleId[];
12
+ quota: number;
13
+ quotaThreshold: number;
14
+ }
15
+ export type Command = LintCommand | {
16
+ kind: 'help';
17
+ } | {
18
+ kind: 'version';
19
+ };
20
+ /**
21
+ * Parsed in-tree rather than with a dependency (SPEC §7). Supports `--flag value` and
22
+ * `--flag=value`, and `--` to end option parsing.
23
+ */
24
+ export declare function parseArgs(argv: readonly string[]): Command;
25
+ export declare function usage(): string;
26
+ //# sourceMappingURL=args.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"args.d.ts","sourceRoot":"","sources":["../src/args.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEvD,6EAA6E;AAC7E,qBAAa,UAAW,SAAQ,KAAK;IACnC,SAAkB,IAAI,gBAAgB;CACvC;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,YAAY,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,OAAO,GAAG,WAAW,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAI3E;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6E1D;AA+DD,wBAAgB,KAAK,IAAI,MAAM,CA0B9B"}
package/dist/args.js ADDED
@@ -0,0 +1,170 @@
1
+ import { DEFAULT_QUOTA, DEFAULT_QUOTA_THRESHOLD } from './lint.js';
2
+ import { isRuleId, rules } from './rules/index.js';
3
+ import { RULE_IDS } from './types.js';
4
+ /** Anything the user could have typed differently. Mapped to exit code 2. */
5
+ export class UsageError extends Error {
6
+ name = 'UsageError';
7
+ }
8
+ const FORMATS = ['text', 'json', 'github'];
9
+ /**
10
+ * Parsed in-tree rather than with a dependency (SPEC §7). Supports `--flag value` and
11
+ * `--flag=value`, and `--` to end option parsing.
12
+ */
13
+ export function parseArgs(argv) {
14
+ if (argv.length === 0)
15
+ throw new UsageError('no command given');
16
+ if (argv.includes('--help') || argv.includes('-h'))
17
+ return { kind: 'help' };
18
+ if (argv.includes('--version'))
19
+ return { kind: 'version' };
20
+ const [command, ...rest] = argv;
21
+ if (command !== 'lint') {
22
+ throw new UsageError(`unknown command "${command}"; the only command is "lint"`);
23
+ }
24
+ const files = [];
25
+ let format = 'text';
26
+ let maxWarnings = Number.POSITIVE_INFINITY;
27
+ let quota = DEFAULT_QUOTA;
28
+ let quotaThreshold = DEFAULT_QUOTA_THRESHOLD;
29
+ const selected = [];
30
+ const disabled = [];
31
+ for (let i = 0; i < rest.length; i += 1) {
32
+ const argument = rest[i];
33
+ if (argument === '--') {
34
+ files.push(...rest.slice(i + 1));
35
+ break;
36
+ }
37
+ if (!argument.startsWith('--')) {
38
+ files.push(argument);
39
+ continue;
40
+ }
41
+ const equals = argument.indexOf('=');
42
+ const name = equals === -1 ? argument : argument.slice(0, equals);
43
+ const inlineValue = equals === -1 ? null : argument.slice(equals + 1);
44
+ const takeValue = () => {
45
+ if (inlineValue !== null)
46
+ return inlineValue;
47
+ const next = rest[i + 1];
48
+ if (next === undefined)
49
+ throw new UsageError(`${name} needs a value`);
50
+ i += 1;
51
+ return next;
52
+ };
53
+ switch (name) {
54
+ case '--format':
55
+ format = parseFormat(takeValue());
56
+ break;
57
+ case '--max-warnings':
58
+ maxWarnings = parseCount(takeValue(), name);
59
+ break;
60
+ case '--rule':
61
+ selected.push(parseRuleId(takeValue(), name));
62
+ break;
63
+ case '--disable':
64
+ disabled.push(parseRuleId(takeValue(), name));
65
+ break;
66
+ case '--quota':
67
+ quota = parsePositiveInteger(takeValue(), name);
68
+ break;
69
+ case '--quota-threshold':
70
+ quotaThreshold = parseFraction(takeValue(), name);
71
+ break;
72
+ default:
73
+ throw new UsageError(`unknown option "${name}"`);
74
+ }
75
+ }
76
+ if (files.length === 0)
77
+ throw new UsageError('no input files given');
78
+ return {
79
+ kind: 'lint',
80
+ // De-duplicated so a shell glob that repeats a path does not double every finding.
81
+ files: [...new Set(files)],
82
+ format,
83
+ maxWarnings,
84
+ rules: resolveRules(selected, disabled),
85
+ quota,
86
+ quotaThreshold,
87
+ };
88
+ }
89
+ function resolveRules(selected, disabled) {
90
+ const base = selected.length > 0 ? new Set(selected) : new Set(RULE_IDS);
91
+ for (const id of disabled)
92
+ base.delete(id);
93
+ if (base.size === 0) {
94
+ throw new UsageError('--rule and --disable leave no rules to run');
95
+ }
96
+ return RULE_IDS.filter((id) => base.has(id));
97
+ }
98
+ function parseFormat(value) {
99
+ if (!FORMATS.includes(value)) {
100
+ throw new UsageError(`unknown format "${value}"; expected one of ${FORMATS.join(', ')}`);
101
+ }
102
+ return value;
103
+ }
104
+ /** A typo must not silently produce a clean run, so an unknown rule id is a usage error. */
105
+ function parseRuleId(value, option) {
106
+ if (!isRuleId(value)) {
107
+ throw new UsageError(`unknown rule "${value}" for ${option}; expected one of ${RULE_IDS.join(', ')}`);
108
+ }
109
+ return value;
110
+ }
111
+ /** A plain decimal numeral, with or without a fractional part: `5`, `0.8`, `.8`, `-1`. */
112
+ const DECIMAL = /^-?(\d+(\.\d*)?|\.\d+)$/;
113
+ /**
114
+ * `Number` reads `""` as 0, ignores surrounding whitespace, and reads `"0x10"` as 16. A CI step
115
+ * that writes `--max-warnings=$LIMIT` with `LIMIT` unset would therefore turn "unlimited" into
116
+ * "zero tolerance" silently, so an option value has to be a numeral and nothing else. `NaN` fails
117
+ * every caller's range check, which reports it as the usage error it is.
118
+ */
119
+ function toNumber(value) {
120
+ return DECIMAL.test(value) ? Number(value) : Number.NaN;
121
+ }
122
+ function parseCount(value, option) {
123
+ const parsed = toNumber(value);
124
+ if (!Number.isInteger(parsed) || parsed < 0) {
125
+ throw new UsageError(`${option} needs a non-negative integer, got "${value}"`);
126
+ }
127
+ return parsed;
128
+ }
129
+ function parsePositiveInteger(value, option) {
130
+ const parsed = toNumber(value);
131
+ if (!Number.isInteger(parsed) || parsed <= 0) {
132
+ throw new UsageError(`${option} needs a positive integer, got "${value}"`);
133
+ }
134
+ return parsed;
135
+ }
136
+ function parseFraction(value, option) {
137
+ const parsed = toNumber(value);
138
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 1) {
139
+ throw new UsageError(`${option} needs a number in (0, 1], got "${value}"`);
140
+ }
141
+ return parsed;
142
+ }
143
+ export function usage() {
144
+ const ruleLines = rules.map((rule) => ` ${rule.id.padEnd(21)}${rule.description}`);
145
+ return [
146
+ 'indexwright lint <file...> [options]',
147
+ '',
148
+ 'Lints Firestore composite index declarations. Every rule emits warnings, never errors.',
149
+ 'No finding indicates that an index is unused or safe to delete.',
150
+ '',
151
+ 'Options:',
152
+ ' --format <fmt> text (default) | json | github',
153
+ ' --max-warnings <n> exit 1 if warnings exceed n (default: unlimited)',
154
+ ' --rule <id> run only the given rule; repeatable',
155
+ ' --disable <id> skip the given rule; repeatable',
156
+ ` --quota <n> per-database composite index limit (default: ${DEFAULT_QUOTA})`,
157
+ ` --quota-threshold <p> warn above this fraction of the limit (default: ${DEFAULT_QUOTA_THRESHOLD})`,
158
+ ' -h, --help show this message',
159
+ ' --version show the version',
160
+ '',
161
+ 'Rules:',
162
+ ...ruleLines,
163
+ '',
164
+ 'Exit codes:',
165
+ ' 0 completed; warnings may have been emitted',
166
+ ' 1 warning count exceeded --max-warnings',
167
+ ' 2 usage error, unreadable file, or malformed input',
168
+ ].join('\n');
169
+ }
170
+ //# sourceMappingURL=args.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"args.js","sourceRoot":"","sources":["../src/args.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ export interface Streams {
3
+ out(text: string): void;
4
+ err(text: string): void;
5
+ }
6
+ /**
7
+ * Exit codes (SPEC §4): 0 completed, 1 warnings over the budget, 2 usage error or unusable input.
8
+ * A file that could not be analysed is a 2 regardless of `--max-warnings`, because the run did not
9
+ * cover what it was asked to cover.
10
+ */
11
+ export declare function run(argv: readonly string[], streams: Streams): number;
12
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAWA,MAAM,WAAW,OAAO;IACtB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,OAAO,GAAG,MAAM,CA2CrE"}