@kernhq/module-tracker 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 (158) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +265 -0
  3. package/dist/contract/events.d.ts +140 -0
  4. package/dist/contract/events.d.ts.map +1 -0
  5. package/dist/contract/events.js +51 -0
  6. package/dist/contract/events.js.map +1 -0
  7. package/dist/contract/index.d.ts +6 -0
  8. package/dist/contract/index.d.ts.map +1 -0
  9. package/dist/contract/index.js +6 -0
  10. package/dist/contract/index.js.map +1 -0
  11. package/dist/contract/models.d.ts +2547 -0
  12. package/dist/contract/models.d.ts.map +1 -0
  13. package/dist/contract/models.js +1214 -0
  14. package/dist/contract/models.js.map +1 -0
  15. package/dist/contract/notifications.d.ts +4 -0
  16. package/dist/contract/notifications.d.ts.map +1 -0
  17. package/dist/contract/notifications.js +46 -0
  18. package/dist/contract/notifications.js.map +1 -0
  19. package/dist/contract/permissions.d.ts +161 -0
  20. package/dist/contract/permissions.d.ts.map +1 -0
  21. package/dist/contract/permissions.js +192 -0
  22. package/dist/contract/permissions.js.map +1 -0
  23. package/dist/contract/router.d.ts +9659 -0
  24. package/dist/contract/router.d.ts.map +1 -0
  25. package/dist/contract/router.js +732 -0
  26. package/dist/contract/router.js.map +1 -0
  27. package/dist/kql/ast.d.ts +90 -0
  28. package/dist/kql/ast.d.ts.map +1 -0
  29. package/dist/kql/ast.js +80 -0
  30. package/dist/kql/ast.js.map +1 -0
  31. package/dist/kql/dates.d.ts +16 -0
  32. package/dist/kql/dates.d.ts.map +1 -0
  33. package/dist/kql/dates.js +54 -0
  34. package/dist/kql/dates.js.map +1 -0
  35. package/dist/kql/fields.d.ts +61 -0
  36. package/dist/kql/fields.d.ts.map +1 -0
  37. package/dist/kql/fields.js +106 -0
  38. package/dist/kql/fields.js.map +1 -0
  39. package/dist/kql/index.d.ts +16 -0
  40. package/dist/kql/index.d.ts.map +1 -0
  41. package/dist/kql/index.js +16 -0
  42. package/dist/kql/index.js.map +1 -0
  43. package/dist/kql/lexer.d.ts +28 -0
  44. package/dist/kql/lexer.d.ts.map +1 -0
  45. package/dist/kql/lexer.js +127 -0
  46. package/dist/kql/lexer.js.map +1 -0
  47. package/dist/kql/parser.d.ts +14 -0
  48. package/dist/kql/parser.d.ts.map +1 -0
  49. package/dist/kql/parser.js +290 -0
  50. package/dist/kql/parser.js.map +1 -0
  51. package/dist/kql/suggest.d.ts +8 -0
  52. package/dist/kql/suggest.d.ts.map +1 -0
  53. package/dist/kql/suggest.js +96 -0
  54. package/dist/kql/suggest.js.map +1 -0
  55. package/dist/kql/validate.d.ts +10 -0
  56. package/dist/kql/validate.d.ts.map +1 -0
  57. package/dist/kql/validate.js +84 -0
  58. package/dist/kql/validate.js.map +1 -0
  59. package/dist/server/index.d.ts +17 -0
  60. package/dist/server/index.d.ts.map +1 -0
  61. package/dist/server/index.js +583 -0
  62. package/dist/server/index.js.map +1 -0
  63. package/dist/server/kql/compile.d.ts +49 -0
  64. package/dist/server/kql/compile.d.ts.map +1 -0
  65. package/dist/server/kql/compile.js +372 -0
  66. package/dist/server/kql/compile.js.map +1 -0
  67. package/dist/server/rank.d.ts +23 -0
  68. package/dist/server/rank.d.ts.map +1 -0
  69. package/dist/server/rank.js +66 -0
  70. package/dist/server/rank.js.map +1 -0
  71. package/dist/server/rich.d.ts +30 -0
  72. package/dist/server/rich.d.ts.map +1 -0
  73. package/dist/server/rich.js +97 -0
  74. package/dist/server/rich.js.map +1 -0
  75. package/dist/server/router.d.ts +10589 -0
  76. package/dist/server/router.d.ts.map +1 -0
  77. package/dist/server/router.js +431 -0
  78. package/dist/server/router.js.map +1 -0
  79. package/dist/server/schema.d.ts +6622 -0
  80. package/dist/server/schema.d.ts.map +1 -0
  81. package/dist/server/schema.js +581 -0
  82. package/dist/server/schema.js.map +1 -0
  83. package/dist/server/services/access.d.ts +53 -0
  84. package/dist/server/services/access.d.ts.map +1 -0
  85. package/dist/server/services/access.js +146 -0
  86. package/dist/server/services/access.js.map +1 -0
  87. package/dist/server/services/comments.d.ts +36 -0
  88. package/dist/server/services/comments.d.ts.map +1 -0
  89. package/dist/server/services/comments.js +231 -0
  90. package/dist/server/services/comments.js.map +1 -0
  91. package/dist/server/services/config.d.ts +130 -0
  92. package/dist/server/services/config.d.ts.map +1 -0
  93. package/dist/server/services/config.js +797 -0
  94. package/dist/server/services/config.js.map +1 -0
  95. package/dist/server/services/db.d.ts +93 -0
  96. package/dist/server/services/db.d.ts.map +1 -0
  97. package/dist/server/services/db.js +519 -0
  98. package/dist/server/services/db.js.map +1 -0
  99. package/dist/server/services/imports.d.ts +37 -0
  100. package/dist/server/services/imports.d.ts.map +1 -0
  101. package/dist/server/services/imports.js +324 -0
  102. package/dist/server/services/imports.js.map +1 -0
  103. package/dist/server/services/index.d.ts +36 -0
  104. package/dist/server/services/index.d.ts.map +1 -0
  105. package/dist/server/services/index.js +57 -0
  106. package/dist/server/services/index.js.map +1 -0
  107. package/dist/server/services/intake.d.ts +47 -0
  108. package/dist/server/services/intake.d.ts.map +1 -0
  109. package/dist/server/services/intake.js +268 -0
  110. package/dist/server/services/intake.js.map +1 -0
  111. package/dist/server/services/issues.d.ts +763 -0
  112. package/dist/server/services/issues.d.ts.map +1 -0
  113. package/dist/server/services/issues.js +1381 -0
  114. package/dist/server/services/issues.js.map +1 -0
  115. package/dist/server/services/notify.d.ts +51 -0
  116. package/dist/server/services/notify.d.ts.map +1 -0
  117. package/dist/server/services/notify.js +90 -0
  118. package/dist/server/services/notify.js.map +1 -0
  119. package/dist/server/services/planning.d.ts +62 -0
  120. package/dist/server/services/planning.d.ts.map +1 -0
  121. package/dist/server/services/planning.js +614 -0
  122. package/dist/server/services/planning.js.map +1 -0
  123. package/dist/server/services/projects.d.ts +43 -0
  124. package/dist/server/services/projects.d.ts.map +1 -0
  125. package/dist/server/services/projects.js +375 -0
  126. package/dist/server/services/projects.js.map +1 -0
  127. package/dist/server/services/query.d.ts +24 -0
  128. package/dist/server/services/query.d.ts.map +1 -0
  129. package/dist/server/services/query.js +312 -0
  130. package/dist/server/services/query.js.map +1 -0
  131. package/dist/server/services/reports.d.ts +30 -0
  132. package/dist/server/services/reports.d.ts.map +1 -0
  133. package/dist/server/services/reports.js +295 -0
  134. package/dist/server/services/reports.js.map +1 -0
  135. package/dist/server/services/time.d.ts +53 -0
  136. package/dist/server/services/time.d.ts.map +1 -0
  137. package/dist/server/services/time.js +192 -0
  138. package/dist/server/services/time.js.map +1 -0
  139. package/dist/server/services/transitions.d.ts +61 -0
  140. package/dist/server/services/transitions.d.ts.map +1 -0
  141. package/dist/server/services/transitions.js +493 -0
  142. package/dist/server/services/transitions.js.map +1 -0
  143. package/dist/server/services/views.d.ts +33 -0
  144. package/dist/server/services/views.d.ts.map +1 -0
  145. package/dist/server/services/views.js +195 -0
  146. package/dist/server/services/views.js.map +1 -0
  147. package/migrations/0000_init.sql +529 -0
  148. package/migrations/0001_rls.sql +259 -0
  149. package/migrations/meta/0000_snapshot.json +4140 -0
  150. package/migrations/meta/_journal.json +20 -0
  151. package/package.json +82 -0
  152. package/src/client/api.ts +15 -0
  153. package/src/client/format.ts +164 -0
  154. package/src/client/group.ts +150 -0
  155. package/src/client/index.ts +60 -0
  156. package/src/client/kql.ts +492 -0
  157. package/src/client/rank.ts +87 -0
  158. package/src/client/types.ts +57 -0
@@ -0,0 +1,492 @@
1
+ import type { KqlError, KqlSuggestion } from '../contract/models.js'
2
+ import type { KqlExpr, KqlOp, KqlOrder, KqlQuery, KqlValue, Span } from '../kql/ast.js'
3
+ import { printQuery } from '../kql/ast.js'
4
+ import {
5
+ customKqlField,
6
+ findField,
7
+ KQL_FUNCTIONS,
8
+ type KqlField,
9
+ operatorsFor,
10
+ SYSTEM_FIELDS,
11
+ } from '../kql/fields.js'
12
+
13
+ /**
14
+ * A KQL reader for the query box.
15
+ *
16
+ * The server is the authority: it parses the same grammar and compiles it to SQL. This one exists so
17
+ * the input can underline a mistake and offer the next token while you type, without a round trip
18
+ * per keystroke, and so the mock backend can answer queries at all. It produces the same
19
+ * `KqlParseResult` shape the `kql.parse` procedure returns, so a component can be switched from one
20
+ * to the other without changing anything else.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------------------------
24
+ // lexer
25
+ // ---------------------------------------------------------------------------------------------
26
+
27
+ type TokenKind = 'ident' | 'string' | 'number' | 'date' | 'reldate' | 'op' | 'punct' | 'eof'
28
+
29
+ interface Token {
30
+ kind: TokenKind
31
+ /** the source text, with quotes stripped for strings */
32
+ text: string
33
+ raw: string
34
+ start: number
35
+ end: number
36
+ }
37
+
38
+ const OPERATORS = ['!=', '!~', '<=', '>=', '=', '<', '>', '~']
39
+ const IDENT_START = /[A-Za-z_]/
40
+ const IDENT_BODY = /[A-Za-z0-9_.\-/]/
41
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
42
+ const RELDATE = /^[+-]?\d+(?:\.\d+)?([hdwmy])$/
43
+
44
+ /** Words that end a bareword value and start a new clause. */
45
+ const KEYWORDS = new Set(['and', 'or', 'not', 'in', 'is', 'empty', 'order', 'by', 'asc', 'desc'])
46
+
47
+ function lex(src: string): { tokens: Token[]; errors: KqlError[] } {
48
+ const tokens: Token[] = []
49
+ const errors: KqlError[] = []
50
+ let i = 0
51
+
52
+ while (i < src.length) {
53
+ const ch = src[i] as string
54
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
55
+ i++
56
+ continue
57
+ }
58
+
59
+ if (ch === '"' || ch === "'") {
60
+ const start = i
61
+ const quote = ch
62
+ let value = ''
63
+ i++
64
+ let closed = false
65
+ while (i < src.length) {
66
+ const c = src[i] as string
67
+ if (c === '\\' && i + 1 < src.length) {
68
+ value += src[i + 1]
69
+ i += 2
70
+ continue
71
+ }
72
+ if (c === quote) {
73
+ i++
74
+ closed = true
75
+ break
76
+ }
77
+ value += c
78
+ i++
79
+ }
80
+ if (!closed) errors.push({ message: 'Unclosed quote', start, end: src.length })
81
+ tokens.push({ kind: 'string', text: value, raw: src.slice(start, i), start, end: i })
82
+ continue
83
+ }
84
+
85
+ if (ch === '(' || ch === ')' || ch === ',') {
86
+ tokens.push({ kind: 'punct', text: ch, raw: ch, start: i, end: i + 1 })
87
+ i++
88
+ continue
89
+ }
90
+
91
+ const op = OPERATORS.find((o) => src.startsWith(o, i))
92
+ if (op) {
93
+ tokens.push({ kind: 'op', text: op, raw: op, start: i, end: i + op.length })
94
+ i += op.length
95
+ continue
96
+ }
97
+
98
+ // numbers, dates and relative dates all start with a digit or a sign
99
+ if (/[0-9]/.test(ch) || ((ch === '-' || ch === '+') && /[0-9]/.test(src[i + 1] ?? ''))) {
100
+ const start = i
101
+ i++
102
+ while (i < src.length && /[0-9.:\-a-zA-Z]/.test(src[i] as string)) i++
103
+ const raw = src.slice(start, i)
104
+ const kind: TokenKind = DATE.test(raw) ? 'date' : RELDATE.test(raw) ? 'reldate' : 'number'
105
+ if (kind === 'number' && !/^[+-]?\d+(\.\d+)?$/.test(raw)) {
106
+ errors.push({ message: `Not a number, date or relative date: ${raw}`, start, end: i })
107
+ }
108
+ tokens.push({ kind, text: raw, raw, start, end: i })
109
+ continue
110
+ }
111
+
112
+ if (IDENT_START.test(ch)) {
113
+ const start = i
114
+ while (i < src.length && IDENT_BODY.test(src[i] as string)) i++
115
+ const raw = src.slice(start, i)
116
+ tokens.push({ kind: 'ident', text: raw, raw, start, end: i })
117
+ continue
118
+ }
119
+
120
+ errors.push({ message: `Unexpected character ${JSON.stringify(ch)}`, start: i, end: i + 1 })
121
+ i++
122
+ }
123
+
124
+ tokens.push({ kind: 'eof', text: '', raw: '', start: src.length, end: src.length })
125
+ return { tokens, errors }
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------------------------
129
+ // parser
130
+ // ---------------------------------------------------------------------------------------------
131
+
132
+ class Parser {
133
+ private pos = 0
134
+ readonly errors: KqlError[] = []
135
+
136
+ constructor(
137
+ private readonly tokens: Token[],
138
+ private readonly fields: readonly KqlField[],
139
+ ) {}
140
+
141
+ private peek(offset = 0): Token {
142
+ return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)] as Token
143
+ }
144
+ private next(): Token {
145
+ const t = this.peek()
146
+ if (t.kind !== 'eof') this.pos++
147
+ return t
148
+ }
149
+ private isWord(word: string, offset = 0): boolean {
150
+ const t = this.peek(offset)
151
+ return t.kind === 'ident' && t.text.toLowerCase() === word
152
+ }
153
+ private eatWord(word: string): boolean {
154
+ if (!this.isWord(word)) return false
155
+ this.pos++
156
+ return true
157
+ }
158
+ private error(message: string, token = this.peek()) {
159
+ this.errors.push({ message, start: token.start, end: Math.max(token.end, token.start + 1) })
160
+ }
161
+ private span(from: Token, to = this.peek(-1)): Span {
162
+ return { start: from.start, end: Math.max(to.end, from.end) }
163
+ }
164
+
165
+ parseQuery(): KqlQuery {
166
+ const where = this.peek().kind === 'eof' || this.isWord('order') ? null : this.parseOr()
167
+ const orderBy = this.parseOrderBy()
168
+ if (this.peek().kind !== 'eof') {
169
+ this.error(`Unexpected ${JSON.stringify(this.peek().raw)}`)
170
+ }
171
+ return { where, orderBy }
172
+ }
173
+
174
+ private parseOrderBy(): KqlOrder[] {
175
+ if (!this.isWord('order')) return []
176
+ const start = this.next()
177
+ if (!this.eatWord('by')) this.error('Expected "by" after "order"')
178
+ const out: KqlOrder[] = []
179
+ for (;;) {
180
+ const token = this.peek()
181
+ if (token.kind !== 'ident') {
182
+ this.error('Expected a field to order by')
183
+ break
184
+ }
185
+ this.next()
186
+ const field = this.checkField(token, { sortable: true })
187
+ let dir: 'asc' | 'desc' = 'asc'
188
+ if (this.eatWord('desc')) dir = 'desc'
189
+ else this.eatWord('asc')
190
+ out.push({ field: field?.name ?? token.text, dir, span: this.span(token) })
191
+ if (this.peek().text === ',') {
192
+ this.next()
193
+ continue
194
+ }
195
+ break
196
+ }
197
+ if (out.length === 0) this.error('Expected at least one field after "order by"', start)
198
+ return out
199
+ }
200
+
201
+ private parseOr(): KqlExpr {
202
+ const first = this.peek()
203
+ const children = [this.parseAnd()]
204
+ while (this.eatWord('or')) children.push(this.parseAnd())
205
+ return children.length === 1 ? (children[0] as KqlExpr) : { kind: 'or', children, span: this.span(first) }
206
+ }
207
+
208
+ private parseAnd(): KqlExpr {
209
+ const first = this.peek()
210
+ const children = [this.parseUnary()]
211
+ while (this.eatWord('and')) children.push(this.parseUnary())
212
+ return children.length === 1
213
+ ? (children[0] as KqlExpr)
214
+ : { kind: 'and', children, span: this.span(first) }
215
+ }
216
+
217
+ private parseUnary(): KqlExpr {
218
+ const first = this.peek()
219
+ if (this.eatWord('not')) {
220
+ return { kind: 'not', child: this.parseUnary(), span: this.span(first) }
221
+ }
222
+ if (first.text === '(') {
223
+ this.next()
224
+ const inner = this.parseOr()
225
+ if (this.peek().text === ')') this.next()
226
+ else this.error('Expected ")"')
227
+ return inner
228
+ }
229
+ return this.parseComparison()
230
+ }
231
+
232
+ private parseComparison(): KqlExpr {
233
+ const fieldToken = this.peek()
234
+ if (fieldToken.kind !== 'ident') {
235
+ this.error(fieldToken.kind === 'eof' ? 'Expected a field' : `Expected a field, got ${fieldToken.raw}`)
236
+ this.next()
237
+ return { kind: 'cmp', field: '', op: '=', value: null, values: null, span: this.span(fieldToken) }
238
+ }
239
+ this.next()
240
+ const field = this.checkField(fieldToken)
241
+ // normalise to the field's canonical spelling so a saved query reads the same however it was typed
242
+ const name = field?.name ?? fieldToken.text
243
+
244
+ // `field is empty` / `field is not empty`
245
+ if (this.isWord('is')) {
246
+ this.next()
247
+ const negated = this.eatWord('not')
248
+ if (!this.eatWord('empty')) this.error('Expected "empty" after "is"')
249
+ const op: KqlOp = negated ? 'is-not-empty' : 'is-empty'
250
+ this.checkOperator(field, op, fieldToken)
251
+ return { kind: 'cmp', field: name, op, value: null, values: null, span: this.span(fieldToken) }
252
+ }
253
+
254
+ // `field in (a, b)` / `field not in (a, b)`
255
+ if (this.isWord('in') || (this.isWord('not') && this.isWord('in', 1))) {
256
+ const op: KqlOp = this.isWord('not') ? 'not-in' : 'in'
257
+ if (op === 'not-in') this.next()
258
+ this.next()
259
+ const values = this.parseValueList(field)
260
+ this.checkOperator(field, op, fieldToken)
261
+ return { kind: 'cmp', field: name, op, value: null, values, span: this.span(fieldToken) }
262
+ }
263
+
264
+ const opToken = this.peek()
265
+ if (opToken.kind !== 'op') {
266
+ this.error('Expected an operator such as =, !=, ~ or "in"', opToken)
267
+ return { kind: 'cmp', field: name, op: '=', value: null, values: null, span: this.span(fieldToken) }
268
+ }
269
+ this.next()
270
+ const op = opToken.text as KqlOp
271
+ this.checkOperator(field, op, opToken)
272
+ const value = this.parseValue(field)
273
+ return { kind: 'cmp', field: name, op, value, values: null, span: this.span(fieldToken) }
274
+ }
275
+
276
+ private parseValueList(field: KqlField | undefined): KqlValue[] {
277
+ const values: KqlValue[] = []
278
+ if (this.peek().text !== '(') {
279
+ this.error('Expected "(" after "in"')
280
+ return values
281
+ }
282
+ this.next()
283
+ if (this.peek().text === ')') {
284
+ this.error('Expected at least one value')
285
+ this.next()
286
+ return values
287
+ }
288
+ for (;;) {
289
+ values.push(this.parseValue(field))
290
+ if (this.peek().text === ',') {
291
+ this.next()
292
+ continue
293
+ }
294
+ break
295
+ }
296
+ if (this.peek().text === ')') this.next()
297
+ else this.error('Expected ")"')
298
+ return values
299
+ }
300
+
301
+ private parseValue(field: KqlField | undefined): KqlValue {
302
+ const token = this.next()
303
+ const span: Span = { start: token.start, end: token.end }
304
+ switch (token.kind) {
305
+ case 'string':
306
+ return { kind: 'string', value: token.text, span }
307
+ case 'number':
308
+ return { kind: 'number', value: Number(token.text), span }
309
+ case 'date':
310
+ return { kind: 'date', value: token.text, span }
311
+ case 'reldate': {
312
+ const m = RELDATE.exec(token.text)
313
+ return {
314
+ kind: 'reldate',
315
+ amount: Number.parseFloat(token.text),
316
+ unit: (m?.[1] ?? 'd') as 'h' | 'd' | 'w' | 'm' | 'y',
317
+ span,
318
+ }
319
+ }
320
+ case 'ident': {
321
+ const lower = token.text.toLowerCase()
322
+ if (this.peek().text === '(') return this.parseFunction(token)
323
+ if (lower === 'true' || lower === 'false') return { kind: 'bool', value: lower === 'true', span }
324
+ if (lower === 'null' || lower === 'empty') return { kind: 'null', span }
325
+ if (KEYWORDS.has(lower)) this.error(`Expected a value, got the keyword "${token.text}"`, token)
326
+ else this.checkEnumValue(field, token)
327
+ return { kind: 'ident', value: token.text, span }
328
+ }
329
+ default:
330
+ this.error('Expected a value', token)
331
+ return { kind: 'null', span }
332
+ }
333
+ }
334
+
335
+ private parseFunction(nameToken: Token): KqlValue {
336
+ const known = KQL_FUNCTIONS.find((f) => f.name.toLowerCase() === nameToken.text.toLowerCase())
337
+ if (!known) this.error(`Unknown function ${nameToken.text}()`, nameToken)
338
+ this.next() // (
339
+ const args: KqlValue[] = []
340
+ if (this.peek().text !== ')') {
341
+ for (;;) {
342
+ args.push(this.parseValue(undefined))
343
+ if (this.peek().text === ',') {
344
+ this.next()
345
+ continue
346
+ }
347
+ break
348
+ }
349
+ }
350
+ if (this.peek().text === ')') this.next()
351
+ else this.error('Expected ")"')
352
+ return {
353
+ kind: 'func',
354
+ name: known?.name ?? nameToken.text,
355
+ args,
356
+ span: { start: nameToken.start, end: this.peek(-1).end },
357
+ }
358
+ }
359
+
360
+ private checkField(token: Token, opts: { sortable?: boolean } = {}): KqlField | undefined {
361
+ const field = findField(this.fields, token.text)
362
+ if (!field) {
363
+ this.error(`Unknown field "${token.text}"`, token)
364
+ return undefined
365
+ }
366
+ if (opts.sortable && field.sortable !== true) {
367
+ this.error(`"${field.name}" cannot be sorted on`, token)
368
+ }
369
+ return field
370
+ }
371
+
372
+ private checkOperator(field: KqlField | undefined, op: KqlOp, token: Token) {
373
+ if (!field) return
374
+ if (!operatorsFor(field).includes(op)) {
375
+ this.error(`"${field.name}" does not support ${printOp(op)}`, token)
376
+ }
377
+ }
378
+
379
+ private checkEnumValue(field: KqlField | undefined, token: Token) {
380
+ if (!field?.enumValues) return
381
+ if (!field.enumValues.some((v) => v.toLowerCase() === token.text.toLowerCase())) {
382
+ this.error(`"${token.text}" is not a valid ${field.label.toLowerCase()}`, token)
383
+ }
384
+ }
385
+ }
386
+
387
+ function printOp(op: KqlOp): string {
388
+ return op === 'is-empty' ? '"is empty"' : op === 'is-not-empty' ? '"is not empty"' : `"${op}"`
389
+ }
390
+
391
+ // ---------------------------------------------------------------------------------------------
392
+ // public API
393
+ // ---------------------------------------------------------------------------------------------
394
+
395
+ export interface ParseOptions {
396
+ /** system fields plus any `cf.*` custom fields the workspace defines */
397
+ fields?: readonly KqlField[]
398
+ /** caret offset, used to decide what to suggest next */
399
+ cursor?: number
400
+ }
401
+
402
+ export interface ParsedKql {
403
+ ok: boolean
404
+ ast: KqlQuery | null
405
+ errors: KqlError[]
406
+ normalized: string | null
407
+ suggestions: KqlSuggestion[]
408
+ }
409
+
410
+ /** Parse and validate a KQL string. Empty input is valid and matches everything. */
411
+ export function parseKql(source: string, opts: ParseOptions = {}): ParsedKql {
412
+ const fields = opts.fields ?? SYSTEM_FIELDS
413
+ const { tokens, errors: lexErrors } = lex(source)
414
+ const parser = new Parser(tokens, fields)
415
+ const ast = parser.parseQuery()
416
+ const errors = [...lexErrors, ...parser.errors].sort((a, b) => a.start - b.start)
417
+ const ok = errors.length === 0
418
+ return {
419
+ ok,
420
+ ast: ok ? ast : null,
421
+ errors,
422
+ normalized: ok ? printQuery(ast) : null,
423
+ suggestions: suggest(tokens, fields, opts.cursor ?? source.length),
424
+ }
425
+ }
426
+
427
+ /**
428
+ * What could come next at the caret.
429
+ *
430
+ * The grammar is regular enough that the token before the caret decides the answer: nothing or a
431
+ * connective means a field is expected, a field means an operator, an operator means a value.
432
+ */
433
+ function suggest(tokens: Token[], fields: readonly KqlField[], cursor: number): KqlSuggestion[] {
434
+ const before = tokens.filter((t) => t.kind !== 'eof' && t.end <= cursor)
435
+ // a token the caret sits inside is being typed, so it filters rather than terminates
436
+ const typing = tokens.find((t) => t.kind !== 'eof' && t.start < cursor && t.end >= cursor)
437
+ const prefix = typing && typing.end === cursor ? typing.text.toLowerCase() : ''
438
+ const anchor = prefix ? before[before.length - 2] : before[before.length - 1]
439
+
440
+ const matches = (label: string) => !prefix || label.toLowerCase().startsWith(prefix)
441
+ const fieldSuggestions = (): KqlSuggestion[] =>
442
+ fields
443
+ .filter((f) => matches(f.name))
444
+ .map((f) => ({
445
+ kind: 'field' as const,
446
+ label: f.name,
447
+ insertText: `${f.name} `,
448
+ detail: f.label,
449
+ }))
450
+
451
+ if (!anchor) return fieldSuggestions()
452
+
453
+ if (
454
+ anchor.kind === 'op' ||
455
+ (anchor.kind === 'ident' && ['in', 'not'].includes(anchor.text.toLowerCase()))
456
+ ) {
457
+ const fieldToken = [...before].reverse().find((t) => t.kind === 'ident' && findField(fields, t.text))
458
+ const field = fieldToken ? findField(fields, fieldToken.text) : undefined
459
+ const values: KqlSuggestion[] = (field?.enumValues ?? [])
460
+ .filter((v) => matches(v))
461
+ .map((v) => ({ kind: 'value' as const, label: v, insertText: `${v} `, detail: field?.label }))
462
+ const functions: KqlSuggestion[] = KQL_FUNCTIONS.filter((f) => matches(f.name)).map((f) => ({
463
+ kind: 'function' as const,
464
+ label: `${f.name}()`,
465
+ insertText: `${f.name}() `,
466
+ detail: f.detail,
467
+ }))
468
+ return [...values, ...functions]
469
+ }
470
+
471
+ if (anchor.kind === 'ident') {
472
+ const lower = anchor.text.toLowerCase()
473
+ if (KEYWORDS.has(lower)) return fieldSuggestions()
474
+ const field = findField(fields, anchor.text)
475
+ if (field) {
476
+ const ops: KqlSuggestion[] = operatorsFor(field)
477
+ .map((op) => (op === 'is-empty' ? 'is empty' : op === 'is-not-empty' ? 'is not empty' : op))
478
+ .filter((op) => matches(op))
479
+ .map((op) => ({ kind: 'operator' as const, label: op, insertText: `${op} ` }))
480
+ return ops
481
+ }
482
+ }
483
+
484
+ // after a complete clause the only sensible continuations are connectives
485
+ return ['and', 'or', 'order by']
486
+ .filter((k) => matches(k))
487
+ .map((k) => ({ kind: 'keyword' as const, label: k, insertText: `${k} ` }))
488
+ }
489
+
490
+ export type { KqlComparison, KqlExpr, KqlOp, KqlOrder, KqlQuery, KqlValue, Span } from '../kql/ast.js'
491
+ export type { KqlField }
492
+ export { customKqlField, operatorsFor, SYSTEM_FIELDS }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Fractional indexing for `Issue.rank`.
3
+ *
4
+ * Manual ordering (backlog, board columns) must survive concurrent edits: two people dragging
5
+ * different cards at the same time should not have to renumber anything. Each issue therefore
6
+ * carries a short string key, and inserting between two rows only mints a new key strictly between
7
+ * its neighbours — no other row is touched, and the result sorts with a plain string comparison.
8
+ *
9
+ * The alphabet is ordered by code point (digits, uppercase, lowercase) so lexicographic order and
10
+ * numeric order agree, which is what `ORDER BY rank` in Postgres relies on.
11
+ */
12
+
13
+ const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
14
+ const BASE = DIGITS.length
15
+
16
+ const indexOfDigit = (ch: string) => {
17
+ const i = DIGITS.indexOf(ch)
18
+ if (i < 0) throw new Error(`invalid rank character: ${JSON.stringify(ch)}`)
19
+ return i
20
+ }
21
+
22
+ /** True when `rank` only uses the rank alphabet and has no trailing zero (the canonical form). */
23
+ export function isValidRank(rank: string): boolean {
24
+ if (rank.length === 0) return false
25
+ for (const ch of rank) if (!DIGITS.includes(ch)) return false
26
+ return !rank.endsWith(DIGITS[0] as string)
27
+ }
28
+
29
+ /**
30
+ * A key strictly between `before` and `after`. Pass `null` for an open end: `rankBetween(null, first)`
31
+ * puts an item at the top of the list, `rankBetween(last, null)` at the bottom.
32
+ */
33
+ export function rankBetween(before: string | null, after: string | null): string {
34
+ const lo = before ?? ''
35
+ const hi = after ?? ''
36
+ if (lo && hi && lo >= hi) {
37
+ throw new Error(`rankBetween expects before < after, got ${JSON.stringify(lo)} >= ${JSON.stringify(hi)}`)
38
+ }
39
+
40
+ let prefix = ''
41
+ // once the digit we keep is strictly below the upper bound's digit, the rest of `hi` cannot
42
+ // constrain us any more and we are free to use the whole alphabet
43
+ let free = hi === ''
44
+
45
+ for (let i = 0; ; i++) {
46
+ const da = i < lo.length ? indexOfDigit(lo[i] as string) : -1
47
+ const db = free ? BASE : i < hi.length ? indexOfDigit(hi[i] as string) : 0
48
+ if (db - da > 1) return prefix + DIGITS[Math.floor((da + db) / 2)]
49
+ const kept = da >= 0 ? da : 0
50
+ prefix += DIGITS[kept]
51
+ if (!free && kept < db) free = true
52
+ }
53
+ }
54
+
55
+ /** First rank in an empty list. */
56
+ export function initialRank(): string {
57
+ return rankBetween(null, null)
58
+ }
59
+
60
+ /**
61
+ * `count` evenly spread ranks, for seeding a list. Cheaper and tidier than calling `rankBetween`
62
+ * repeatedly, which would bias every new key towards the end.
63
+ */
64
+ export function rankSequence(count: number): string[] {
65
+ const out: string[] = []
66
+ let prev: string | null = null
67
+ for (let i = 0; i < count; i++) {
68
+ prev = rankBetween(prev, null)
69
+ out.push(prev)
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Ascending comparator for anything carrying a `rank`. */
75
+ export function byRank(a: { rank: string }, b: { rank: string }): number {
76
+ return a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0
77
+ }
78
+
79
+ /**
80
+ * The rank an item needs to land at `targetIndex` of `ordered` (the list it is being dropped into,
81
+ * already excluding the dragged item).
82
+ */
83
+ export function rankForIndex(ordered: Array<{ rank: string }>, targetIndex: number): string {
84
+ const before = targetIndex > 0 ? (ordered[targetIndex - 1]?.rank ?? null) : null
85
+ const after = targetIndex < ordered.length ? (ordered[targetIndex]?.rank ?? null) : null
86
+ return rankBetween(before, after)
87
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The contract types a tracker UI works with, re-exported for hosts.
3
+ *
4
+ * Types only: nothing here survives compilation, so importing this file never pulls the zod schemas
5
+ * (or the server contract) into a browser bundle. Anything that needs the schemas at runtime should
6
+ * import `@kernhq/module-tracker/contract` instead.
7
+ */
8
+
9
+ export type {
10
+ ApprovalState,
11
+ Attachment,
12
+ AvailableTransition,
13
+ BoardColumn,
14
+ Comment,
15
+ Component,
16
+ CreateIssue,
17
+ Cycle,
18
+ CycleStatus,
19
+ DateOnly,
20
+ EstimateUnit,
21
+ FieldDef,
22
+ FieldOption,
23
+ FieldType,
24
+ GroupBy,
25
+ Issue,
26
+ IssueHistoryEntry,
27
+ IssueKey,
28
+ IssueQueryInput,
29
+ IssueQueryResult,
30
+ IssueSummary,
31
+ KqlFieldInfo,
32
+ KqlParseResult,
33
+ KqlSuggestion,
34
+ Label,
35
+ Link,
36
+ Milestone,
37
+ OrderBy,
38
+ Priority,
39
+ Project,
40
+ ProjectKey,
41
+ ProjectMember,
42
+ ReactionSummary,
43
+ RelationSummary,
44
+ RelationType,
45
+ RelationView,
46
+ RichDoc,
47
+ StatusCategory,
48
+ StatusHistoryEntry,
49
+ StatusInfo,
50
+ UpdateIssue,
51
+ Version,
52
+ View,
53
+ ViewDisplay,
54
+ ViewLayout,
55
+ Workflow,
56
+ WorkItemType,
57
+ } from '../contract/models.js'