@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.
package/src/kql/ast.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * KQL — Kern Query Language (JQL-like) abstract syntax tree.
3
+ * Produced by `parse()`, consumed by the SQL compiler and the visual filter builder.
4
+ */
5
+
6
+ export type KqlOp =
7
+ | '='
8
+ | '!='
9
+ | '<'
10
+ | '<='
11
+ | '>'
12
+ | '>='
13
+ | '~'
14
+ | '!~'
15
+ | 'in'
16
+ | 'not-in'
17
+ | 'is-empty'
18
+ | 'is-not-empty'
19
+
20
+ export interface Span {
21
+ start: number
22
+ end: number
23
+ }
24
+
25
+ export type KqlValue =
26
+ | { kind: 'string'; value: string; span: Span }
27
+ | { kind: 'number'; value: number; span: Span }
28
+ | { kind: 'date'; value: string; span: Span }
29
+ /** relative date `-7d` / `+2w`; units d(ay) w(eek) m(onth) y(ear), h(our) */
30
+ | { kind: 'reldate'; amount: number; unit: 'h' | 'd' | 'w' | 'm' | 'y'; span: Span }
31
+ | { kind: 'bool'; value: boolean; span: Span }
32
+ | { kind: 'null'; span: Span }
33
+ /** bareword like `done` or `high` */
34
+ | { kind: 'ident'; value: string; span: Span }
35
+ /** function call: currentUser(), membersOf("qa"), startOfWeek(-1) */
36
+ | { kind: 'func'; name: string; args: KqlValue[]; span: Span }
37
+
38
+ export type KqlExpr =
39
+ | { kind: 'and'; children: KqlExpr[]; span: Span }
40
+ | { kind: 'or'; children: KqlExpr[]; span: Span }
41
+ | { kind: 'not'; child: KqlExpr; span: Span }
42
+ | KqlComparison
43
+
44
+ export interface KqlComparison {
45
+ kind: 'cmp'
46
+ field: string
47
+ op: KqlOp
48
+ /** absent for is-empty / is-not-empty */
49
+ value: KqlValue | null
50
+ /** list for in / not-in */
51
+ values: KqlValue[] | null
52
+ span: Span
53
+ }
54
+
55
+ export interface KqlOrder {
56
+ field: string
57
+ dir: 'asc' | 'desc'
58
+ span: Span
59
+ }
60
+
61
+ export interface KqlQuery {
62
+ where: KqlExpr | null
63
+ orderBy: KqlOrder[]
64
+ }
65
+
66
+ /** Walk every comparison in an expression. */
67
+ export function walkComparisons(expr: KqlExpr | null, fn: (cmp: KqlComparison) => void): void {
68
+ if (!expr) return
69
+ if (expr.kind === 'cmp') fn(expr)
70
+ else if (expr.kind === 'not') walkComparisons(expr.child, fn)
71
+ else for (const c of expr.children) walkComparisons(c, fn)
72
+ }
73
+
74
+ /** Collect field names used in where + order by. */
75
+ export function fieldsUsed(query: KqlQuery): string[] {
76
+ const out = new Set<string>()
77
+ walkComparisons(query.where, (c) => out.add(c.field))
78
+ for (const o of query.orderBy) out.add(o.field)
79
+ return [...out]
80
+ }
81
+
82
+ /** Pretty-print a value back to KQL text. */
83
+ export function printValue(v: KqlValue): string {
84
+ switch (v.kind) {
85
+ case 'string':
86
+ return JSON.stringify(v.value)
87
+ case 'number':
88
+ return String(v.value)
89
+ case 'date':
90
+ return v.value
91
+ case 'reldate':
92
+ return `${v.amount >= 0 ? '+' : ''}${v.amount}${v.unit}`
93
+ case 'bool':
94
+ return v.value ? 'true' : 'false'
95
+ case 'null':
96
+ return 'null'
97
+ case 'ident':
98
+ return /^[A-Za-z0-9_.-]+$/.test(v.value) ? v.value : JSON.stringify(v.value)
99
+ case 'func':
100
+ return `${v.name}(${v.args.map(printValue).join(', ')})`
101
+ }
102
+ }
103
+
104
+ /** Pretty-print a whole query (normalised form). */
105
+ export function printQuery(query: KqlQuery): string {
106
+ const expr = (e: KqlExpr, parent?: 'and' | 'or'): string => {
107
+ switch (e.kind) {
108
+ case 'cmp': {
109
+ if (e.op === 'is-empty') return `${e.field} is empty`
110
+ if (e.op === 'is-not-empty') return `${e.field} is not empty`
111
+ if (e.op === 'in' || e.op === 'not-in')
112
+ return `${e.field} ${e.op === 'in' ? 'in' : 'not in'} (${(e.values ?? []).map(printValue).join(', ')})`
113
+ return `${e.field} ${e.op} ${e.value ? printValue(e.value) : ''}`.trim()
114
+ }
115
+ case 'not': {
116
+ const inner = expr(e.child)
117
+ return e.child.kind === 'cmp' ? `not ${inner}` : `not (${inner})`
118
+ }
119
+ case 'and': {
120
+ const s = e.children.map((c) => expr(c, 'and')).join(' and ')
121
+ return parent === 'or' ? s : s
122
+ }
123
+ case 'or': {
124
+ const s = e.children.map((c) => expr(c, 'or')).join(' or ')
125
+ return parent === 'and' ? `(${s})` : s
126
+ }
127
+ }
128
+ }
129
+ const parts: string[] = []
130
+ if (query.where) parts.push(expr(query.where))
131
+ if (query.orderBy.length)
132
+ parts.push(
133
+ `order by ${query.orderBy.map((o) => `${o.field}${o.dir === 'desc' ? ' desc' : ''}`).join(', ')}`,
134
+ )
135
+ return parts.join(' ')
136
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Date maths for KQL literals and functions. Everything is computed in UTC: the tracker stores
3
+ * timestamps with time zone, and a query must mean the same thing wherever it runs.
4
+ */
5
+
6
+ export type RelUnit = 'h' | 'd' | 'w' | 'm' | 'y'
7
+
8
+ const DAY_MS = 86_400_000
9
+
10
+ /** Apply a relative offset (`-7d`, `+2w`) to a base instant. */
11
+ export function shift(base: Date, amount: number, unit: RelUnit): Date {
12
+ const d = new Date(base.getTime())
13
+ switch (unit) {
14
+ case 'h':
15
+ return new Date(d.getTime() + amount * 3_600_000)
16
+ case 'd':
17
+ return new Date(d.getTime() + amount * DAY_MS)
18
+ case 'w':
19
+ return new Date(d.getTime() + amount * 7 * DAY_MS)
20
+ case 'm': {
21
+ const target = new Date(d.getTime())
22
+ const day = target.getUTCDate()
23
+ target.setUTCDate(1)
24
+ target.setUTCMonth(target.getUTCMonth() + Math.trunc(amount))
25
+ // clamp (31 Jan + 1m → 28/29 Feb)
26
+ const lastDay = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth() + 1, 0)).getUTCDate()
27
+ target.setUTCDate(Math.min(day, lastDay))
28
+ return target
29
+ }
30
+ case 'y': {
31
+ const target = new Date(d.getTime())
32
+ target.setUTCFullYear(target.getUTCFullYear() + Math.trunc(amount))
33
+ return target
34
+ }
35
+ }
36
+ }
37
+
38
+ export function startOfDay(base: Date, offsetDays = 0): Date {
39
+ const d = new Date(base.getTime() + offsetDays * DAY_MS)
40
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
41
+ }
42
+
43
+ /** Start of the ISO week (Monday) containing `base`, shifted by `offsetWeeks`. */
44
+ export function startOfWeek(base: Date, offsetWeeks = 0, weekStartsOn = 1): Date {
45
+ const day = startOfDay(base)
46
+ const diff = (day.getUTCDay() - weekStartsOn + 7) % 7
47
+ return new Date(day.getTime() - diff * DAY_MS + offsetWeeks * 7 * DAY_MS)
48
+ }
49
+
50
+ export function startOfMonth(base: Date, offsetMonths = 0): Date {
51
+ return new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + offsetMonths, 1))
52
+ }
53
+
54
+ /** `YYYY-MM-DD` in UTC. */
55
+ export const dateOnly = (d: Date): string => d.toISOString().slice(0, 10)
56
+
57
+ /** Parse a KQL date literal (`2026-08-22` or a full ISO timestamp) into an instant. */
58
+ export function parseDateLiteral(value: string): Date | null {
59
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00.000Z` : value.replace(' ', 'T')
60
+ const d = new Date(iso)
61
+ return Number.isNaN(d.getTime()) ? null : d
62
+ }
@@ -0,0 +1,150 @@
1
+ import type { FieldType } from '../contract/models.js'
2
+ import type { KqlOp } from './ast.js'
3
+
4
+ export type KqlFieldKind =
5
+ | 'text'
6
+ | 'enum'
7
+ | 'user'
8
+ | 'number'
9
+ | 'date'
10
+ | 'datetime'
11
+ | 'boolean'
12
+ | 'ref'
13
+ | 'id'
14
+ | 'key'
15
+
16
+ export interface KqlField {
17
+ name: string
18
+ kind: KqlFieldKind
19
+ label: string
20
+ /** value is a uuid[] column (membership semantics for = / in) */
21
+ array?: boolean
22
+ sortable?: boolean
23
+ /** enum values for autocomplete/validation */
24
+ enumValues?: readonly string[]
25
+ /** ref fields resolve barewords/strings by name via the resolver (labels, cycles…) */
26
+ refType?:
27
+ | 'project'
28
+ | 'type'
29
+ | 'label'
30
+ | 'component'
31
+ | 'version'
32
+ | 'cycle'
33
+ | 'milestone'
34
+ | 'status'
35
+ | 'issue'
36
+ custom?: { key: string; fieldType: FieldType }
37
+ }
38
+
39
+ const EQ: KqlOp[] = ['=', '!=', 'in', 'not-in']
40
+ const EMPTY: KqlOp[] = ['is-empty', 'is-not-empty']
41
+ const CMP: KqlOp[] = ['<', '<=', '>', '>=']
42
+ const LIKE: KqlOp[] = ['~', '!~']
43
+
44
+ export function operatorsFor(f: KqlField): KqlOp[] {
45
+ switch (f.kind) {
46
+ case 'text':
47
+ return [...LIKE, '=', '!=', ...EMPTY]
48
+ case 'enum':
49
+ return [...EQ, ...CMP, ...EMPTY]
50
+ case 'user':
51
+ return [...EQ, ...EMPTY]
52
+ case 'number':
53
+ return ['=', '!=', ...CMP, 'in', 'not-in', ...EMPTY]
54
+ case 'date':
55
+ case 'datetime':
56
+ return ['=', '!=', ...CMP, ...EMPTY]
57
+ case 'boolean':
58
+ return ['=', '!=']
59
+ case 'ref':
60
+ case 'id':
61
+ return [...EQ, ...EMPTY]
62
+ case 'key':
63
+ return [...EQ, ...LIKE]
64
+ }
65
+ }
66
+
67
+ export const PRIORITIES = ['none', 'low', 'medium', 'high', 'urgent'] as const
68
+ export const STATUS_CATEGORIES = ['backlog', 'todo', 'in_progress', 'done', 'cancelled', 'triage'] as const
69
+
70
+ /** System fields available in every KQL query. Custom fields are added as `cf.<key>`. */
71
+ export const SYSTEM_FIELDS: readonly KqlField[] = [
72
+ { name: 'key', kind: 'key', label: 'Issue key', sortable: true },
73
+ { name: 'project', kind: 'ref', refType: 'project', label: 'Project', sortable: true },
74
+ { name: 'type', kind: 'ref', refType: 'type', label: 'Type', sortable: true },
75
+ { name: 'title', kind: 'text', label: 'Title', sortable: true },
76
+ { name: 'status', kind: 'ref', refType: 'status', label: 'Status', sortable: true },
77
+ {
78
+ name: 'statusCategory',
79
+ kind: 'enum',
80
+ label: 'Status category',
81
+ enumValues: STATUS_CATEGORIES,
82
+ sortable: true,
83
+ },
84
+ { name: 'priority', kind: 'enum', label: 'Priority', enumValues: PRIORITIES, sortable: true },
85
+ { name: 'assignee', kind: 'user', array: true, label: 'Assignee' },
86
+ { name: 'reporter', kind: 'user', label: 'Reporter' },
87
+ { name: 'label', kind: 'ref', refType: 'label', array: true, label: 'Label' },
88
+ { name: 'component', kind: 'ref', refType: 'component', array: true, label: 'Component' },
89
+ { name: 'version', kind: 'ref', refType: 'version', array: true, label: 'Fix version' },
90
+ { name: 'affectsVersion', kind: 'ref', refType: 'version', array: true, label: 'Affects version' },
91
+ { name: 'cycle', kind: 'ref', refType: 'cycle', label: 'Cycle', sortable: true },
92
+ { name: 'milestone', kind: 'ref', refType: 'milestone', label: 'Milestone', sortable: true },
93
+ { name: 'parent', kind: 'ref', refType: 'issue', label: 'Parent', sortable: true },
94
+ { name: 'created', kind: 'datetime', label: 'Created', sortable: true },
95
+ { name: 'updated', kind: 'datetime', label: 'Updated', sortable: true },
96
+ { name: 'completed', kind: 'datetime', label: 'Completed', sortable: true },
97
+ { name: 'due', kind: 'date', label: 'Due date', sortable: true },
98
+ { name: 'start', kind: 'date', label: 'Start date', sortable: true },
99
+ { name: 'estimate', kind: 'number', label: 'Estimate', sortable: true },
100
+ { name: 'timeSpent', kind: 'number', label: 'Time spent (s)', sortable: true },
101
+ { name: 'watcher', kind: 'user', array: true, label: 'Watcher' },
102
+ { name: 'resolution', kind: 'text', label: 'Resolution', sortable: true },
103
+ { name: 'text', kind: 'text', label: 'Full text' },
104
+ { name: 'triage', kind: 'boolean', label: 'In triage', sortable: true },
105
+ { name: 'archived', kind: 'boolean', label: 'Archived' },
106
+ /** sort-only pseudo field (manual order) */
107
+ { name: 'rank', kind: 'text', label: 'Manual rank', sortable: true },
108
+ ] as const
109
+
110
+ export const KQL_FUNCTIONS = [
111
+ { name: 'currentUser', args: 0, detail: 'The authenticated user' },
112
+ { name: 'now', args: 0, detail: 'Current timestamp' },
113
+ { name: 'startOfDay', args: '0-1', detail: 'Midnight today (optional day offset)' },
114
+ { name: 'startOfWeek', args: '0-1', detail: 'Start of this week (optional week offset)' },
115
+ { name: 'startOfMonth', args: '0-1', detail: 'Start of this month (optional month offset)' },
116
+ { name: 'membersOf', args: 1, detail: 'Members of a workspace group' },
117
+ { name: 'activeCycle', args: 0, detail: 'The active cycle(s) of the queried projects' },
118
+ { name: 'openCycles', args: 0, detail: 'Active + upcoming cycles' },
119
+ ] as const
120
+
121
+ export function findField(fields: readonly KqlField[], name: string): KqlField | undefined {
122
+ const lower = name.toLowerCase()
123
+ return fields.find((f) => f.name.toLowerCase() === lower)
124
+ }
125
+
126
+ /** Map a custom field def to a KQL field (`cf.<key>`). */
127
+ export function customKqlField(key: string, fieldType: FieldType, label: string): KqlField {
128
+ const kind: KqlFieldKind =
129
+ fieldType === 'number' || fieldType === 'formula'
130
+ ? 'number'
131
+ : fieldType === 'date'
132
+ ? 'date'
133
+ : fieldType === 'datetime'
134
+ ? 'datetime'
135
+ : fieldType === 'checkbox'
136
+ ? 'boolean'
137
+ : fieldType === 'user' || fieldType === 'multiuser'
138
+ ? 'user'
139
+ : fieldType === 'select' || fieldType === 'multiselect' || fieldType === 'label'
140
+ ? 'enum'
141
+ : 'text'
142
+ return {
143
+ name: `cf.${key}`,
144
+ kind,
145
+ label,
146
+ array: fieldType === 'multiselect' || fieldType === 'multiuser' || fieldType === 'label',
147
+ sortable: kind === 'number' || kind === 'date' || kind === 'datetime' || kind === 'text',
148
+ custom: { key, fieldType },
149
+ }
150
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * KQL — Kern Query Language.
3
+ *
4
+ * A JQL-like query language over tracker issues: `assignee = currentUser() and status != done
5
+ * order by priority desc`. This entry point is isomorphic (no database, no Node built-ins) so the
6
+ * app can parse, validate and autocomplete a query while the user types; the SQL compiler that turns
7
+ * a parsed query into a statement lives server-side in `@kernhq/module-tracker/server`.
8
+ */
9
+ export * from './ast.js'
10
+ export * from './dates.js'
11
+ export * from './fields.js'
12
+ export { type LexError, type LexResult, type Token, type TokenKind, tokenize } from './lexer.js'
13
+ export { type ParseError, type ParseResult, parseKql } from './parser.js'
14
+ export { suggest } from './suggest.js'
15
+ export { type KqlIssue, validateQuery } from './validate.js'