@tellaro/tql 1.5.0 → 3.0.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.
@@ -1,26 +1,36 @@
1
- import { parseTql as u } from "./parser.js";
2
- const d = {
1
+ import { parseTql as d } from "./parser.js";
2
+ import { MUTATORS as p } from "./tokenizer.js";
3
+ const g = {
3
4
  maxDepth: 5,
4
5
  maxRegex: 3,
5
6
  maxInListSize: 100,
6
7
  maxOrChainLength: 10,
7
8
  maxLeadingWildcards: 2
8
- }, h = /* @__PURE__ */ new Set([
9
+ }, h = new Set(p), c = /* @__PURE__ */ new Set([
9
10
  "geoip_lookup",
10
11
  "geoip",
11
12
  "geo",
12
13
  "nslookup"
13
- ]), p = /\*/g, g = /^\*/;
14
- class c {
14
+ ]), y = /\*/g, f = /^\*/;
15
+ function u(o, e, t) {
16
+ const a = c.has(o);
17
+ return {
18
+ type: "post_processing_mutator",
19
+ message: a ? `Mutator '${o}' requires post-processing and may be slow` : `Mutator '${o}' requires post-processing: matching happens after results return, so the filter cannot be pushed into OpenSearch`,
20
+ severity: a ? "warning" : "info",
21
+ location: { start: e, end: t }
22
+ };
23
+ }
24
+ class x {
15
25
  thresholds;
16
26
  constructor(e) {
17
- this.thresholds = { ...d, ...e };
27
+ this.thresholds = { ...g, ...e };
18
28
  }
19
29
  /**
20
30
  * Analyze a TQL query for complexity
21
31
  */
22
32
  analyze(e) {
23
- const t = u(e), a = {
33
+ const t = d(e), a = {
24
34
  depth: 0,
25
35
  operatorCount: 0,
26
36
  wildcardCount: 0,
@@ -59,6 +69,11 @@ class c {
59
69
  case "comparison":
60
70
  this.analyzeComparisonExpression(e, t, a);
61
71
  break;
72
+ // `field ANY value` -- the cost lives in the wrapped comparison (its mutators, its
73
+ // regex, its list size), so recurse rather than scoring the wrapper as nothing.
74
+ case "collection":
75
+ this.analyzeExpression(e.condition, t, a, n, s);
76
+ break;
62
77
  case "group":
63
78
  this.analyzeGroupExpression(e, t, a, n, s);
64
79
  break;
@@ -79,21 +94,11 @@ class c {
79
94
  t.operatorCount++;
80
95
  const n = e.field;
81
96
  if (n.type === "mutator")
82
- t.mutatorCount++, h.has(n.mutator) && (t.postProcessingMutatorCount++, a.push({
83
- type: "post_processing_mutator",
84
- message: `Mutator '${n.mutator}' requires post-processing and may be slow`,
85
- severity: "warning",
86
- location: { start: n.start, end: n.end }
87
- }));
97
+ t.mutatorCount++, h.has(n.mutator) && (t.postProcessingMutatorCount++, a.push(u(n.mutator, n.start, n.end)));
88
98
  else if (n.type === "field" && n.mutators) {
89
99
  t.mutatorCount += n.mutators.length;
90
100
  for (const i of n.mutators)
91
- h.has(i) && (t.postProcessingMutatorCount++, a.push({
92
- type: "post_processing_mutator",
93
- message: `Mutator '${i}' requires post-processing and may be slow`,
94
- severity: "warning",
95
- location: { start: n.start, end: n.end }
96
- }));
101
+ h.has(i) && (t.postProcessingMutatorCount++, a.push(u(i, n.start, n.end)));
97
102
  }
98
103
  const s = e.operator.toLowerCase();
99
104
  if ((s === "regexp" || s === "regex" || s === "matches" || s === "not_regexp" || s === "not_regex" || s === "not_matches") && t.regexCount++, this.analyzeValue(e.value, t, a, e), (s === "in" || s === "not_in") && Array.isArray(e.value)) {
@@ -105,10 +110,10 @@ class c {
105
110
  const s = Array.isArray(e) ? e : [e];
106
111
  for (const i of s)
107
112
  if (i.valueType === "string" && typeof i.value == "string") {
108
- const l = i.value.match(p);
109
- l && (t.wildcardCount += l.length, g.test(i.value) && (t.leadingWildcardCount++, t.leadingWildcardCount <= this.thresholds.maxLeadingWildcards && a.push({
113
+ const l = i.value.match(y);
114
+ l && (t.wildcardCount += l.length, f.test(i.value) && (t.leadingWildcardCount++, t.leadingWildcardCount <= this.thresholds.maxLeadingWildcards && a.push({
110
115
  type: "leading_wildcard",
111
- message: `Leading wildcard in '${i.value}' is expensive - consider using 'endswith' if possible`,
116
+ message: `'${i.value}' contains '*', which TQL treats as a literal character, not a wildcard - use 'endswith' for a suffix match or 'matches' for a regex`,
112
117
  severity: "warning",
113
118
  location: { start: n.start, end: n.end }
114
119
  })));
@@ -173,34 +178,38 @@ class c {
173
178
  */
174
179
  generateSuggestions(e, t, a) {
175
180
  const n = new Set(e.map((s) => s.type));
176
- (n.has("leading_wildcard") || n.has("multiple_wildcards")) && t.push("Leading wildcards (`*foo`) are expensive - use `endswith` if possible"), n.has("deep_nesting") && t.push(`Query depth of ${a.depth} may impact performance - consider simplifying`), n.has("multiple_regex") && t.push("Multiple regex operators are slow - consider using `contains` or `startswith` where possible"), n.has("large_in_list") && t.push("Large IN lists can be slow - consider splitting into multiple queries or using a lookup table"), n.has("complex_or_chain") && t.push("Consider using `in` instead of multiple `OR` conditions for the same field"), n.has("post_processing_mutator") && t.push("Mutators like `geoip_lookup` and `nslookup` require post-processing and cannot be optimized by the database"), n.has("nested_negation") && t.push("Simplify nested NOT expressions for better readability and potential optimization");
181
+ (n.has("leading_wildcard") || n.has("multiple_wildcards")) && t.push(
182
+ "TQL has no wildcard syntax - `*` is matched literally. Use `endswith` for a suffix match, `startswith` for a prefix, `contains` for a substring, or `matches` for a regex."
183
+ ), n.has("deep_nesting") && t.push(`Query depth of ${a.depth} may impact performance - consider simplifying`), n.has("multiple_regex") && t.push("Multiple regex operators are slow - consider using `contains` or `startswith` where possible"), n.has("large_in_list") && t.push("Large IN lists can be slow - consider splitting into multiple queries or using a lookup table"), n.has("complex_or_chain") && t.push("Consider using `in` instead of multiple `OR` conditions for the same field"), n.has("post_processing_mutator") && t.push("Mutators like `geoip_lookup` and `nslookup` require post-processing and cannot be optimized by the database"), n.has("nested_negation") && t.push("Simplify nested NOT expressions for better readability and potential optimization");
177
184
  }
178
185
  /**
179
186
  * Calculate overall complexity score based on metrics and warnings
180
187
  */
181
188
  calculateScore(e, t) {
182
189
  let a = 0;
183
- e.depth > this.thresholds.maxDepth ? a += 2 : e.depth > 3 && (a += 1), e.regexCount > this.thresholds.maxRegex ? a += 2 : e.regexCount > 1 && (a += 1), e.leadingWildcardCount > this.thresholds.maxLeadingWildcards ? a += 2 : e.leadingWildcardCount > 0 && (a += 1), e.inListMaxSize > this.thresholds.maxInListSize ? a += 2 : e.inListMaxSize > 50 && (a += 1), e.postProcessingMutatorCount > 0 && (a += e.postProcessingMutatorCount), e.orChainLength > this.thresholds.maxOrChainLength && (a += 1);
190
+ e.depth > this.thresholds.maxDepth ? a += 2 : e.depth > 3 && (a += 1), e.regexCount > this.thresholds.maxRegex ? a += 2 : e.regexCount > 1 && (a += 1), e.leadingWildcardCount > this.thresholds.maxLeadingWildcards ? a += 2 : e.leadingWildcardCount > 0 && (a += 1), e.inListMaxSize > this.thresholds.maxInListSize ? a += 2 : e.inListMaxSize > 50 && (a += 1), a += t.filter(
191
+ (s) => s.type === "post_processing_mutator" && s.severity === "warning"
192
+ ).length, e.orChainLength > this.thresholds.maxOrChainLength && (a += 1);
184
193
  const n = t.filter((s) => s.severity === "error").length;
185
194
  return a += n * 2, a >= 4 ? "high" : a >= 2 ? "medium" : "low";
186
195
  }
187
196
  }
188
197
  function r(o, e) {
189
- return new c(e).analyze(o);
198
+ return new x(e).analyze(o);
190
199
  }
191
- function f(o, e) {
200
+ function L(o, e) {
192
201
  return r(o, e).score === "high";
193
202
  }
194
- function x(o, e) {
203
+ function z(o, e) {
195
204
  return r(o, e).warnings;
196
205
  }
197
- function C(o) {
206
+ function _(o) {
198
207
  return r(o).metrics;
199
208
  }
200
209
  export {
201
- c as ComplexityAnalyzer,
210
+ x as ComplexityAnalyzer,
202
211
  r as analyzeComplexity,
203
- C as getComplexityMetrics,
204
- x as getComplexityWarnings,
205
- f as isHighComplexity
212
+ _ as getComplexityMetrics,
213
+ z as getComplexityWarnings,
214
+ L as isHighComplexity
206
215
  };
@@ -6,10 +6,10 @@
6
6
  *
7
7
  * @packageDocumentation
8
8
  */
9
- export type { TqlToken, TqlTokenType, TqlExpression, TqlBinaryExpression, TqlUnaryExpression, TqlComparisonExpression, TqlCollectionExpression, TqlMutatorExpression, TqlAggregationExpression, TqlStatsExpression, TqlGroupExpression, TqlLiteral, TqlFieldReference, TqlComparisonOperator, TqlLogicalOperator, TqlCollectionOperator, TqlMutator, TqlAggregationFunction, TqlTypeHint, TqlVisualizationType, TqlVizParams, TqlVizHint, TqlParseResult, TqlParseError, TqlParseWarning, TqlErrorCode, TqlWarningCode, TqlValidationResult, TqlValidationOptions, TqlFieldType, TqlCompletionItem, TqlCompletionKind, TqlCompletionContext, TqlCompletionOptions, TqlFieldSchema, TqlSnippet, TqlInputProps, TqlInputEmits, TqlInputExpose } from './types';
10
- export { TqlTokenizer, tokenizeTql, tokenizeTqlNoWhitespace, findTokenAtPosition, findTokensInRange, findTokenBefore, findPreviousToken, COMPARISON_OPERATORS, SYMBOL_OPERATORS, NEGATION_SYMBOL_OPERATORS, LOGICAL_OPERATORS, COLLECTION_OPERATORS, MUTATORS, AGGREGATION_FUNCTIONS, TYPE_HINTS, VISUALIZATION_TYPES, KEYWORDS } from './tokenizer';
9
+ export type { TqlToken, TqlTokenType, TqlExpression, TqlBinaryExpression, TqlUnaryExpression, TqlComparisonExpression, TqlCollectionExpression, TqlMutatorExpression, TqlAggregationExpression, TqlStatsExpression, TqlGroupExpression, TqlLiteral, TqlFieldReference, TqlComparisonOperator, TqlLogicalOperator, TqlCollectionOperator, TqlMutator, TqlAggregationFunction, TqlTypeHint, TqlVisualizationType, TqlVizParams, TqlVizHint, TqlParseResult, TqlParseError, TqlParseWarning, TqlErrorCode, TqlWarningCode, TqlValidationResult, TqlValidationOptions, TqlFieldType, CaseSensitiveSupport, TqlCompletionItem, TqlCompletionKind, TqlCompletionContext, TqlCompletionOptions, TqlFieldSchema, TqlSnippet, TqlInputProps, TqlInputEmits, TqlInputExpose } from './types';
10
+ export { TqlTokenizer, tokenizeTql, tokenizeTqlNoWhitespace, findTokenAtPosition, findTokensInRange, findTokenBefore, findPreviousToken, COMPARISON_OPERATORS, SYMBOL_OPERATORS, NEGATION_SYMBOL_OPERATORS, LOGICAL_OPERATORS, COLLECTION_OPERATORS, MUTATORS, BOOLEAN_PREDICATE_MUTATORS, BOOLEAN_PREDICATE_MUTATOR_SET, AGGREGATION_FUNCTIONS, TYPE_HINTS, VISUALIZATION_TYPES, KEYWORDS } from './tokenizer';
11
11
  export { TqlParser, parseTql, isValidTql, getTqlErrors, formatParseError } from './parser';
12
- export { TqlValidator, validateTql, isValidTqlSyntax, getTqlIssues, formatValidationResult } from './validator';
12
+ export { TqlValidator, validateTql, isValidTqlSyntax, getTqlIssues, formatValidationResult, caseSensitiveSupport, casePreservingForm, CASE_SENSITIVE_OPERATORS, CASE_SENSITIVE_WHOLE_VALUE_OPERATORS, CASE_SENSITIVE_MEMBERSHIP_OPERATORS, MAX_REGEX_PATTERN_LENGTH } from './validator';
13
13
  export { getCompletions, getWordAtPosition, getCompletionTriggerCharacters, shouldTriggerCompletion } from './completionProvider';
14
14
  export { fuzzyMatch, levenshteinDistance, findClosestMatch, fuzzyFilter, highlightMatch, type FuzzyMatchResult, type FuzzyMatchOptions } from './fuzzyMatcher';
15
15
  export { SchemaCache, getSchemaCache, createSchemaCache, getCachedFieldNames, getCachedFieldType, getCachedField, type CachedSchema } from './schemaCache';
@@ -1,4 +1,19 @@
1
- import { TqlParseError, TqlParseResult } from './types';
1
+ import { TqlCollectionOperator, TqlParseError, TqlParseResult } from './types';
2
+ /**
3
+ * The canonical WORD spelling of a collection operator, derived from the table above rather
4
+ * than written out a second time.
5
+ *
6
+ * `TqlCollectionExpression` normalises every accepted spelling to `operator` + `negated`, so
7
+ * anything that has to show a collection operator back to the user — the semantic preview is
8
+ * the only such consumer today — has to reverse that. Doing it by reading the table means a
9
+ * spelling added to or removed from the parser cannot leave a renderer describing an operator
10
+ * this package no longer accepts.
11
+ *
12
+ * Symbolic spellings (`!any`) are excluded on purpose: `not_any` is the form both engines
13
+ * accept AND the form `COMPARISON_OPERATORS` carries, which is what makes the rendered string
14
+ * a query the user can paste back.
15
+ */
16
+ export declare function collectionOperatorSpelling(operator: TqlCollectionOperator, negated: boolean): string;
2
17
  export declare class TqlParser {
3
18
  private tokens;
4
19
  private allTokens;
@@ -42,6 +57,63 @@ export declare class TqlParser {
42
57
  * - Optional mutator chain: field | lowercase | trim eq "value"
43
58
  */
44
59
  private parseComparison;
60
+ /**
61
+ * The part of a field reference after its name token: `::type` and `| mutator` chains.
62
+ *
63
+ * Split out of `parseComparison` so the operator-first collection form
64
+ * (`ANY field::string | lowercase eq "x"`) reads the same field grammar rather than a
65
+ * reduced copy of it. A second copy is how `field::geo` came to be accepted in one place
66
+ * and rejected in another.
67
+ */
68
+ private parseFieldTail;
69
+ /**
70
+ * Read one `::type` slot, if the current token opens one. Returns the field with the hint
71
+ * applied and whether a hint name was actually consumed.
72
+ *
73
+ * Called from BOTH sides of the mutator chain so the two spellings share one definition of
74
+ * what may follow `::`.
75
+ *
76
+ * Match on the token's VALUE, not its type. `classifyIdentifier` tests the mutator catalog
77
+ * before the type-hint catalog, so a name in both — `geo` is the live case, exported by
78
+ * TYPE_HINTS and by MUTATORS — arrives here typed "mutator" and was rejected. The package
79
+ * exported `geo` as a valid type hint while its own parser refused `field::geo`. After
80
+ * `::` the only thing a token can be is a type name, so the catalog is the right authority
81
+ * and the lexical class is noise.
82
+ */
83
+ private readTypeHintSlot;
84
+ /**
85
+ * `ANY field [op] value` — the operator-first collection forms.
86
+ *
87
+ * `grammar.pest:226-231` gives `collection_comparison` four alternatives; this is the two
88
+ * that lead with the operator. The Python parser accepts NEITHER — probed, not inferred:
89
+ * `ANY f eq 'x'` and `ANY f 'x'` both raise `TQLSyntaxError at position 4`. They are
90
+ * therefore parsed AND flagged: see `RUST_ONLY_COLLECTION_MESSAGE`.
91
+ *
92
+ * The current token is the collection operator.
93
+ */
94
+ private parseCollectionFirst;
95
+ /**
96
+ * `field [not] ANY [op] value`, or null when the token after the field is not a
97
+ * collection operator at all.
98
+ *
99
+ * Only `field ANY value` — no inner comparison operator — is accepted by both engines.
100
+ * `field ANY eq value` is `grammar.pest`'s third alternative and Python rejects it
101
+ * (`f any eq 'x'` -> `TQLSyntaxError at position 9`), so it parses with the divergence
102
+ * warning rather than being silently accepted or silently refused.
103
+ */
104
+ private tryParseCollectionTail;
105
+ /**
106
+ * The shared tail of both collection forms: an OPTIONAL inner comparison operator
107
+ * (defaulting to `eq`, as both grammars do) followed by a value or a value list.
108
+ */
109
+ private finishCollection;
110
+ /**
111
+ * `<field> <comparison-op> <value>` — everything after the field reference.
112
+ *
113
+ * `fieldToken` is threaded through only for the error messages, which name the field the
114
+ * user typed rather than the normalised reference.
115
+ */
116
+ private parseComparisonTail;
45
117
  /**
46
118
  * Parse a value (string, number, boolean, null)
47
119
  */
@@ -50,15 +122,108 @@ export declare class TqlParser {
50
122
  * Parse a list of values for 'in' operator
51
123
  */
52
124
  private parseValueList;
125
+ /**
126
+ * Parse the optional argument list of a mutator call.
127
+ *
128
+ * `field | split(delimiter=",") eq "bob"` is valid TQL that both engines accept
129
+ * (`mutator = { "|" ~ !^"stats" ~ mutator_name ~ ("(" ~ mutator_args? ~ ")")? }` in
130
+ * tql/src/parser/grammar.pest; `mutator_with_params` in
131
+ * src/tql/parser_components/grammar.py). The JS parser used to stop at the mutator
132
+ * NAME, so the '(' fell through to the top-level "Unexpected token" arm and the editor
133
+ * red-squiggled a query the backend runs happily -- while this package's own completion
134
+ * documentation advertised that exact call form.
135
+ *
136
+ * The name token has already been consumed by the caller.
137
+ */
138
+ private parseMutatorCall;
139
+ /**
140
+ * Parse one mutator argument: `value`, or `name=value`.
141
+ *
142
+ * Returns null (after recording an error) when the current token cannot start an
143
+ * argument, so the caller stops rather than spinning.
144
+ */
145
+ private parseMutatorArgument;
146
+ /**
147
+ * Parse the value half of a mutator argument.
148
+ *
149
+ * Neither engine's grammar admits a list literal here, so `[...]` is an error rather
150
+ * than a silently-accepted superset.
151
+ */
152
+ private parseMutatorArgumentValue;
53
153
  private createFieldReference;
154
+ /**
155
+ * Consume a `not` / `!` sitting between a field and its operator, if one is there.
156
+ *
157
+ * The two-token spelling is the PRIMARY form in both grammars, not an alias: fourteen
158
+ * rules in `tql/src/parser/grammar.pest` are declared as
159
+ * `not_X_op = { neg_prefix ~ X_op | not_X_word }`, and Python builds the same shape with
160
+ * `(CaselessKeyword("not") | "!") + CaselessKeyword("X")`. `field not contains "x"` is
161
+ * how the documentation and the UI examples spell it.
162
+ *
163
+ * This package accepted only the underscore form (`not_contains`) and the glued symbol
164
+ * (`!contains`). Every two-token negation -- `not exists`, `not in`, `not between`,
165
+ * `not startswith`, `not cidr`, ten more -- was reported as
166
+ * `Expected operator after field 'f'`, a false red on the most readable spelling of a
167
+ * third of the operator surface.
168
+ *
169
+ * Only consumed when a comparison operator actually follows. Without that lookahead this
170
+ * would swallow the `not` in `a eq 1 and b not (c eq 2)`, where it belongs to the unary
171
+ * arm; leaving it in place lets the existing error recovery report on it as before.
172
+ */
173
+ private consumeNegationPrefix;
174
+ /**
175
+ * The negated form of an operator, or null when it has none.
176
+ *
177
+ * DERIVED from `COMPARISON_OPERATORS`, not transcribed: `not X` is a TQL operator exactly
178
+ * when `not_X` is. That catalog is itself derived from both engine grammars and pinned by
179
+ * `tests/catalogParity.spec.ts`, so this rule cannot drift from the grammars without that
180
+ * test going red first -- and `tests/negatedOperatorForms.spec.ts` re-derives the
181
+ * `neg_prefix ~ X_op` rules straight out of `grammar.pest` and asserts this parser accepts
182
+ * exactly them.
183
+ *
184
+ * Returning null rather than fabricating `not_eq` matters. Neither engine has a
185
+ * `neg_prefix ~ eq_op` rule, so `f not eq 1` is a syntax error in both, and inventing an
186
+ * operator no evaluator dispatches on would turn a parse error into an unknown-operator
187
+ * failure at execution -- later, and further from the user.
188
+ */
189
+ private negateOperator;
54
190
  private normalizeOperator;
55
191
  private isAtEnd;
56
192
  private peek;
193
+ /** Lookahead by `offset` tokens without consuming; undefined past the end. */
194
+ private peekAt;
57
195
  private previous;
58
196
  private advance;
59
197
  private check;
198
+ /**
199
+ * True when the current token names a mutator in `MUTATORS` but was lexed as something
200
+ * else because an earlier catalog claimed the name first.
201
+ *
202
+ * Deliberately consults the catalog rather than listing the seven affected names: a list
203
+ * would be an eighth transcription of a set this package already maintains, and it would
204
+ * go stale the moment a mutator is added that collides with an aggregation.
205
+ */
206
+ private isCatalogMutatorToken;
60
207
  private match;
61
208
  private matchLogical;
209
+ /**
210
+ * The canonical word a `logicalOp` token spells: `AND`, `OR` or `NOT`.
211
+ *
212
+ * `&&`, `||` and `!` are the symbolic halves of the same three grammar rules as `and`,
213
+ * `or` and `not` (see SYMBOL_LOGICAL_OPERATORS), so every consumer must see one name.
214
+ * Normalising HERE rather than in the tokenizer keeps the token's `value` equal to the
215
+ * source text, which the highlighting and completion surfaces read.
216
+ */
217
+ private logicalWord;
218
+ /**
219
+ * Match the WORD spelling of a logical operator only, never the symbol.
220
+ *
221
+ * Used for the `and` inside `field between low AND high`. Both engines spell that one
222
+ * literally -- `value ~ ^"and" ~ value` in `grammar.pest`, `CaselessKeyword("and")` in
223
+ * `between_comparison_natural` -- and neither accepts `between 1 && 5`. Accepting it here
224
+ * would make this package the only one of the three that does.
225
+ */
226
+ private matchLogicalWord;
62
227
  private addError;
63
228
  private addWarning;
64
229
  /**
@@ -71,6 +236,33 @@ export declare class TqlParser {
71
236
  * This allows the parser to continue and find additional errors.
72
237
  */
73
238
  private createPlaceholderExpression;
239
+ /**
240
+ * Flag the malformed `top N` / `bottom N` spellings on a stats aggregation.
241
+ *
242
+ * ## Why this is a scan and not a stats parser
243
+ *
244
+ * This package deliberately does not build a stats AST — the engines do. But "we do not
245
+ * parse it" had silently become "we accept anything after `stats`": measured at HEAD, every
246
+ * one of `sum(x, top 10) top 5`, `sum(x) top by y` and `count(*, top 2)` is a parse error in
247
+ * BOTH engines and was reported valid here.
248
+ *
249
+ * A full stats parser would have to take a side on five spellings the engines currently
250
+ * DISAGREE about (`count(*) top 2`, `sum(x|trim) top 3`, `sum(x::number) top 3`,
251
+ * `sum(x|trim, top 3)`, `by y|trim top 3` — Rust accepts all five, Python refuses all five).
252
+ * Resolving those is an engine question. So this fires ONLY on shapes both engines refuse,
253
+ * and is silent everywhere else — it can never squiggle a query an engine accepts, which is
254
+ * the failure mode this parser keeps being repaired for.
255
+ *
256
+ * ## Why the group-by region is excluded
257
+ *
258
+ * `top` and `bottom` are ordinary identifiers, and `| stats sum(x) by top` is valid in BOTH
259
+ * engines — a group-by on a field named `top`. So "a `top` not followed by a number" is only
260
+ * unambiguous in the two AGGREGATION modifier positions: directly after the aggregation's
261
+ * `)`, or after a comma at the aggregation's own paren depth. `by y top` (no count) is
262
+ * refused by both engines and is deliberately NOT flagged here, because distinguishing it
263
+ * from `by top` needs the grammar this method is declining to reimplement.
264
+ */
265
+ private checkStatsModifiers;
74
266
  /**
75
267
  * Check if the token stream represents a stats query.
76
268
  * Handles: "| stats ...", "stats ...", or starts with a filter before "| stats".