@kernhq/module-tracker 0.1.0 → 0.1.2

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.
@@ -0,0 +1,300 @@
1
+ import type { KqlComparison, KqlExpr, KqlOp, KqlOrder, KqlQuery, KqlValue, Span } from './ast.js'
2
+ import { type Token, tokenize } from './lexer.js'
3
+
4
+ export interface ParseError {
5
+ message: string
6
+ start: number
7
+ end: number
8
+ }
9
+
10
+ export interface ParseResult {
11
+ ok: boolean
12
+ query: KqlQuery | null
13
+ errors: ParseError[]
14
+ }
15
+
16
+ const KEYWORDS = new Set([
17
+ 'and',
18
+ 'or',
19
+ 'not',
20
+ 'in',
21
+ 'is',
22
+ 'empty',
23
+ 'order',
24
+ 'by',
25
+ 'asc',
26
+ 'desc',
27
+ 'true',
28
+ 'false',
29
+ 'null',
30
+ ])
31
+
32
+ const COMPARE_OPS: Record<string, KqlOp> = {
33
+ '=': '=',
34
+ '!=': '!=',
35
+ '<': '<',
36
+ '<=': '<=',
37
+ '>': '>',
38
+ '>=': '>=',
39
+ '~': '~',
40
+ '!~': '!~',
41
+ }
42
+
43
+ class ParseFailure extends Error {
44
+ constructor(
45
+ message: string,
46
+ readonly span: Span,
47
+ ) {
48
+ super(message)
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Recursive-descent parser for KQL.
54
+ *
55
+ * ```ebnf
56
+ * query = [ expr ] [ order-clause ] ;
57
+ * expr = or-expr ;
58
+ * or-expr = and-expr { "or" and-expr } ;
59
+ * and-expr = unary { "and" unary } ;
60
+ * unary = "not" unary | "(" expr ")" | comparison ;
61
+ * comparison = field ( op value
62
+ * | "in" "(" value { "," value } ")"
63
+ * | "not" "in" "(" value { "," value } ")"
64
+ * | "is" [ "not" ] "empty" ) ;
65
+ * op = "=" | "!=" | "<" | "<=" | ">" | ">=" | "~" | "!~" ;
66
+ * value = string | number | date | reldate | "true" | "false" | "null" | ident | function ;
67
+ * function = ident "(" [ value { "," value } ] ")" ;
68
+ * order-clause = "order" "by" order-item { "," order-item } ;
69
+ * order-item = field [ "asc" | "desc" ] ;
70
+ * ```
71
+ */
72
+ class Parser {
73
+ private pos = 0
74
+ constructor(private readonly tokens: Token[]) {}
75
+
76
+ private peek(offset = 0): Token {
77
+ return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)]!
78
+ }
79
+ private next(): Token {
80
+ const t = this.peek()
81
+ if (t.kind !== 'eof') this.pos++
82
+ return t
83
+ }
84
+ private atKeyword(word: string, offset = 0): boolean {
85
+ const t = this.peek(offset)
86
+ return t.kind === 'ident' && t.text.toLowerCase() === word
87
+ }
88
+ private eatKeyword(word: string): boolean {
89
+ if (!this.atKeyword(word)) return false
90
+ this.pos++
91
+ return true
92
+ }
93
+ private expectKeyword(word: string): Token {
94
+ const t = this.peek()
95
+ if (!this.eatKeyword(word)) throw new ParseFailure(`Expected "${word}"`, span(t))
96
+ return t
97
+ }
98
+ private expect(kind: Token['kind'], label: string): Token {
99
+ const t = this.peek()
100
+ if (t.kind !== kind) throw new ParseFailure(`Expected ${label}`, span(t))
101
+ return this.next()
102
+ }
103
+
104
+ parse(): KqlQuery {
105
+ let where: KqlExpr | null = null
106
+ if (this.peek().kind !== 'eof' && !this.atKeyword('order')) where = this.parseOr()
107
+ const orderBy = this.parseOrderClause()
108
+ const trailing = this.peek()
109
+ if (trailing.kind !== 'eof') throw new ParseFailure(`Unexpected "${trailing.text}"`, span(trailing))
110
+ return { where, orderBy }
111
+ }
112
+
113
+ private parseOrderClause(): KqlOrder[] {
114
+ if (!this.atKeyword('order')) return []
115
+ this.next()
116
+ this.expectKeyword('by')
117
+ const out: KqlOrder[] = []
118
+ for (;;) {
119
+ const field = this.peek()
120
+ if (field.kind !== 'ident' || KEYWORDS.has(field.text.toLowerCase()))
121
+ throw new ParseFailure('Expected a field name', span(field))
122
+ this.next()
123
+ let dir: 'asc' | 'desc' = 'asc'
124
+ let end = field.end
125
+ if (this.atKeyword('asc') || this.atKeyword('desc')) {
126
+ const d = this.next()
127
+ dir = d.text.toLowerCase() as 'asc' | 'desc'
128
+ end = d.end
129
+ }
130
+ out.push({ field: field.text, dir, span: { start: field.start, end } })
131
+ if (this.peek().kind === 'comma') {
132
+ this.next()
133
+ continue
134
+ }
135
+ break
136
+ }
137
+ return out
138
+ }
139
+
140
+ private parseOr(): KqlExpr {
141
+ const first = this.parseAnd()
142
+ if (!this.atKeyword('or')) return first
143
+ const children = [first]
144
+ while (this.eatKeyword('or')) children.push(this.parseAnd())
145
+ return { kind: 'or', children, span: joinSpans(children) }
146
+ }
147
+
148
+ private parseAnd(): KqlExpr {
149
+ const first = this.parseUnary()
150
+ if (!this.atKeyword('and')) return first
151
+ const children = [first]
152
+ while (this.eatKeyword('and')) children.push(this.parseUnary())
153
+ return { kind: 'and', children, span: joinSpans(children) }
154
+ }
155
+
156
+ private parseUnary(): KqlExpr {
157
+ // `not` starts a negation only when it is not the `not in` of a comparison
158
+ if (this.atKeyword('not') && !this.atKeyword('in', 1)) {
159
+ const start = this.next().start
160
+ const child = this.parseUnary()
161
+ return { kind: 'not', child, span: { start, end: child.span.end } }
162
+ }
163
+ if (this.peek().kind === 'lparen') {
164
+ this.next()
165
+ const inner = this.parseOr()
166
+ this.expect('rparen', '")"')
167
+ return inner
168
+ }
169
+ return this.parseComparison()
170
+ }
171
+
172
+ private parseComparison(): KqlComparison {
173
+ const field = this.peek()
174
+ if (field.kind !== 'ident' || KEYWORDS.has(field.text.toLowerCase()))
175
+ throw new ParseFailure('Expected a field name', span(field))
176
+ this.next()
177
+
178
+ const t = this.peek()
179
+ if (t.kind === 'op') {
180
+ const op = COMPARE_OPS[t.text]
181
+ if (!op) throw new ParseFailure(`Unknown operator "${t.text}"`, span(t))
182
+ this.next()
183
+ const value = this.parseValue()
184
+ return {
185
+ kind: 'cmp',
186
+ field: field.text,
187
+ op,
188
+ value,
189
+ values: null,
190
+ span: { start: field.start, end: value.span.end },
191
+ }
192
+ }
193
+ if (this.atKeyword('in') || (this.atKeyword('not') && this.atKeyword('in', 1))) {
194
+ const negated = this.eatKeyword('not')
195
+ this.expectKeyword('in')
196
+ this.expect('lparen', '"("')
197
+ const values: KqlValue[] = []
198
+ if (this.peek().kind !== 'rparen') {
199
+ values.push(this.parseValue())
200
+ while (this.peek().kind === 'comma') {
201
+ this.next()
202
+ values.push(this.parseValue())
203
+ }
204
+ }
205
+ const close = this.expect('rparen', '")"')
206
+ return {
207
+ kind: 'cmp',
208
+ field: field.text,
209
+ op: negated ? 'not-in' : 'in',
210
+ value: null,
211
+ values,
212
+ span: { start: field.start, end: close.end },
213
+ }
214
+ }
215
+ if (this.atKeyword('is')) {
216
+ this.next()
217
+ const negated = this.eatKeyword('not')
218
+ const empty = this.expectKeyword('empty')
219
+ return {
220
+ kind: 'cmp',
221
+ field: field.text,
222
+ op: negated ? 'is-not-empty' : 'is-empty',
223
+ value: null,
224
+ values: null,
225
+ span: { start: field.start, end: empty.end },
226
+ }
227
+ }
228
+ throw new ParseFailure(`Expected an operator after "${field.text}"`, span(t))
229
+ }
230
+
231
+ private parseValue(): KqlValue {
232
+ const t = this.peek()
233
+ switch (t.kind) {
234
+ case 'string':
235
+ this.next()
236
+ return { kind: 'string', value: String(t.value ?? ''), span: span(t) }
237
+ case 'number':
238
+ this.next()
239
+ return { kind: 'number', value: Number(t.value), span: span(t) }
240
+ case 'date':
241
+ this.next()
242
+ return { kind: 'date', value: String(t.value), span: span(t) }
243
+ case 'reldate':
244
+ this.next()
245
+ return { kind: 'reldate', amount: t.amount!, unit: t.unit!, span: span(t) }
246
+ case 'ident': {
247
+ const lower = t.text.toLowerCase()
248
+ if (lower === 'true' || lower === 'false') {
249
+ this.next()
250
+ return { kind: 'bool', value: lower === 'true', span: span(t) }
251
+ }
252
+ if (lower === 'null') {
253
+ this.next()
254
+ return { kind: 'null', span: span(t) }
255
+ }
256
+ if (KEYWORDS.has(lower)) throw new ParseFailure(`Expected a value, got "${t.text}"`, span(t))
257
+ this.next()
258
+ if (this.peek().kind === 'lparen') {
259
+ this.next()
260
+ const args: KqlValue[] = []
261
+ if (this.peek().kind !== 'rparen') {
262
+ args.push(this.parseValue())
263
+ while (this.peek().kind === 'comma') {
264
+ this.next()
265
+ args.push(this.parseValue())
266
+ }
267
+ }
268
+ const close = this.expect('rparen', '")"')
269
+ return { kind: 'func', name: t.text, args, span: { start: t.start, end: close.end } }
270
+ }
271
+ return { kind: 'ident', value: t.text, span: span(t) }
272
+ }
273
+ default:
274
+ throw new ParseFailure('Expected a value', span(t))
275
+ }
276
+ }
277
+ }
278
+
279
+ const span = (t: Token): Span => ({ start: t.start, end: t.end })
280
+ const joinSpans = (nodes: Array<{ span: Span }>): Span => ({
281
+ start: nodes[0]?.span.start ?? 0,
282
+ end: nodes.at(-1)?.span.end ?? 0,
283
+ })
284
+
285
+ /** Parse a KQL string. Never throws: syntax problems come back as spans in `errors`. */
286
+ export function parseKql(input: string): ParseResult {
287
+ const { tokens, errors: lexErrors } = tokenize(input)
288
+ const errors: ParseError[] = [...lexErrors]
289
+ if (!input.trim()) return { ok: errors.length === 0, query: { where: null, orderBy: [] }, errors }
290
+ try {
291
+ const query = new Parser(tokens).parse()
292
+ return { ok: errors.length === 0, query, errors }
293
+ } catch (err) {
294
+ if (err instanceof ParseFailure) {
295
+ errors.push({ message: err.message, start: err.span.start, end: err.span.end })
296
+ return { ok: false, query: null, errors }
297
+ }
298
+ throw err
299
+ }
300
+ }
@@ -0,0 +1,102 @@
1
+ import type { KqlSuggestion } from '../contract/models.js'
2
+ import { findField, KQL_FUNCTIONS, type KqlField, operatorsFor } from './fields.js'
3
+ import { tokenize } from './lexer.js'
4
+
5
+ const KEYWORD_TOKENS = new Set(['and', 'or', 'not', 'in', 'is', 'empty', 'order', 'by', 'asc', 'desc'])
6
+
7
+ const fieldSuggestion = (f: KqlField): KqlSuggestion => ({
8
+ kind: 'field',
9
+ label: f.name,
10
+ insertText: f.name,
11
+ detail: f.label,
12
+ })
13
+
14
+ const opSuggestion = (op: string): KqlSuggestion => ({ kind: 'operator', label: op, insertText: op })
15
+
16
+ const valueSuggestions = (field: KqlField): KqlSuggestion[] => {
17
+ const out: KqlSuggestion[] = []
18
+ for (const v of field.enumValues ?? []) out.push({ kind: 'value', label: v, insertText: v })
19
+ if (field.kind === 'user')
20
+ out.push({ kind: 'function', label: 'currentUser()', insertText: 'currentUser()', detail: 'You' })
21
+ if (field.kind === 'date' || field.kind === 'datetime')
22
+ for (const f of KQL_FUNCTIONS)
23
+ if (['now', 'startOfDay', 'startOfWeek', 'startOfMonth'].includes(f.name))
24
+ out.push({ kind: 'function', label: `${f.name}()`, insertText: `${f.name}()`, detail: f.detail })
25
+ if (field.refType === 'cycle')
26
+ for (const name of ['activeCycle', 'openCycles'])
27
+ out.push({ kind: 'function', label: `${name}()`, insertText: `${name}()` })
28
+ if (field.kind === 'boolean')
29
+ for (const v of ['true', 'false']) out.push({ kind: 'value', label: v, insertText: v })
30
+ return out
31
+ }
32
+
33
+ /**
34
+ * Autocomplete for a KQL editor. Looks only at the tokens before `cursor`, so it works on
35
+ * half-written queries the parser would reject.
36
+ */
37
+ export function suggest(input: string, fields: readonly KqlField[], cursor?: number): KqlSuggestion[] {
38
+ const at = cursor ?? input.length
39
+ const head = input.slice(0, at)
40
+ const { tokens } = tokenize(head)
41
+ const real = tokens.filter((t) => t.kind !== 'eof')
42
+ const last = real.at(-1)
43
+ const prev = real.at(-2)
44
+
45
+ const matchFields = (prefix: string) =>
46
+ fields
47
+ .filter((f) => f.name.toLowerCase().startsWith(prefix.toLowerCase()))
48
+ .slice(0, 40)
49
+ .map(fieldSuggestion)
50
+
51
+ // nothing typed yet, or the query continues after a connective / open paren
52
+ const startsNewTerm =
53
+ !last ||
54
+ last.kind === 'lparen' ||
55
+ (last.kind === 'ident' && ['and', 'or', 'not'].includes(last.text.toLowerCase()))
56
+ if (startsNewTerm) return matchFields('')
57
+
58
+ // the caret sits inside a bareword
59
+ if (last.kind === 'ident' && last.end === at) {
60
+ const word = last.text.toLowerCase()
61
+ // after `field <op>` a bareword is a value, not a field
62
+ if (prev?.kind === 'op' || (prev?.kind === 'ident' && ['in', 'is'].includes(prev.text.toLowerCase()))) {
63
+ const fieldTok = fieldTokenBefore(real, real.length - 2)
64
+ const field = fieldTok ? findField(fields, fieldTok) : undefined
65
+ return field ? valueSuggestions(field).filter((s) => s.label.toLowerCase().startsWith(word)) : []
66
+ }
67
+ if (KEYWORD_TOKENS.has(word)) return matchFields('')
68
+ const exact = findField(fields, last.text)
69
+ if (exact) return operatorsFor(exact).map(opSuggestion)
70
+ return matchFields(last.text)
71
+ }
72
+
73
+ // right after an operator → values of the field on its left
74
+ if (last.kind === 'op' || (last.kind === 'ident' && ['in', 'is'].includes(last.text.toLowerCase()))) {
75
+ const fieldTok = fieldTokenBefore(real, real.length - 2)
76
+ const field = fieldTok ? findField(fields, fieldTok) : undefined
77
+ return field ? valueSuggestions(field) : []
78
+ }
79
+ if (last.kind === 'lparen' || last.kind === 'comma') {
80
+ const fieldTok = fieldTokenBefore(real, real.length - 1)
81
+ const field = fieldTok ? findField(fields, fieldTok) : undefined
82
+ return field ? valueSuggestions(field) : matchFields('')
83
+ }
84
+
85
+ // a complete term: offer connectives
86
+ return [
87
+ { kind: 'keyword', label: 'and', insertText: ' and ' },
88
+ { kind: 'keyword', label: 'or', insertText: ' or ' },
89
+ { kind: 'keyword', label: 'order by', insertText: ' order by ' },
90
+ ]
91
+ }
92
+
93
+ /** Walk left from `from` to the nearest bareword that is not a keyword — the field of this comparison. */
94
+ function fieldTokenBefore(tokens: ReturnType<typeof tokenize>['tokens'], from: number): string | null {
95
+ for (let i = from; i >= 0; i--) {
96
+ const t = tokens[i]
97
+ if (t?.kind !== 'ident') continue
98
+ if (KEYWORD_TOKENS.has(t.text.toLowerCase())) continue
99
+ return t.text
100
+ }
101
+ return null
102
+ }
@@ -0,0 +1,89 @@
1
+ import type { KqlComparison, KqlQuery, KqlValue } from './ast.js'
2
+ import { walkComparisons } from './ast.js'
3
+ import { findField, KQL_FUNCTIONS, type KqlField, operatorsFor } from './fields.js'
4
+
5
+ export interface KqlIssue {
6
+ message: string
7
+ start: number
8
+ end: number
9
+ }
10
+
11
+ const FUNCTIONS = new Map(KQL_FUNCTIONS.map((f) => [f.name.toLowerCase(), f]))
12
+
13
+ const argCountOk = (spec: number | string, got: number): boolean =>
14
+ typeof spec === 'number' ? got === spec : spec === '0-1' ? got <= 1 : true
15
+
16
+ /** Semantic checks: known fields, operators the field supports, plausible values, known functions. */
17
+ export function validateQuery(query: KqlQuery, fields: readonly KqlField[]): KqlIssue[] {
18
+ const issues: KqlIssue[] = []
19
+ walkComparisons(query.where, (cmp) => issues.push(...checkComparison(cmp, fields)))
20
+ for (const order of query.orderBy) {
21
+ const field = findField(fields, order.field)
22
+ if (!field) {
23
+ issues.push({ message: `Unknown field "${order.field}"`, ...order.span })
24
+ continue
25
+ }
26
+ // only fields explicitly marked sortable have a deterministic column to order by
27
+ if (!field.sortable) issues.push({ message: `Field "${order.field}" cannot be sorted on`, ...order.span })
28
+ }
29
+ return issues
30
+ }
31
+
32
+ function checkComparison(cmp: KqlComparison, fields: readonly KqlField[]): KqlIssue[] {
33
+ const issues: KqlIssue[] = []
34
+ const field = findField(fields, cmp.field)
35
+ if (!field) {
36
+ return [{ message: `Unknown field "${cmp.field}"`, start: cmp.span.start, end: cmp.span.end }]
37
+ }
38
+ const allowed = operatorsFor(field)
39
+ if (!allowed.includes(cmp.op))
40
+ issues.push({
41
+ message: `Operator "${cmp.op}" is not supported for "${field.name}" (try ${allowed.join(', ')})`,
42
+ start: cmp.span.start,
43
+ end: cmp.span.end,
44
+ })
45
+ for (const v of [cmp.value, ...(cmp.values ?? [])]) if (v) issues.push(...checkValue(v, field))
46
+ return issues
47
+ }
48
+
49
+ function checkValue(value: KqlValue, field: KqlField): KqlIssue[] {
50
+ if (value.kind === 'func') {
51
+ const spec = FUNCTIONS.get(value.name.toLowerCase())
52
+ if (!spec) return [{ message: `Unknown function "${value.name}()"`, ...value.span }]
53
+ if (!argCountOk(spec.args, value.args.length))
54
+ return [{ message: `${value.name}() expects ${spec.args} argument(s)`, ...value.span }]
55
+ return []
56
+ }
57
+ if (value.kind === 'null') return []
58
+ switch (field.kind) {
59
+ case 'number':
60
+ if (value.kind !== 'number') return [{ message: `"${field.name}" expects a number`, ...value.span }]
61
+ return []
62
+ case 'boolean':
63
+ if (value.kind !== 'bool') return [{ message: `"${field.name}" expects true or false`, ...value.span }]
64
+ return []
65
+ case 'date':
66
+ case 'datetime':
67
+ if (value.kind !== 'date' && value.kind !== 'reldate')
68
+ return [
69
+ {
70
+ message: `"${field.name}" expects a date (2026-08-22), a relative date (-7d) or a function`,
71
+ ...value.span,
72
+ },
73
+ ]
74
+ return []
75
+ case 'enum': {
76
+ const text = value.kind === 'string' || value.kind === 'ident' ? String(value.value) : null
77
+ if (text && field.enumValues && !field.enumValues.includes(text.toLowerCase()))
78
+ return [
79
+ {
80
+ message: `"${text}" is not a valid ${field.name} (${field.enumValues.join(', ')})`,
81
+ ...value.span,
82
+ },
83
+ ]
84
+ return []
85
+ }
86
+ default:
87
+ return []
88
+ }
89
+ }