pyric 0.1.0-alpha.20 → 0.1.0-alpha.21

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 (225) hide show
  1. package/README.md +64 -89
  2. package/dist/ai/blocked.d.ts +52 -0
  3. package/dist/ai/blocked.d.ts.map +1 -0
  4. package/dist/ai/blocked.js +83 -0
  5. package/dist/ai/blocked.js.map +1 -0
  6. package/dist/ai/broker/broker.d.ts +49 -0
  7. package/dist/ai/broker/broker.d.ts.map +1 -1
  8. package/dist/ai/broker/broker.js +90 -0
  9. package/dist/ai/broker/broker.js.map +1 -1
  10. package/dist/ai/broker/gemini-engine.d.ts +14 -1
  11. package/dist/ai/broker/gemini-engine.d.ts.map +1 -1
  12. package/dist/ai/broker/gemini-engine.js +30 -9
  13. package/dist/ai/broker/gemini-engine.js.map +1 -1
  14. package/dist/ai/broker/index.d.ts +2 -2
  15. package/dist/ai/broker/index.d.ts.map +1 -1
  16. package/dist/ai/broker/index.js +2 -2
  17. package/dist/ai/broker/index.js.map +1 -1
  18. package/dist/ai/broker/openai-engine.d.ts +10 -1
  19. package/dist/ai/broker/openai-engine.d.ts.map +1 -1
  20. package/dist/ai/broker/openai-engine.js +26 -4
  21. package/dist/ai/broker/openai-engine.js.map +1 -1
  22. package/dist/ai/broker/synthesizer.d.ts +14 -0
  23. package/dist/ai/broker/synthesizer.d.ts.map +1 -1
  24. package/dist/ai/broker/synthesizer.js +31 -0
  25. package/dist/ai/broker/synthesizer.js.map +1 -1
  26. package/dist/ai/broker/types.d.ts +24 -0
  27. package/dist/ai/broker/types.d.ts.map +1 -1
  28. package/dist/ai/internal.d.ts +5 -0
  29. package/dist/ai/internal.d.ts.map +1 -1
  30. package/dist/ai/internal.js +5 -0
  31. package/dist/ai/internal.js.map +1 -1
  32. package/dist/ai/response-helpers.d.ts.map +1 -1
  33. package/dist/ai/response-helpers.js +6 -24
  34. package/dist/ai/response-helpers.js.map +1 -1
  35. package/dist/analytics/index.d.ts +40 -0
  36. package/dist/analytics/index.d.ts.map +1 -0
  37. package/dist/analytics/index.js +25 -0
  38. package/dist/analytics/index.js.map +1 -0
  39. package/dist/app-check/index.d.ts +31 -0
  40. package/dist/app-check/index.d.ts.map +1 -0
  41. package/dist/app-check/index.js +20 -0
  42. package/dist/app-check/index.js.map +1 -0
  43. package/dist/database/controls.d.ts +1 -1
  44. package/dist/database/controls.js +1 -1
  45. package/dist/database/sandbox/rules-eval.d.ts +1 -1
  46. package/dist/database/sandbox/rules-eval.d.ts.map +1 -1
  47. package/dist/database/sandbox/rules-eval.js +2 -2
  48. package/dist/database/sandbox/rules-eval.js.map +1 -1
  49. package/dist/database/sandbox/write-plane.js +2 -2
  50. package/dist/database/sandbox/write-plane.js.map +1 -1
  51. package/dist/database/sandbox-controls.d.ts +12 -5
  52. package/dist/database/sandbox-controls.d.ts.map +1 -1
  53. package/dist/database/sandbox-controls.js +20 -9
  54. package/dist/database/sandbox-controls.js.map +1 -1
  55. package/dist/deferred/entry.d.ts +75 -0
  56. package/dist/deferred/entry.d.ts.map +1 -0
  57. package/dist/deferred/entry.js +143 -0
  58. package/dist/deferred/entry.js.map +1 -0
  59. package/dist/firestore/lite.d.ts +72 -0
  60. package/dist/firestore/lite.d.ts.map +1 -0
  61. package/dist/firestore/lite.js +22 -0
  62. package/dist/firestore/lite.js.map +1 -0
  63. package/dist/firestore/persistence.d.ts +2 -2
  64. package/dist/firestore/persistence.js +1 -1
  65. package/dist/functions/index.d.ts +29 -0
  66. package/dist/functions/index.d.ts.map +1 -0
  67. package/dist/functions/index.js +20 -0
  68. package/dist/functions/index.js.map +1 -0
  69. package/dist/performance/index.d.ts +22 -0
  70. package/dist/performance/index.d.ts.map +1 -0
  71. package/dist/performance/index.js +19 -0
  72. package/dist/performance/index.js.map +1 -0
  73. package/dist/remote-config/index.d.ts +40 -0
  74. package/dist/remote-config/index.d.ts.map +1 -0
  75. package/dist/remote-config/index.js +25 -0
  76. package/dist/remote-config/index.js.map +1 -0
  77. package/dist/rules/grammar/FirestoreValidator.d.ts.map +1 -1
  78. package/dist/rules/grammar/FirestoreValidator.js +43 -27
  79. package/dist/rules/grammar/FirestoreValidator.js.map +1 -1
  80. package/dist/rules/grammar/document-access-count.d.ts +24 -0
  81. package/dist/rules/grammar/document-access-count.d.ts.map +1 -0
  82. package/dist/rules/grammar/document-access-count.js +100 -0
  83. package/dist/rules/grammar/document-access-count.js.map +1 -0
  84. package/dist/rules/linter/ast-utils.d.ts +2 -6
  85. package/dist/rules/linter/ast-utils.d.ts.map +1 -1
  86. package/dist/rules/linter/ast-utils.js +2 -63
  87. package/dist/rules/linter/ast-utils.js.map +1 -1
  88. package/dist/rules/linter/hallucinations.d.ts +8 -0
  89. package/dist/rules/linter/hallucinations.d.ts.map +1 -1
  90. package/dist/rules/linter/hallucinations.js +121 -31
  91. package/dist/rules/linter/hallucinations.js.map +1 -1
  92. package/dist/rules/linter/linter.d.ts +10 -1
  93. package/dist/rules/linter/linter.d.ts.map +1 -1
  94. package/dist/rules/linter/linter.js +23 -9
  95. package/dist/rules/linter/linter.js.map +1 -1
  96. package/dist/rules/rtdb/grammar/simulator.d.ts.map +1 -1
  97. package/dist/rules/rtdb/grammar/simulator.js +13 -13
  98. package/dist/rules/rtdb/grammar/simulator.js.map +1 -1
  99. package/dist/rules/rtdb/simulation/handler.d.ts.map +1 -1
  100. package/dist/rules/rtdb/simulation/handler.js +96 -35
  101. package/dist/rules/rtdb/simulation/handler.js.map +1 -1
  102. package/dist/rules/simulator/document-lookups.d.ts +4 -0
  103. package/dist/rules/simulator/document-lookups.d.ts.map +1 -1
  104. package/dist/rules/simulator/document-lookups.js +42 -3
  105. package/dist/rules/simulator/document-lookups.js.map +1 -1
  106. package/dist/rules/simulator/eval-error.d.ts +11 -0
  107. package/dist/rules/simulator/eval-error.d.ts.map +1 -1
  108. package/dist/rules/simulator/eval-error.js +14 -0
  109. package/dist/rules/simulator/eval-error.js.map +1 -1
  110. package/dist/rules/simulator/evaluation-builtins.d.ts.map +1 -1
  111. package/dist/rules/simulator/evaluation-builtins.js +33 -8
  112. package/dist/rules/simulator/evaluation-builtins.js.map +1 -1
  113. package/dist/rules/simulator/evaluation-context.d.ts +12 -0
  114. package/dist/rules/simulator/evaluation-context.d.ts.map +1 -1
  115. package/dist/rules/simulator/evaluator.d.ts +1 -1
  116. package/dist/rules/simulator/evaluator.d.ts.map +1 -1
  117. package/dist/rules/simulator/evaluator.js +16 -4
  118. package/dist/rules/simulator/evaluator.js.map +1 -1
  119. package/dist/rules/simulator/handler.d.ts.map +1 -1
  120. package/dist/rules/simulator/handler.js +52 -5
  121. package/dist/rules/simulator/handler.js.map +1 -1
  122. package/dist/rules/simulator/lookup-budget.d.ts +37 -0
  123. package/dist/rules/simulator/lookup-budget.d.ts.map +1 -0
  124. package/dist/rules/simulator/lookup-budget.js +96 -0
  125. package/dist/rules/simulator/lookup-budget.js.map +1 -0
  126. package/dist/rules/stdlib-modules.d.ts +7 -0
  127. package/dist/rules/stdlib-modules.d.ts.map +1 -1
  128. package/dist/rules/stdlib-modules.js +11 -11
  129. package/dist/rules/stdlib-modules.js.map +1 -1
  130. package/dist/sandbox/admin-firestore/get-firestore.d.ts +6 -0
  131. package/dist/sandbox/admin-firestore/get-firestore.d.ts.map +1 -1
  132. package/dist/sandbox/admin-firestore/get-firestore.js +22 -3
  133. package/dist/sandbox/admin-firestore/get-firestore.js.map +1 -1
  134. package/dist/sandbox/index.d.ts +1 -1
  135. package/dist/sandbox/index.d.ts.map +1 -1
  136. package/dist/sandbox/index.js +1 -1
  137. package/dist/sandbox/index.js.map +1 -1
  138. package/dist/sandbox/persistence/types.d.ts +1 -1
  139. package/dist/sandbox/remote.d.ts +2 -2
  140. package/dist/sandbox/sandbox-context.d.ts +8 -1
  141. package/dist/sandbox/sandbox-context.d.ts.map +1 -1
  142. package/dist/sandbox/sandbox-context.js +59 -8
  143. package/dist/sandbox/sandbox-context.js.map +1 -1
  144. package/dist/sandbox/types/auth-state.d.ts +1 -0
  145. package/dist/sandbox/types/auth-state.d.ts.map +1 -1
  146. package/dist/sandbox/types/events.d.ts +2 -1
  147. package/dist/sandbox/types/events.d.ts.map +1 -1
  148. package/dist/sandbox/types/operation.d.ts +1 -0
  149. package/dist/sandbox/types/operation.d.ts.map +1 -1
  150. package/dist/storage/enforce.d.ts +20 -1
  151. package/dist/storage/enforce.d.ts.map +1 -1
  152. package/dist/storage/enforce.js +47 -14
  153. package/dist/storage/enforce.js.map +1 -1
  154. package/dist/storage/index.d.ts +1 -1
  155. package/dist/storage/index.d.ts.map +1 -1
  156. package/dist/storage/list.js +1 -1
  157. package/dist/storage/list.js.map +1 -1
  158. package/dist/storage/sandbox/rules-evaluation-error.d.ts +25 -0
  159. package/dist/storage/sandbox/rules-evaluation-error.d.ts.map +1 -1
  160. package/dist/storage/sandbox/rules-evaluation-error.js +29 -0
  161. package/dist/storage/sandbox/rules-evaluation-error.js.map +1 -1
  162. package/dist/storage/sandbox/rules-evaluator.d.ts +10 -5
  163. package/dist/storage/sandbox/rules-evaluator.d.ts.map +1 -1
  164. package/dist/storage/sandbox/rules-evaluator.js +105 -33
  165. package/dist/storage/sandbox/rules-evaluator.js.map +1 -1
  166. package/dist/storage/sandbox/rules-methods.d.ts.map +1 -1
  167. package/dist/storage/sandbox/rules-methods.js +17 -7
  168. package/dist/storage/sandbox/rules-methods.js.map +1 -1
  169. package/dist/storage/service.d.ts +23 -2
  170. package/dist/storage/service.d.ts.map +1 -1
  171. package/dist/storage/service.js +36 -26
  172. package/dist/storage/service.js.map +1 -1
  173. package/package.json +41 -1
  174. package/src/ai/blocked.ts +97 -0
  175. package/src/ai/broker/broker.ts +88 -0
  176. package/src/ai/broker/gemini-engine.ts +36 -9
  177. package/src/ai/broker/index.ts +2 -1
  178. package/src/ai/broker/openai-engine.ts +31 -3
  179. package/src/ai/broker/synthesizer.ts +34 -0
  180. package/src/ai/broker/types.ts +25 -0
  181. package/src/ai/internal.ts +5 -0
  182. package/src/ai/response-helpers.ts +6 -25
  183. package/src/analytics/index.ts +53 -0
  184. package/src/app/dispatch.test.ts +3 -1
  185. package/src/app-check/index.ts +41 -0
  186. package/src/database/controls.ts +1 -1
  187. package/src/database/sandbox/rules-eval.ts +2 -2
  188. package/src/database/sandbox/write-plane.ts +2 -2
  189. package/src/database/sandbox-controls.ts +29 -9
  190. package/src/deferred/entry.ts +167 -0
  191. package/src/firestore/lite.ts +91 -0
  192. package/src/firestore/persistence.ts +2 -2
  193. package/src/functions/index.ts +38 -0
  194. package/src/performance/index.ts +31 -0
  195. package/src/remote-config/index.ts +53 -0
  196. package/src/rules/grammar/FirestoreValidator.ts +44 -28
  197. package/src/rules/grammar/document-access-count.ts +75 -0
  198. package/src/rules/linter/ast-utils.ts +2 -36
  199. package/src/rules/linter/hallucinations.ts +123 -25
  200. package/src/rules/linter/linter.ts +32 -10
  201. package/src/rules/rtdb/grammar/simulator.ts +12 -13
  202. package/src/rules/rtdb/simulation/handler.ts +149 -46
  203. package/src/rules/simulator/document-lookups.ts +46 -3
  204. package/src/rules/simulator/eval-error.ts +16 -0
  205. package/src/rules/simulator/evaluation-builtins.ts +35 -8
  206. package/src/rules/simulator/evaluation-context.ts +12 -0
  207. package/src/rules/simulator/evaluator.ts +14 -3
  208. package/src/rules/simulator/handler.ts +68 -4
  209. package/src/rules/simulator/lookup-budget.ts +101 -0
  210. package/src/rules/stdlib-modules.ts +19 -11
  211. package/src/sandbox/admin-firestore/get-firestore.ts +19 -3
  212. package/src/sandbox/index.ts +5 -1
  213. package/src/sandbox/persistence/types.ts +1 -1
  214. package/src/sandbox/remote.ts +2 -2
  215. package/src/sandbox/sandbox-context.ts +69 -9
  216. package/src/sandbox/types/auth-state.ts +1 -1
  217. package/src/sandbox/types/events.ts +2 -1
  218. package/src/sandbox/types/operation.ts +1 -1
  219. package/src/storage/enforce.ts +53 -14
  220. package/src/storage/index.ts +1 -1
  221. package/src/storage/list.ts +1 -1
  222. package/src/storage/sandbox/rules-evaluation-error.ts +32 -0
  223. package/src/storage/sandbox/rules-evaluator.ts +109 -32
  224. package/src/storage/sandbox/rules-methods.ts +21 -7
  225. package/src/storage/service.ts +92 -27
@@ -95,7 +95,14 @@ const HALLUCINATED_GLOBALS: Record<string, string> = {
95
95
  undefined: '`undefined` does not exist in rules — use `null`',
96
96
  };
97
97
 
98
- const VALID_MATH_METHODS = new Set(['abs', 'ceil', 'floor', 'round', 'sqrt', 'pow', 'isNaN']);
98
+ /**
99
+ * The math-namespace functions production Firestore actually compiles.
100
+ * Notably ABSENT: `isInfinite`. Reference docs list it, but production
101
+ * rejects it at compile (`Function not found error: Name: [math.isInfinite]`).
102
+ * Exported so the stdlib-modules drift test can assert the documented
103
+ * catalog never re-grows a name this validator rejects.
104
+ */
105
+ export const VALID_MATH_METHODS = new Set(['abs', 'ceil', 'floor', 'round', 'sqrt', 'pow', 'isNaN']);
99
106
 
100
107
 
101
108
  /**
@@ -122,6 +129,21 @@ const WRONG_CONTEXT_PATHS: WrongPath[] = [
122
129
  suggestion: '`resource.id` is not available — capture the document id with `/{docId}` in the match path' },
123
130
  ];
124
131
 
132
+ /**
133
+ * Auth-token claims that are typed BOOL in production. Comparing one
134
+ * against a STRING literal compiles fine (CEL is dynamically typed) but
135
+ * the comparison is cross-type, so `== "true"` is always false and
136
+ * `!= "true"` is always true, and the latter silently opens the rule.
137
+ * WRONG_CONTEXT_PATHS can't express this: it matches `receiver.property`
138
+ * at depth 2, while token claims live at `request.auth.token.<claim>`
139
+ * and the bug is in the *comparison*, not the path itself.
140
+ *
141
+ * Only claims verifiably typed bool belong here. `email_verified` is the
142
+ * documented one (`request.auth.token.email_verified: bool`).
143
+ * `firebase.sign_in_provider` is a string, so it is deliberately NOT listed.
144
+ */
145
+ const BOOL_TOKEN_CLAIMS = new Set(['email_verified']);
146
+
125
147
  /**
126
148
  * Built-in CEL methods that, when accessed without parentheses, are
127
149
  * almost certainly a missing-call mistake. Restricted to a small
@@ -186,40 +208,51 @@ interface Loc {
186
208
  ruleIndex?: number;
187
209
  }
188
210
 
211
+ /**
212
+ * Visit every expression in the match tree, carrying the set of
213
+ * user-defined function names visible where the expression sits. Scope
214
+ * accumulates outward-in: a block sees its own declarations plus every
215
+ * enclosing scope's, which is the resolution order the simulator
216
+ * implements in `simulator/match-resolution.ts`.
217
+ */
189
218
  function walkAllExpressions(
190
219
  match: MatchBlock,
191
- visit: (expr: Expression, loc: Loc) => void,
220
+ inheritedScope: ReadonlySet<string>,
221
+ visit: (expr: Expression, loc: Loc, scope: ReadonlySet<string>) => void,
192
222
  ) {
193
223
  const path = match.path.raw;
224
+ const scope = new Set(inheritedScope);
225
+ for (const fn of match.functions) scope.add(fn.name);
194
226
  for (const fn of match.functions) {
195
227
  const loc: Loc = { matchPath: path, functionName: fn.name };
196
- walkExpr(fn.body, loc, visit);
197
- for (const b of fn.lets) walkExpr(b.value, loc, visit);
228
+ walkExpr(fn.body, loc, scope, visit);
229
+ for (const b of fn.lets) walkExpr(b.value, loc, scope, visit);
198
230
  }
199
231
  for (let i = 0; i < match.allows.length; i++) {
200
- walkExpr(match.allows[i].condition, { matchPath: path, ruleIndex: i }, visit);
232
+ walkExpr(match.allows[i].condition, { matchPath: path, ruleIndex: i }, scope, visit);
201
233
  }
202
- for (const child of match.children) walkAllExpressions(child, visit);
234
+ for (const child of match.children) walkAllExpressions(child, scope, visit);
203
235
  }
204
236
 
205
237
  function walkExpr(
206
238
  expr: Expression,
207
239
  loc: Loc,
208
- visit: (expr: Expression, loc: Loc) => void,
240
+ scope: ReadonlySet<string>,
241
+ visit: (expr: Expression, loc: Loc, scope: ReadonlySet<string>) => void,
209
242
  ) {
210
- visit(expr, loc);
243
+ visit(expr, loc, scope);
211
244
  switch (expr.type) {
212
- case 'binaryOp': walkExpr(expr.left, loc, visit); walkExpr(expr.right, loc, visit); break;
213
- case 'unaryOp': walkExpr(expr.operand, loc, visit); break;
214
- case 'methodCall': walkExpr(expr.object, loc, visit); expr.args.forEach(a => walkExpr(a, loc, visit)); break;
215
- case 'memberAccess': walkExpr(expr.object, loc, visit); break;
216
- case 'bracketAccess': walkExpr(expr.object, loc, visit); walkExpr(expr.index, loc, visit); break;
217
- case 'ternary': walkExpr(expr.condition, loc, visit); walkExpr(expr.consequent, loc, visit); walkExpr(expr.alternate, loc, visit); break;
218
- case 'inExpr': walkExpr(expr.element, loc, visit); walkExpr(expr.collection, loc, visit); break;
219
- case 'isExpr': walkExpr(expr.value, loc, visit); break;
220
- case 'listLiteral': expr.elements.forEach(e => walkExpr(e, loc, visit)); break;
221
- case 'mapLiteral': expr.entries.forEach(en => { walkExpr(en.key, loc, visit); walkExpr(en.value, loc, visit); }); break;
222
- case 'functionCall': expr.args.forEach(a => walkExpr(a, loc, visit)); break;
245
+ case 'binaryOp': walkExpr(expr.left, loc, scope, visit); walkExpr(expr.right, loc, scope, visit); break;
246
+ case 'unaryOp': walkExpr(expr.operand, loc, scope, visit); break;
247
+ case 'methodCall': walkExpr(expr.object, loc, scope, visit); expr.args.forEach(a => walkExpr(a, loc, scope, visit)); break;
248
+ case 'memberAccess': walkExpr(expr.object, loc, scope, visit); break;
249
+ case 'bracketAccess': walkExpr(expr.object, loc, scope, visit); walkExpr(expr.index, loc, scope, visit); break;
250
+ case 'ternary': walkExpr(expr.condition, loc, scope, visit); walkExpr(expr.consequent, loc, scope, visit); walkExpr(expr.alternate, loc, scope, visit); break;
251
+ case 'inExpr': walkExpr(expr.element, loc, scope, visit); walkExpr(expr.collection, loc, scope, visit); break;
252
+ case 'isExpr': walkExpr(expr.value, loc, scope, visit); break;
253
+ case 'listLiteral': expr.elements.forEach(e => walkExpr(e, loc, scope, visit)); break;
254
+ case 'mapLiteral': expr.entries.forEach(en => { walkExpr(en.key, loc, scope, visit); walkExpr(en.value, loc, scope, visit); }); break;
255
+ case 'functionCall': expr.args.forEach(a => walkExpr(a, loc, scope, visit)); break;
223
256
  }
224
257
  }
225
258
 
@@ -231,9 +264,19 @@ function walkExpr(
231
264
  * distinct (rule, key, location) tuple — duplicate identical patterns
232
265
  * within the same rule are de-duplicated.
233
266
  */
234
- function isUnsupportedDebugCall(expr: Expression, allowDebug?: boolean): boolean {
267
+ /**
268
+ * A `debug(...)` call that resolves to NOTHING. Production has no built-in
269
+ * `debug`, but a ruleset is free to declare `function debug(v) { ... }`, and
270
+ * that call resolves and evaluates like any other user function, so only an
271
+ * unresolved call is a finding.
272
+ */
273
+ function isUnresolvedDebugCall(
274
+ expr: Expression,
275
+ scope: ReadonlySet<string>,
276
+ allowDebug?: boolean,
277
+ ): boolean {
235
278
  if (allowDebug) return false;
236
- return expr.type === 'functionCall' && expr.name === 'debug';
279
+ return expr.type === 'functionCall' && expr.name === 'debug' && !scope.has('debug');
237
280
  }
238
281
 
239
282
  function isHallucinatedMethodCall(expr: Expression): boolean {
@@ -275,6 +318,37 @@ function isWrongContextPath(expr: Expression): { suggestion: string } | undefine
275
318
  return WRONG_CONTEXT_PATHS.find(p => p.receiver === recv && p.property === prop);
276
319
  }
277
320
 
321
+ /** Is `expr` a member access spelling exactly `request.auth.token.<claim>`
322
+ * for a claim in BOOL_TOKEN_CLAIMS? */
323
+ function boolTokenClaimName(expr: Expression): string | undefined {
324
+ if (expr.type !== 'memberAccess' || !BOOL_TOKEN_CLAIMS.has(expr.property)) return undefined;
325
+ const token = expr.object;
326
+ if (token.type !== 'memberAccess' || token.property !== 'token') return undefined;
327
+ const auth = token.object;
328
+ if (auth.type !== 'memberAccess' || auth.property !== 'auth') return undefined;
329
+ return auth.object.type === 'identifier' && auth.object.name === 'request'
330
+ ? expr.property
331
+ : undefined;
332
+ }
333
+
334
+ /**
335
+ * `request.auth.token.email_verified == "true"` (or `!=`, either operand
336
+ * order). The claim is a bool; a string literal can never equal it, so the
337
+ * comparison is a constant: `==` always denies, `!=` always allows.
338
+ */
339
+ function boolTokenClaimStringComparison(
340
+ expr: Expression,
341
+ ): { claim: string; op: string; literal: string } | undefined {
342
+ if (expr.type !== 'binaryOp' || (expr.op !== '==' && expr.op !== '!=')) return undefined;
343
+ for (const [side, other] of [[expr.left, expr.right], [expr.right, expr.left]] as const) {
344
+ const claim = boolTokenClaimName(side);
345
+ if (claim && other.type === 'literal' && typeof other.value === 'string') {
346
+ return { claim, op: expr.op, literal: other.value };
347
+ }
348
+ }
349
+ return undefined;
350
+ }
351
+
278
352
  function isLengthPropertyAccessOnMethod(expr: Expression): boolean {
279
353
  return expr.type === 'memberAccess' && expr.property === 'length' && expr.object.type === 'methodCall';
280
354
  }
@@ -301,14 +375,23 @@ export function checkHallucinations(ast: FirestoreRules, options: { allowDebug?:
301
375
  warnings.push(w);
302
376
  }
303
377
 
304
- walkAllExpressions(ast.service.match, (expr, loc) => {
305
- if (isUnsupportedDebugCall(expr, options.allowDebug)) {
378
+ // Functions declared above `service` and directly inside it are visible
379
+ // everywhere below, so they seed the scope the walk carries down.
380
+ const outerScope = new Set<string>();
381
+ for (const fn of ast.functions ?? []) outerScope.add(fn.name);
382
+ for (const fn of ast.service.functions ?? []) outerScope.add(fn.name);
383
+
384
+ walkAllExpressions(ast.service.match, outerScope, (expr, loc, scope) => {
385
+ if (isUnresolvedDebugCall(expr, scope, options.allowDebug)) {
306
386
  emit('HALLUCINATED_GLOBAL', 'debug', loc, {
307
387
  rule: 'HALLUCINATED_GLOBAL',
308
388
  severity: 'error',
309
- message: '`debug()` helper is only permitted in local testing environments; remove it before deployment.',
389
+ message:
390
+ '`debug()` is not a Firestore rules function and no function named `debug` is declared in scope. '
391
+ + 'Production rejects the whole ruleset at compile time with '
392
+ + '`Function not found error: Name: [debug]`, so nothing in the file deploys.',
310
393
  location: loc,
311
- fix: 'Remove debug() wrapper around the expression.',
394
+ fix: 'Remove the debug() call and evaluate the inner expression directly.',
312
395
  });
313
396
  }
314
397
 
@@ -400,6 +483,21 @@ export function checkHallucinations(ast: FirestoreRules, options: { allowDebug?:
400
483
  }
401
484
  }
402
485
 
486
+ const boolClaim = boolTokenClaimStringComparison(expr);
487
+ if (boolClaim) {
488
+ const { claim, op, literal } = boolClaim;
489
+ emit('BOOL_TOKEN_CLAIM', `${claim}|${op}|${literal}`, loc, {
490
+ rule: 'BOOL_TOKEN_CLAIM',
491
+ severity: 'error',
492
+ message:
493
+ `\`request.auth.token.${claim}\` is a bool, but it is compared against the string ` +
494
+ `"${literal}", a cross-type comparison that is always ${op === '==' ? 'false (rule always denies)' : 'true (rule silently allows)'}. ` +
495
+ `Compare against the boolean literal instead: \`request.auth.token.${claim} ${op} true\`.`,
496
+ location: loc,
497
+ fix: `Drop the quotes: \`request.auth.token.${claim} ${op} ${literal === 'false' ? 'false' : 'true'}\`.`,
498
+ });
499
+ }
500
+
403
501
  if (isLengthPropertyAccessOnMethod(expr)) {
404
502
  emit('LENGTH_PROPERTY', '', loc, {
405
503
  rule: 'LENGTH_PROPERTY',
@@ -18,7 +18,6 @@ import {
18
18
  extractFirstExpression,
19
19
  buildCallGraph,
20
20
  maxCallDepth,
21
- countGetCalls,
22
21
  countFunctionCallSites,
23
22
  functionContainsGet,
24
23
  referencesRequestTime,
@@ -26,6 +25,7 @@ import {
26
25
  collectAllRules,
27
26
  } from './ast-utils.js';
28
27
  import { checkSyntaxHints, checkHallucinations } from './hallucinations.js';
28
+ import { countDocumentAccessCalls } from '../grammar/document-access-count.js';
29
29
 
30
30
  // ═══ Types ═══
31
31
 
@@ -93,7 +93,11 @@ const THRESHOLDS = {
93
93
  CALL_DEPTH_WARN: 6,
94
94
  CALL_DEPTH_ERROR: 10,
95
95
  GET_COUNT_WARN: 5,
96
- GET_COUNT_ERROR: 10, // documented by Google
96
+ // Production allows EXACTLY 10 document access calls per request
97
+ // evaluation; the 11th fails (site-docs secure/firestore-rules-limits.md).
98
+ // Error fires strictly ABOVE this value, the same boundary as SEM-3 and
99
+ // the simulator's runtime LookupBudget.
100
+ GET_COUNT_ERROR: 10,
97
101
  };
98
102
 
99
103
  // ═══ Lint Rules ═══
@@ -350,12 +354,14 @@ function checkGetCount(
350
354
  for (const fn of allFunctions) fnMap.set(fn.name, fn);
351
355
 
352
356
  for (let i = 0; i < rules.length; i++) {
353
- const count = countGetCalls(rules[i].rule.condition, fnMap);
354
- if (count >= THRESHOLDS.GET_COUNT_ERROR) {
357
+ const count = countDocumentAccessCalls(rules[i].rule.condition, fnMap);
358
+ // Error only ABOVE the limit: production allows exactly 10 and fails
359
+ // the 11th, so a rule at exactly 10 is legal (still worth the WARN).
360
+ if (count > THRESHOLDS.GET_COUNT_ERROR) {
355
361
  warnings.push({
356
362
  rule: 'GET_COUNT',
357
363
  severity: 'error',
358
- message: `Rule #${i} may invoke ${count} get()/exists() calls. Limit is ${THRESHOLDS.GET_COUNT_ERROR}.`,
364
+ message: `Rule #${i} may invoke ${count} get()/exists()/getAfter()/existsAfter() calls. Limit is ${THRESHOLDS.GET_COUNT_ERROR}.`,
359
365
  location: { ruleIndex: i },
360
366
  fix: 'Cache get() results via a config() wrapper function. Same-path calls are cached by Firestore.',
361
367
  });
@@ -363,7 +369,7 @@ function checkGetCount(
363
369
  warnings.push({
364
370
  rule: 'GET_COUNT',
365
371
  severity: 'warning',
366
- message: `Rule #${i} invokes ${count} get()/exists() calls. Limit is ${THRESHOLDS.GET_COUNT_ERROR}.`,
372
+ message: `Rule #${i} invokes ${count} get()/exists()/getAfter()/existsAfter() calls. Limit is ${THRESHOLDS.GET_COUNT_ERROR}.`,
367
373
  location: { ruleIndex: i },
368
374
  });
369
375
  }
@@ -767,7 +773,16 @@ export interface LintOptions {
767
773
  * block linting of a valid current ruleset.
768
774
  */
769
775
  previousSource?: string;
770
- /** Set to true when validating in a local emulator or testing environment where debug() is permitted. */
776
+ /**
777
+ * Set to true ONLY when linting a scratch ruleset that will never be
778
+ * deployed and an unresolved `debug()` call should be tolerated.
779
+ * Production Firestore rejects a ruleset that calls `debug()` at compile
780
+ * time (`Function not found error: Name: [debug]`), so the linter rejects
781
+ * it by default, including when `testCases` is supplied. A ruleset that
782
+ * declares its own `function debug(...)` is never flagged, with or
783
+ * without this flag. This flag is an explicit caller choice; it is never
784
+ * inferred from other options.
785
+ */
771
786
  allowDebug?: boolean;
772
787
  }
773
788
 
@@ -836,8 +851,15 @@ export function lintFirestoreRules(source: string, options: LintOptions = {}): L
836
851
  // Rule 8: Get duplication (same get()-containing function called multiple times)
837
852
  checkGetDuplication(allRules, allFunctions, warnings);
838
853
 
839
- // Rule 9: Hallucinations — JS-style code that parses but fails at runtime
840
- warnings.push(...checkHallucinations(ast, { allowDebug: options.allowDebug || Boolean(options.testCases && options.testCases.length > 0) }));
854
+ // Rule 9: Hallucinations, JS-style code that parses but fails at runtime.
855
+ // `allowDebug` is an EXPLICIT caller opt-in only. It used to be implied by
856
+ // a non-empty `testCases` array, which silently disabled the debug()
857
+ // rejection in any lint run that also carried a test suite, exactly the
858
+ // authoring path that feeds the write gate. Production rejects debug() at
859
+ // compile time (`Function not found error: Name: [debug]`), so the default
860
+ // must reject; a caller linting a ruleset that will never deploy can still
861
+ // pass `allowDebug: true`.
862
+ warnings.push(...checkHallucinations(ast, { allowDebug: options.allowDebug }));
841
863
 
842
864
  // Rule 9.5: Always-true predicates and recursive-wildcard open rules.
843
865
  // Severity: error so deployRules refuses to swap. The agent's #1
@@ -884,7 +906,7 @@ export function lintFirestoreRules(source: string, options: LintOptions = {}): L
884
906
  let maxExprs = 0;
885
907
  let maxGets = 0;
886
908
  for (const r of allRules) {
887
- const gets = countGetCalls(r.rule.condition, fnMap);
909
+ const gets = countDocumentAccessCalls(r.rule.condition, fnMap);
888
910
  if (gets > maxGets) maxGets = gets;
889
911
  }
890
912
 
@@ -115,23 +115,21 @@ export class DataSnapshot {
115
115
 
116
116
  child(path: string): DataSnapshot {
117
117
  const parts = path.split('/').filter(p => p.length > 0);
118
- let current: unknown = this._value;
119
- let currentPath = this._path;
118
+ if (parts.length === 0) return this;
120
119
 
120
+ let current: unknown = this._value;
121
121
  for (const part of parts) {
122
- if (current === null || current === undefined || typeof current !== 'object') {
122
+ if (current !== null && current !== undefined && typeof current === 'object') {
123
+ current = Object.hasOwn(current as object, part)
124
+ ? ((current as Record<string, unknown>)[part] ?? null)
125
+ : null;
126
+ } else {
123
127
  current = null;
124
- currentPath = `${currentPath}/${part}`.replace(/\/+/g, '/');
125
- break;
126
128
  }
127
- // Own-property access only: a `__proto__`/`constructor` child must
128
- // not resolve to the JS prototype chain.
129
- current = Object.hasOwn(current as object, part)
130
- ? ((current as Record<string, unknown>)[part] ?? null)
131
- : null;
132
- currentPath = `${currentPath === '/' ? '' : currentPath}/${part}`;
133
129
  }
134
130
 
131
+ const prefix = this._path === '/' ? '' : this._path;
132
+ const currentPath = `${prefix}/${parts.join('/')}`;
135
133
  return new DataSnapshot(current, currentPath, this._root);
136
134
  }
137
135
 
@@ -139,9 +137,10 @@ export class DataSnapshot {
139
137
  if (this._path === '/') return null;
140
138
  const parts = this._path.split('/').filter(p => p.length > 0);
141
139
  parts.pop();
142
- const parentPath = '/' + parts.join('/');
140
+ if (parts.length === 0) {
141
+ return new DataSnapshot(this._root, '/');
142
+ }
143
143
  const rootSnap = new DataSnapshot(this._root, '/');
144
- if (parentPath === '/') return rootSnap;
145
144
  return rootSnap.child(parts.join('/'));
146
145
  }
147
146
 
@@ -58,6 +58,23 @@ function snapshotChildKeys(snap: DataSnapshot): string[] {
58
58
  * the first unsupported node, else `null` when every applicable `.validate`
59
59
  * passes.
60
60
  */
61
+ function hasValidateRule(n: RtdbNode): boolean {
62
+ if (n.validate) return true;
63
+ return n.children.some(hasValidateRule);
64
+ }
65
+
66
+ function shouldValidateSiblingSubtree(
67
+ currentSegments: readonly string[],
68
+ hasLocalDeletion: boolean,
69
+ allWritePaths: readonly string[][],
70
+ ): boolean {
71
+ return (
72
+ currentSegments.length > 0 ||
73
+ hasLocalDeletion ||
74
+ allWritePaths.some((wp) => wp.length === 1)
75
+ );
76
+ }
77
+
61
78
  function findFailingValidate(
62
79
  node: RtdbNode,
63
80
  data: DataSnapshot,
@@ -65,31 +82,47 @@ function findFailingValidate(
65
82
  bindings: Record<string, string>,
66
83
  buildContext: ContextBuilder,
67
84
  pathToWrite: string[],
85
+ updates?: readonly { path: string; value?: unknown }[],
68
86
  ): ValidateFailure | null {
69
87
  let firstUnsupported: ValidateFailure | null = null;
70
88
 
89
+ const writePathStrings = new Set<string>();
90
+ if (updates && updates.length > 0) {
91
+ for (const u of updates) {
92
+ writePathStrings.add(u.path.split('/').filter(Boolean).join('/'));
93
+ }
94
+ }
95
+ writePathStrings.add(pathToWrite.filter(Boolean).join('/'));
96
+ const allWritePaths = Array.from(writePathStrings).map((p) => p.split('/').filter(Boolean));
97
+
71
98
  function walk(
72
99
  node: RtdbNode,
73
100
  data: DataSnapshot,
74
101
  newData: DataSnapshot,
75
102
  bindings: Record<string, string>,
76
- remainingPath: string[],
103
+ currentSegments: string[],
104
+ isUnderModifiedSubtree: boolean,
77
105
  ): ValidateFailure | null {
78
- // A null proposed value is a delete — RTDB does not validate deletes.
79
- if (!newData.exists()) return null;
80
-
81
- const rule = node.validate;
82
- if (rule) {
83
- if (!rule.parsed.valid) {
84
- if (!firstUnsupported) {
85
- firstUnsupported = { node, rule, bindings, unsupported: true };
106
+ if (newData.exists()) {
107
+ const rule = node.validate;
108
+ if (rule) {
109
+ if (!rule.parsed.valid) {
110
+ if (!firstUnsupported) {
111
+ firstUnsupported = { node, rule, bindings, unsupported: true };
112
+ }
113
+ } else {
114
+ const result = evaluateRtdbExpression(rule.raw, buildContext(data, newData, bindings));
115
+ if (!result) return { node, rule, bindings };
86
116
  }
87
- } else {
88
- const result = evaluateRtdbExpression(rule.raw, buildContext(data, newData, bindings));
89
- if (!result) return { node, rule, bindings };
90
117
  }
91
118
  }
92
119
 
120
+ const isAtOrBelowWriteTarget = allWritePaths.some(
121
+ (wp) => wp.length <= currentSegments.length && wp.every((seg, idx) => seg === currentSegments[idx]),
122
+ );
123
+
124
+ const hasLocalDeletion = snapshotChildKeys(data).some((k) => !newData.child(k).exists());
125
+
93
126
  for (const child of node.children) {
94
127
  const childSegments = child.path.split('/').filter(Boolean);
95
128
  if (childSegments.length === 0) continue;
@@ -97,49 +130,118 @@ function findFailingValidate(
97
130
  const lastSegment = childSegments[childSegments.length - 1];
98
131
  const isPathVar = lastSegment.startsWith('$');
99
132
 
100
- // Above the write location, validation follows only the operation path.
101
- // Once the write location is reached, it fans out through every child
102
- // present in the proposed value.
103
- if (remainingPath.length > 0) {
104
- if (!isPathVar && remainingPath[0] !== lastSegment) continue;
105
- return walk(
106
- child,
107
- data.child(remainingPath[0]),
108
- newData.child(remainingPath[0]),
109
- isPathVar ? { ...bindings, [lastSegment]: remainingPath[0] } : bindings,
110
- remainingPath.slice(1),
111
- );
112
- }
133
+ if (isAtOrBelowWriteTarget || isUnderModifiedSubtree) {
134
+ if (isPathVar) {
135
+ for (const key of snapshotChildKeys(newData)) {
136
+ const failure = walk(
137
+ child,
138
+ data.child(key),
139
+ newData.child(key),
140
+ { ...bindings, [lastSegment]: key },
141
+ [...currentSegments, key],
142
+ true,
143
+ );
144
+ if (failure) return failure;
145
+ }
146
+ } else {
147
+ if (newData.child(lastSegment).exists()) {
148
+ const failure = walk(
149
+ child,
150
+ data.child(lastSegment),
151
+ newData.child(lastSegment),
152
+ bindings,
153
+ [...currentSegments, lastSegment],
154
+ true,
155
+ );
156
+ if (failure) return failure;
157
+ }
158
+ }
159
+ } else {
160
+ // We are at an ancestor of write path(s)
161
+ if (isPathVar) {
162
+ const writeKeys = new Set<string>();
163
+ for (const wp of allWritePaths) {
164
+ if (
165
+ wp.length > currentSegments.length &&
166
+ currentSegments.every((seg, idx) => seg === wp[idx])
167
+ ) {
168
+ writeKeys.add(wp[currentSegments.length]);
169
+ }
170
+ }
171
+ for (const key of writeKeys) {
172
+ const failure = walk(
173
+ child,
174
+ data.child(key),
175
+ newData.child(key),
176
+ { ...bindings, [lastSegment]: key },
177
+ [...currentSegments, key],
178
+ false,
179
+ );
180
+ if (failure) return failure;
181
+ }
113
182
 
114
- if (isPathVar) {
115
- // Bind the path variable to each key actually present in the new
116
- // data — a `$var` node validates every child of the written value.
117
- for (const key of snapshotChildKeys(newData)) {
118
- const failure = walk(
119
- child,
120
- data.child(key),
121
- newData.child(key),
122
- { ...bindings, [lastSegment]: key },
123
- [],
183
+ if (
184
+ hasValidateRule(child) &&
185
+ shouldValidateSiblingSubtree(currentSegments, hasLocalDeletion, allWritePaths)
186
+ ) {
187
+ for (const key of snapshotChildKeys(newData)) {
188
+ if (writeKeys.has(key)) continue;
189
+ const failure = walk(
190
+ child,
191
+ data.child(key),
192
+ newData.child(key),
193
+ { ...bindings, [lastSegment]: key },
194
+ [...currentSegments, key],
195
+ true,
196
+ );
197
+ if (failure) return failure;
198
+ }
199
+ }
200
+ } else {
201
+ const key = lastSegment;
202
+ const isChildOnWritePath = allWritePaths.some(
203
+ (wp) =>
204
+ wp.length > currentSegments.length &&
205
+ wp[currentSegments.length] === key &&
206
+ currentSegments.every((seg, idx) => seg === wp[idx]),
124
207
  );
125
- if (failure) return failure;
208
+
209
+ if (isChildOnWritePath) {
210
+ const failure = walk(
211
+ child,
212
+ data.child(key),
213
+ newData.child(key),
214
+ bindings,
215
+ [...currentSegments, key],
216
+ false,
217
+ );
218
+ if (failure) return failure;
219
+ } else {
220
+ // Sibling branch
221
+ if (
222
+ newData.child(key).exists() &&
223
+ hasValidateRule(child) &&
224
+ shouldValidateSiblingSubtree(currentSegments, hasLocalDeletion, allWritePaths)
225
+ ) {
226
+ const failure = walk(
227
+ child,
228
+ data.child(key),
229
+ newData.child(key),
230
+ bindings,
231
+ [...currentSegments, key],
232
+ true,
233
+ );
234
+ if (failure) return failure;
235
+ }
236
+ }
126
237
  }
127
- } else {
128
- const failure = walk(
129
- child,
130
- data.child(lastSegment),
131
- newData.child(lastSegment),
132
- bindings,
133
- [],
134
- );
135
- if (failure) return failure;
136
238
  }
137
239
  }
138
240
 
139
241
  return null;
140
242
  }
141
243
 
142
- const realFailure = walk(node, data, newData, bindings, pathToWrite);
244
+ const realFailure = walk(node, data, newData, bindings, [], false);
143
245
  return realFailure ?? firstUnsupported;
144
246
  }
145
247
 
@@ -343,6 +445,7 @@ export class SimulateHandler {
343
445
  {},
344
446
  buildContext,
345
447
  pathSegments,
448
+ updates,
346
449
  );
347
450
  if (failure) {
348
451
  return {