@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.
- package/LICENSE +662 -0
- package/README.md +265 -0
- package/dist/contract/events.d.ts +140 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +51 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +6 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +6 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +2547 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +1214 -0
- package/dist/contract/models.js.map +1 -0
- package/dist/contract/notifications.d.ts +4 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +46 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +161 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +192 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +9659 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +732 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/kql/ast.d.ts +90 -0
- package/dist/kql/ast.d.ts.map +1 -0
- package/dist/kql/ast.js +80 -0
- package/dist/kql/ast.js.map +1 -0
- package/dist/kql/dates.d.ts +16 -0
- package/dist/kql/dates.d.ts.map +1 -0
- package/dist/kql/dates.js +54 -0
- package/dist/kql/dates.js.map +1 -0
- package/dist/kql/fields.d.ts +61 -0
- package/dist/kql/fields.d.ts.map +1 -0
- package/dist/kql/fields.js +106 -0
- package/dist/kql/fields.js.map +1 -0
- package/dist/kql/index.d.ts +16 -0
- package/dist/kql/index.d.ts.map +1 -0
- package/dist/kql/index.js +16 -0
- package/dist/kql/index.js.map +1 -0
- package/dist/kql/lexer.d.ts +28 -0
- package/dist/kql/lexer.d.ts.map +1 -0
- package/dist/kql/lexer.js +127 -0
- package/dist/kql/lexer.js.map +1 -0
- package/dist/kql/parser.d.ts +14 -0
- package/dist/kql/parser.d.ts.map +1 -0
- package/dist/kql/parser.js +290 -0
- package/dist/kql/parser.js.map +1 -0
- package/dist/kql/suggest.d.ts +8 -0
- package/dist/kql/suggest.d.ts.map +1 -0
- package/dist/kql/suggest.js +96 -0
- package/dist/kql/suggest.js.map +1 -0
- package/dist/kql/validate.d.ts +10 -0
- package/dist/kql/validate.d.ts.map +1 -0
- package/dist/kql/validate.js +84 -0
- package/dist/kql/validate.js.map +1 -0
- package/dist/server/index.d.ts +17 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +583 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/kql/compile.d.ts +49 -0
- package/dist/server/kql/compile.d.ts.map +1 -0
- package/dist/server/kql/compile.js +372 -0
- package/dist/server/kql/compile.js.map +1 -0
- package/dist/server/rank.d.ts +23 -0
- package/dist/server/rank.d.ts.map +1 -0
- package/dist/server/rank.js +66 -0
- package/dist/server/rank.js.map +1 -0
- package/dist/server/rich.d.ts +30 -0
- package/dist/server/rich.d.ts.map +1 -0
- package/dist/server/rich.js +97 -0
- package/dist/server/rich.js.map +1 -0
- package/dist/server/router.d.ts +10589 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +431 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +6622 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +581 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/services/access.d.ts +53 -0
- package/dist/server/services/access.d.ts.map +1 -0
- package/dist/server/services/access.js +146 -0
- package/dist/server/services/access.js.map +1 -0
- package/dist/server/services/comments.d.ts +36 -0
- package/dist/server/services/comments.d.ts.map +1 -0
- package/dist/server/services/comments.js +231 -0
- package/dist/server/services/comments.js.map +1 -0
- package/dist/server/services/config.d.ts +130 -0
- package/dist/server/services/config.d.ts.map +1 -0
- package/dist/server/services/config.js +797 -0
- package/dist/server/services/config.js.map +1 -0
- package/dist/server/services/db.d.ts +93 -0
- package/dist/server/services/db.d.ts.map +1 -0
- package/dist/server/services/db.js +519 -0
- package/dist/server/services/db.js.map +1 -0
- package/dist/server/services/imports.d.ts +37 -0
- package/dist/server/services/imports.d.ts.map +1 -0
- package/dist/server/services/imports.js +324 -0
- package/dist/server/services/imports.js.map +1 -0
- package/dist/server/services/index.d.ts +36 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +57 -0
- package/dist/server/services/index.js.map +1 -0
- package/dist/server/services/intake.d.ts +47 -0
- package/dist/server/services/intake.d.ts.map +1 -0
- package/dist/server/services/intake.js +268 -0
- package/dist/server/services/intake.js.map +1 -0
- package/dist/server/services/issues.d.ts +763 -0
- package/dist/server/services/issues.d.ts.map +1 -0
- package/dist/server/services/issues.js +1381 -0
- package/dist/server/services/issues.js.map +1 -0
- package/dist/server/services/notify.d.ts +51 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +90 -0
- package/dist/server/services/notify.js.map +1 -0
- package/dist/server/services/planning.d.ts +62 -0
- package/dist/server/services/planning.d.ts.map +1 -0
- package/dist/server/services/planning.js +614 -0
- package/dist/server/services/planning.js.map +1 -0
- package/dist/server/services/projects.d.ts +43 -0
- package/dist/server/services/projects.d.ts.map +1 -0
- package/dist/server/services/projects.js +375 -0
- package/dist/server/services/projects.js.map +1 -0
- package/dist/server/services/query.d.ts +24 -0
- package/dist/server/services/query.d.ts.map +1 -0
- package/dist/server/services/query.js +312 -0
- package/dist/server/services/query.js.map +1 -0
- package/dist/server/services/reports.d.ts +30 -0
- package/dist/server/services/reports.d.ts.map +1 -0
- package/dist/server/services/reports.js +295 -0
- package/dist/server/services/reports.js.map +1 -0
- package/dist/server/services/time.d.ts +53 -0
- package/dist/server/services/time.d.ts.map +1 -0
- package/dist/server/services/time.js +192 -0
- package/dist/server/services/time.js.map +1 -0
- package/dist/server/services/transitions.d.ts +61 -0
- package/dist/server/services/transitions.d.ts.map +1 -0
- package/dist/server/services/transitions.js +493 -0
- package/dist/server/services/transitions.js.map +1 -0
- package/dist/server/services/views.d.ts +33 -0
- package/dist/server/services/views.d.ts.map +1 -0
- package/dist/server/services/views.js +195 -0
- package/dist/server/services/views.js.map +1 -0
- package/migrations/0000_init.sql +529 -0
- package/migrations/0001_rls.sql +259 -0
- package/migrations/meta/0000_snapshot.json +4140 -0
- package/migrations/meta/_journal.json +20 -0
- package/package.json +82 -0
- package/src/client/api.ts +15 -0
- package/src/client/format.ts +164 -0
- package/src/client/group.ts +150 -0
- package/src/client/index.ts +60 -0
- package/src/client/kql.ts +492 -0
- package/src/client/rank.ts +87 -0
- 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'
|