storymapper 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 (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * SM-15 — Project Export / Import (JSON).
3
+ *
4
+ * Pure, UMD-wrapped helpers for serializing a project snapshot to a JSON
5
+ * export envelope and parsing/validating an uploaded file back into a
6
+ * snapshot. The browser glue (Blob download, <input type=file>, the
7
+ * overwrite-vs-copy dialog and the POST/PUT calls) lives in main.js — these
8
+ * functions carry the logic that's worth unit-testing in isolation.
9
+ *
10
+ * Export envelope:
11
+ * { format: "storymap-project", version: 1, exportedAt?: ISO, snapshot: {...} }
12
+ *
13
+ * Import is lenient: it accepts the envelope OR a bare snapshot (so a file
14
+ * pulled straight from GET /api/projects/:id also imports).
15
+ */
16
+ (function (root, factory) {
17
+ if (typeof module === "object" && module.exports) {
18
+ module.exports = factory();
19
+ } else {
20
+ (root.STORYMAP = root.STORYMAP || {}).projectIO = factory();
21
+ }
22
+ }(typeof self !== "undefined" ? self : this, function () {
23
+ "use strict";
24
+
25
+ const FORMAT = "storymap-project";
26
+ const VERSION = 1;
27
+
28
+ /**
29
+ * Serialize a snapshot into the on-disk export envelope (pretty JSON string).
30
+ * `opts.exportedAt` (ISO string) is included when supplied — kept as a param
31
+ * so the function stays pure/deterministic for tests.
32
+ */
33
+ function serializeProject(snapshot, opts) {
34
+ opts = opts || {};
35
+ const env = { format: FORMAT, version: VERSION };
36
+ if (opts.exportedAt) env.exportedAt = opts.exportedAt;
37
+ env.snapshot = snapshot;
38
+ return JSON.stringify(env, null, 2);
39
+ }
40
+
41
+ /** Suggested download filename for a snapshot: `<sanitized-id>.storymap.json`. */
42
+ function exportFilename(snapshot) {
43
+ const id = (snapshot && snapshot.project && snapshot.project.id) || "project";
44
+ return String(id).replace(/[^a-zA-Z0-9_-]/g, "_") + ".storymap.json";
45
+ }
46
+
47
+ /**
48
+ * Parse + validate imported text. Accepts the export envelope OR a bare
49
+ * snapshot; returns the snapshot. Throws Error with a human message on bad
50
+ * input (caller surfaces it via flashStatus).
51
+ */
52
+ function parseImport(text) {
53
+ let data;
54
+ try { data = JSON.parse(text); }
55
+ catch (e) { throw new Error("not valid JSON: " + e.message); }
56
+ if (!data || typeof data !== "object") throw new Error("file is not a JSON object");
57
+ const snap = (data.format === FORMAT || data.snapshot) ? data.snapshot : data;
58
+ if (!snap || typeof snap !== "object") throw new Error("no snapshot found in file");
59
+ if (!snap.project || typeof snap.project !== "object"
60
+ || typeof snap.project.id !== "string" || !snap.project.id) {
61
+ throw new Error("snapshot has no project.id");
62
+ }
63
+ for (const k of ["tickets", "releases", "processSteps"]) {
64
+ if (snap[k] != null && !Array.isArray(snap[k])) {
65
+ throw new Error(k + " must be an array");
66
+ }
67
+ }
68
+ return snap;
69
+ }
70
+
71
+ /** Derive a unique `<base>-copy[-N]` id given the set of existing ids. */
72
+ function suggestCopyId(baseId, existingIds) {
73
+ const taken = new Set(existingIds || []);
74
+ if (!taken.has(baseId + "-copy")) return baseId + "-copy";
75
+ let n = 2;
76
+ while (taken.has(baseId + "-copy-" + n)) n++;
77
+ return baseId + "-copy-" + n;
78
+ }
79
+
80
+ /**
81
+ * Decide the import plan. mode ∈ "overwrite" | "copy".
82
+ * Returns { targetId, isOverwrite, conflict }.
83
+ * - no conflict → import under the original id (create)
84
+ * - conflict + "overwrite" → replace the existing project
85
+ * - conflict + "copy" → import under a fresh `<id>-copy` id
86
+ */
87
+ function planImport(snapshot, existingIds, mode) {
88
+ const id = snapshot.project.id;
89
+ const conflict = (existingIds || []).indexOf(id) >= 0;
90
+ if (conflict && mode === "copy") {
91
+ return { targetId: suggestCopyId(id, existingIds), isOverwrite: false, conflict: true };
92
+ }
93
+ return { targetId: id, isOverwrite: conflict, conflict: conflict };
94
+ }
95
+
96
+ /** Deep clone of the snapshot with project.id re-pinned to `targetId`. */
97
+ function withProjectId(snapshot, targetId) {
98
+ const next = JSON.parse(JSON.stringify(snapshot));
99
+ next.project = next.project || {};
100
+ next.project.id = targetId;
101
+ return next;
102
+ }
103
+
104
+ return {
105
+ FORMAT, VERSION,
106
+ serializeProject, exportFilename, parseImport,
107
+ suggestCopyId, planImport, withProjectId
108
+ };
109
+ }));
@@ -0,0 +1,196 @@
1
+ /**
2
+ * SM-216 — JQL autocomplete suggestion engine (Story S5 of epic SM-187).
3
+ *
4
+ * Pure. No DOM. UMD; shared so it's unit-testable in Node and used by the
5
+ * browser dropdown (frontend/js/smartbar-autocomplete.js). Requires the parser
6
+ * (shared/query.js, for tokenize + keyword/operator catalogue) and the engine
7
+ * (shared/query-engine.js, for QUERY_FIELDS — the SINGLE source of fields + the
8
+ * per-field value lists).
9
+ *
10
+ * suggest(input, cursor, snapshot) → [{ insertText, label, kind, replaceStart,
11
+ * replaceEnd }]
12
+ *
13
+ * `kind` ∈ field | operator | value | keyword. The dropdown replaces the text in
14
+ * [replaceStart, replaceEnd] (the in-progress token) with insertText. Context is
15
+ * derived from a small state machine over the tokens to the LEFT of the cursor:
16
+ * at the start / after AND·OR·NOT·'(' → fields; after a field → operators; after
17
+ * an operator → that field's values; after a complete clause → AND·OR·')'·ORDER BY.
18
+ */
19
+ (function (global, factory) {
20
+ if (typeof module === "object" && module.exports) {
21
+ module.exports = factory(require("./query.js"), require("./query-engine.js"));
22
+ } else {
23
+ const ns = (global.STORYMAP = global.STORYMAP || {});
24
+ ns.queryAutocomplete = factory(ns.query, ns.queryEngine);
25
+ }
26
+ }(typeof window !== "undefined" ? window : globalThis, function (Q, engine) {
27
+ "use strict";
28
+
29
+ const MAX = 20; // enough to show every field with no prefix; values stay scrollable
30
+ const isKw = (t, kw) => t && t.type === "word" && String(t.value).toUpperCase() === kw;
31
+ const opToInsert = { "=": "= ", "!=": "!= ", "~": "~ ", "<": "< ", ">": "> ", "<=": "<= ", ">=": ">= " };
32
+ // The operators offered per field, derived from the field's clauseOps.
33
+ const OP_SUGGEST = { eq: "=", neq: "!=", contains: "~", in: "IN", "not-in": "NOT IN", lt: "<", gt: ">", lte: "<=", gte: ">=" };
34
+
35
+ /** Lenient tokenize of the text up to the cursor — query.tokenize throws on an
36
+ * unterminated string / unexpected char while typing, so fall back to the
37
+ * valid prefix + the in-progress tail as a partial token. */
38
+ function leftTokens(left) {
39
+ try {
40
+ return Q.tokenize(left).filter((t) => t.type !== "eof");
41
+ } catch (e) {
42
+ const pos = typeof e.position === "number" ? e.position : left.length;
43
+ let toks = [];
44
+ try { toks = Q.tokenize(left.slice(0, pos)).filter((t) => t.type !== "eof"); } catch (_e) { toks = []; }
45
+ const tail = left.slice(pos);
46
+ const quoted = tail[0] === '"' || tail[0] === "'";
47
+ toks.push({ type: quoted ? "string" : "word", value: quoted ? tail.slice(1) : tail, start: pos, end: left.length, partial: true });
48
+ return toks;
49
+ }
50
+ }
51
+
52
+ /** Walk the preceding tokens → { state, lastField }. */
53
+ function analyze(tokens) {
54
+ let state = "field", lastField = null;
55
+ for (const t of tokens) {
56
+ switch (state) {
57
+ case "field":
58
+ if (t.type === "lparen" || isKw(t, "NOT")) break; // still expecting a field
59
+ if (t.type === "word") { lastField = t.value; state = "operator"; }
60
+ break;
61
+ case "operator":
62
+ if (t.type === "op") state = "value";
63
+ else if (isKw(t, "IN")) state = "list-open";
64
+ else if (isKw(t, "NOT")) state = "notin";
65
+ else state = "value";
66
+ break;
67
+ case "notin":
68
+ if (isKw(t, "IN")) state = "list-open";
69
+ break;
70
+ case "value":
71
+ if (t.type === "word" || t.type === "string") state = "after";
72
+ break;
73
+ case "list-open":
74
+ if (t.type === "lparen") state = "in-list";
75
+ break;
76
+ case "in-list":
77
+ if (t.type === "rparen") state = "after";
78
+ break; // words / commas stay in-list
79
+ case "after":
80
+ if (isKw(t, "AND") || isKw(t, "OR") || isKw(t, "NOT")) state = "field";
81
+ else if (isKw(t, "ORDER")) state = "order-by";
82
+ break;
83
+ case "order-by": if (isKw(t, "BY")) state = "order-field"; break;
84
+ case "order-field": if (t.type === "word") state = "order-dir"; break;
85
+ case "order-dir": state = "done"; break; // a sort direction ends the query → no more suggestions
86
+ default: break;
87
+ }
88
+ }
89
+ return { state: state, lastField: lastField };
90
+ }
91
+
92
+ function fieldDefs() { return engine.QUERY_FIELDS; }
93
+ function fieldByKey(key) {
94
+ const k = String(key || "").toLowerCase();
95
+ return fieldDefs().find((f) => f.key.toLowerCase() === k) || null;
96
+ }
97
+
98
+ function rank(candidates, prefix, kind, replaceStart, replaceEnd) {
99
+ const p = String(prefix || "").toLowerCase();
100
+ const out = [];
101
+ for (const c of candidates) {
102
+ const label = c.label != null ? c.label : c.insertText.trim();
103
+ const hay = String(c.matchOn != null ? c.matchOn : label).toLowerCase();
104
+ if (p && hay.indexOf(p) < 0) continue;
105
+ out.push({
106
+ insertText: c.insertText, label: label, kind: kind,
107
+ replaceStart: replaceStart, replaceEnd: replaceEnd,
108
+ _exact: p && hay.indexOf(p) === 0 ? 0 : 1
109
+ });
110
+ }
111
+ out.sort((a, b) => (a._exact - b._exact) || a.label.localeCompare(b.label));
112
+ return out.slice(0, MAX).map((s) => { delete s._exact; return s; });
113
+ }
114
+
115
+ function quoteIfNeeded(v) {
116
+ const s = String(v);
117
+ return /[\s(),"]/.test(s) ? '"' + s.replace(/"/g, '\\"') + '"' : s;
118
+ }
119
+
120
+ /** Main entry. */
121
+ function suggest(input, cursor, snapshot) {
122
+ input = String(input == null ? "" : input);
123
+ cursor = typeof cursor === "number" ? cursor : input.length;
124
+ const left = input.slice(0, cursor);
125
+ const toks = leftTokens(left);
126
+
127
+ // The in-progress (partial) token is the last token ending exactly at the
128
+ // cursor; otherwise the cursor sits after whitespace/a delimiter → no prefix.
129
+ let partial = null;
130
+ if (toks.length) {
131
+ const last = toks[toks.length - 1];
132
+ if (last.end === left.length && (last.type === "word" || last.type === "string" || last.partial)) partial = last;
133
+ }
134
+ const preceding = partial ? toks.slice(0, -1) : toks;
135
+ const prefix = partial ? String(partial.value) : "";
136
+ const replaceStart = partial ? partial.start : cursor;
137
+ const replaceEnd = cursor;
138
+
139
+ const ctx = analyze(preceding);
140
+ const field = ctx.lastField ? fieldByKey(ctx.lastField) : null;
141
+
142
+ // FIELD position.
143
+ if (ctx.state === "field" || ctx.state === "order-field") {
144
+ const cands = fieldDefs().map((f) => ({ insertText: f.key + " ", label: f.key, matchOn: f.key + " " + (f.label || "") }));
145
+ if (ctx.state === "field") { cands.push({ insertText: "NOT ", label: "NOT", matchOn: "not" }); cands.push({ insertText: "(", label: "(", matchOn: "(" }); }
146
+ return rank(cands, prefix, "field", replaceStart, replaceEnd);
147
+ }
148
+ // OPERATOR position — only the operators the field actually allows.
149
+ if (ctx.state === "operator") {
150
+ const ops = field ? field.ops : ["eq", "neq", "contains", "in", "not-in", "lt", "gt", "lte", "gte"];
151
+ const cands = ops.map((op) => {
152
+ const sym = OP_SUGGEST[op];
153
+ const insert = opToInsert[sym] != null ? opToInsert[sym] : (sym + " " + (op === "in" || op === "not-in" ? "(" : ""));
154
+ return { insertText: insert, label: sym, matchOn: sym };
155
+ });
156
+ return rank(cands, prefix, "operator", replaceStart, replaceEnd);
157
+ }
158
+ if (ctx.state === "notin") {
159
+ return rank([{ insertText: "IN (", label: "IN", matchOn: "in" }], prefix, "operator", replaceStart, replaceEnd);
160
+ }
161
+ if (ctx.state === "list-open") {
162
+ return rank([{ insertText: "(", label: "(", matchOn: "(" }], prefix, "operator", replaceStart, replaceEnd);
163
+ }
164
+ // VALUE position — field-specific values (enum/ref/multi/link). Free-input
165
+ // fields (text/number/date) offer nothing.
166
+ if (ctx.state === "value" || ctx.state === "in-list") {
167
+ const cands = [];
168
+ if (field && typeof field.values === "function") {
169
+ let vals = [];
170
+ try { vals = field.values(snapshot || {}) || []; } catch (_e) { vals = []; }
171
+ for (const v of vals) cands.push({ insertText: quoteIfNeeded(v) + " ", label: String(v), matchOn: String(v) });
172
+ }
173
+ if (ctx.state === "in-list") cands.push({ insertText: ")", label: ")", matchOn: ")" });
174
+ return rank(cands, prefix, "value", replaceStart, replaceEnd);
175
+ }
176
+ // After a complete clause.
177
+ if (ctx.state === "after") {
178
+ const cands = [
179
+ { insertText: "AND ", label: "AND", matchOn: "and" },
180
+ { insertText: "OR ", label: "OR", matchOn: "or" },
181
+ { insertText: "ORDER BY ", label: "ORDER BY", matchOn: "order by" },
182
+ { insertText: ")", label: ")", matchOn: ")" }
183
+ ];
184
+ return rank(cands, prefix, "keyword", replaceStart, replaceEnd);
185
+ }
186
+ if (ctx.state === "order-by") {
187
+ return rank([{ insertText: "BY ", label: "BY", matchOn: "by" }], prefix, "keyword", replaceStart, replaceEnd);
188
+ }
189
+ if (ctx.state === "order-dir") {
190
+ return rank([{ insertText: "ASC ", label: "ASC", matchOn: "asc" }, { insertText: "DESC ", label: "DESC", matchOn: "desc" }], prefix, "keyword", replaceStart, replaceEnd);
191
+ }
192
+ return [];
193
+ }
194
+
195
+ return { suggest: suggest, _analyze: analyze, _leftTokens: leftTokens };
196
+ }));
@@ -0,0 +1,339 @@
1
+ /**
2
+ * SM-189 — Query engine + field schema (Story S2 of epic SM-187).
3
+ *
4
+ * Pure. No DOM, no I/O. UMD-wrapped; shared by the server (SM-190 MCP
5
+ * query_tickets) and the browser (SM-191 smart-bar) via the
6
+ * frontend/js/query-engine.js symlink. Requires the parser (shared/query.js).
7
+ *
8
+ * The QUERY_FIELDS schema is the SINGLE SOURCE that drives field resolution
9
+ * (here), semantic validation (here), and the autocomplete value-suggestions
10
+ * (SM-216). Each field: { key, label, type, ops, resolve(ticket, ctx),
11
+ * values(snapshot) }. `type` selects the matcher; `ops` is the allow-list of
12
+ * clauseOps the field accepts (validation + autocomplete).
13
+ *
14
+ * Public surface:
15
+ * QUERY_FIELDS — the field definitions (array)
16
+ * fieldsByKey() — lower-cased key → field def
17
+ * buildQueryCtx(snapshot) — resolution indexes (release/step/epic/links)
18
+ * evaluate(ast, ticket, ctx, fields) — per-ticket boolean
19
+ * validateQuery(ast, orderBy, fields) — semantic check → {message,...}|null
20
+ * queryTickets(snapshot, queryOrAst, opts) → { tickets, orderBy?, error? }
21
+ */
22
+ (function (global, factory) {
23
+ if (typeof module === "object" && module.exports) {
24
+ module.exports = factory(require("./query.js"));
25
+ } else {
26
+ const ns = (global.STORYMAP = global.STORYMAP || {});
27
+ ns.queryEngine = factory(ns.query);
28
+ }
29
+ }(typeof window !== "undefined" ? window : globalThis, function (queryParser) {
30
+ "use strict";
31
+
32
+ const parseQuery = queryParser && queryParser.parseQuery;
33
+
34
+ // -- matchers (per field type) ---------------------------------------------
35
+
36
+ const ci = (x) => String(x == null ? "" : x).toLowerCase();
37
+
38
+ function matchScalar(resolved, clause) {
39
+ if (clause.clauseOp === "neq") return !(resolved != null && ci(resolved) === ci(clause.value));
40
+ if (clause.clauseOp === "not-in") return !(resolved != null && clause.values.some((v) => ci(resolved) === ci(v)));
41
+ if (resolved == null) return false;
42
+ switch (clause.clauseOp) {
43
+ case "eq": return ci(resolved) === ci(clause.value);
44
+ case "contains": return ci(resolved).indexOf(ci(clause.value)) >= 0;
45
+ case "in": return clause.values.some((v) => ci(resolved) === ci(v));
46
+ default: return false;
47
+ }
48
+ }
49
+
50
+ // SM-260: ref fields resolve to a SET of acceptable values (name + id, or
51
+ // ticketKey + id for epic). A clause matches if it hits ANY candidate, so
52
+ // `release = r1` (id) and `release = v1.0` (name) both work.
53
+ function matchRef(cands, clause) {
54
+ const arr = (cands || []).filter((x) => x != null).map(String);
55
+ const eqAny = (v) => arr.some((x) => ci(x) === ci(v));
56
+ switch (clause.clauseOp) {
57
+ case "eq": return eqAny(clause.value);
58
+ case "neq": return !eqAny(clause.value);
59
+ case "contains": return arr.some((x) => ci(x).indexOf(ci(clause.value)) >= 0);
60
+ case "in": return (clause.values || []).some(eqAny);
61
+ case "not-in": return !(clause.values || []).some(eqAny);
62
+ default: return false;
63
+ }
64
+ }
65
+
66
+ // membership semantics for array-valued fields (labels, linked keys)
67
+ function matchMulti(resolved, clause) {
68
+ const arr = Array.isArray(resolved) ? resolved : [];
69
+ const has = (v) => arr.some((x) => ci(x) === ci(v));
70
+ switch (clause.clauseOp) {
71
+ case "eq": case "contains": return has(clause.value);
72
+ case "neq": return !has(clause.value);
73
+ case "in": return clause.values.some(has);
74
+ case "not-in": return !clause.values.some(has);
75
+ default: return false;
76
+ }
77
+ }
78
+
79
+ function cmp(a, b, op) {
80
+ switch (op) {
81
+ case "eq": return a === b; case "neq": return a !== b;
82
+ case "lt": return a < b; case "gt": return a > b;
83
+ case "lte": return a <= b; case "gte": return a >= b;
84
+ default: return false;
85
+ }
86
+ }
87
+ function matchNumber(resolved, clause) {
88
+ const a = Number(resolved), b = Number(clause.value);
89
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
90
+ return cmp(a, b, clause.clauseOp);
91
+ }
92
+ function matchDate(resolved, clause) {
93
+ const a = Number(resolved), b = Date.parse(clause.value);
94
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
95
+ return cmp(a, b, clause.clauseOp);
96
+ }
97
+
98
+ const MATCHERS = { enum: matchScalar, ref: matchScalar, text: matchScalar, multi: matchMulti, number: matchNumber, date: matchDate };
99
+
100
+ // -- helpers for values() + resolve ----------------------------------------
101
+
102
+ function uniq(/* ...arrays */) {
103
+ const seen = new Set(), out = [];
104
+ for (let i = 0; i < arguments.length; i++) {
105
+ for (const v of (arguments[i] || [])) {
106
+ if (v == null || v === "") continue;
107
+ const k = ci(v);
108
+ if (!seen.has(k)) { seen.add(k); out.push(v); }
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ const proj = (snap) => (snap && snap.project) || {};
114
+ const liveTickets = (snap) => ((snap && snap.tickets) || []).filter((t) => t && !t.isDeleted);
115
+ function haystack(t) {
116
+ const labels = Array.isArray(t.labels) ? t.labels.join(" ") : "";
117
+ return [t.ticketKey, t.title, t.description, labels].filter((s) => typeof s === "string").join(" ");
118
+ }
119
+
120
+ const SCALAR_OPS = ["eq", "neq", "in", "not-in"];
121
+ const NUM_OPS = ["eq", "neq", "lt", "gt", "lte", "gte"];
122
+ const MULTI_OPS = ["eq", "neq", "contains", "in", "not-in"];
123
+
124
+ // -- the field schema (single source) --------------------------------------
125
+
126
+ const QUERY_FIELDS = [
127
+ { key: "type", label: "Type", type: "enum", ops: SCALAR_OPS,
128
+ resolve: (t) => t.type,
129
+ values: (s) => uniq(proj(s).ticketTypes, liveTickets(s).map((t) => t.type)) },
130
+ { key: "status", label: "Status", type: "enum", ops: SCALAR_OPS,
131
+ resolve: (t) => t.status,
132
+ values: (s) => uniq(((proj(s).workflow || {}).statuses || []).map((x) => x.id || x), liveTickets(s).map((t) => t.status)) },
133
+ { key: "key", label: "Ticket key", type: "text", ops: ["eq", "neq", "contains", "in", "not-in"],
134
+ resolve: (t) => t.ticketKey,
135
+ values: (s) => liveTickets(s).map((t) => t.ticketKey) },
136
+ { key: "title", label: "Title", type: "text", ops: ["contains", "eq", "neq"],
137
+ resolve: (t) => t.title, values: () => [] },
138
+ { key: "description", label: "Description", type: "text", ops: ["contains", "eq", "neq"],
139
+ resolve: (t) => t.description, values: () => [] },
140
+ { key: "text", label: "Full text", type: "text", ops: ["contains"],
141
+ resolve: (t) => haystack(t), values: () => [] },
142
+ // SM-260: ref fields match by NAME *or* ID (resolve = display name for sort
143
+ // + autocomplete; matchset = every acceptable filter value; known = all
144
+ // valid values in the snapshot, for the strictRefs unknown-value check).
145
+ { key: "release", label: "Release", type: "ref", ops: SCALAR_OPS,
146
+ resolve: (t, ctx) => { const r = ctx.releaseById.get(t.position && t.position.releaseId); return r ? r.name : null; },
147
+ matchset: (t, ctx) => { const r = ctx.releaseById.get(t.position && t.position.releaseId); return r ? [r.name, r.id] : []; },
148
+ known: (s) => [].concat.apply([], ((s && s.releases) || []).filter((r) => !r.isDeleted).map((r) => [r.name, r.id])),
149
+ values: (s) => ((s && s.releases) || []).filter((r) => !r.isDeleted).map((r) => r.name) },
150
+ { key: "processStep", label: "Process step", type: "ref", ops: SCALAR_OPS,
151
+ resolve: (t, ctx) => { const p = ctx.stepById.get(t.position && t.position.processStepId); return p ? p.name : null; },
152
+ matchset: (t, ctx) => { const p = ctx.stepById.get(t.position && t.position.processStepId); return p ? [p.name, p.id] : []; },
153
+ known: (s) => [].concat.apply([], ((s && s.processSteps) || []).filter((p) => !p.isDeleted).map((p) => [p.name, p.id])),
154
+ values: (s) => ((s && s.processSteps) || []).filter((p) => !p.isDeleted).map((p) => p.name) },
155
+ { key: "epic", label: "Epic", type: "ref", ops: SCALAR_OPS,
156
+ resolve: (t, ctx) => ctx.epicKeyByStory.get(t.id) || null,
157
+ matchset: (t, ctx) => [ctx.epicKeyByStory.get(t.id), ctx.epicIdByStory.get(t.id)].filter((x) => x != null),
158
+ known: (s) => [].concat.apply([], liveTickets(s).filter((t) => t.type === "epic").map((t) => [t.ticketKey, t.id])),
159
+ values: (s) => liveTickets(s).filter((t) => t.type === "epic").map((t) => t.ticketKey) },
160
+ { key: "label", label: "Label", type: "multi", ops: MULTI_OPS,
161
+ resolve: (t) => t.labels || [],
162
+ values: (s) => uniq(proj(s).labels, [].concat.apply([], liveTickets(s).map((t) => t.labels || []))) },
163
+ { key: "linkedTo", label: "Links to (key)", type: "multi", ops: MULTI_OPS,
164
+ resolve: (t, ctx) => ctx.linkedTo.get(t.id) || [],
165
+ values: (s) => liveTickets(s).map((t) => t.ticketKey) },
166
+ { key: "linkedFrom", label: "Linked from (key)", type: "multi", ops: MULTI_OPS,
167
+ resolve: (t, ctx) => ctx.linkedFrom.get(t.id) || [],
168
+ values: (s) => liveTickets(s).map((t) => t.ticketKey) },
169
+ { key: "acCount", label: "# acceptance criteria", type: "number", ops: NUM_OPS,
170
+ resolve: (t) => (t.acceptanceCriteria || []).length, values: () => [] },
171
+ { key: "created", label: "Created", type: "date", ops: NUM_OPS,
172
+ resolve: (t) => t.createdAt, values: () => [] },
173
+ { key: "updated", label: "Updated", type: "date", ops: NUM_OPS,
174
+ resolve: (t) => t.updatedAt, values: () => [] }
175
+ ];
176
+
177
+ function fieldsByKey() {
178
+ const m = {};
179
+ for (const f of QUERY_FIELDS) m[f.key.toLowerCase()] = f;
180
+ return m;
181
+ }
182
+
183
+ // -- resolution context ----------------------------------------------------
184
+
185
+ function buildQueryCtx(snapshot) {
186
+ const tickets = (snapshot && snapshot.tickets) || [];
187
+ const keyById = new Map();
188
+ for (const t of tickets) keyById.set(t.id, t.ticketKey);
189
+ const releaseById = new Map(((snapshot && snapshot.releases) || []).map((r) => [r.id, r]));
190
+ const stepById = new Map(((snapshot && snapshot.processSteps) || []).map((p) => [p.id, p]));
191
+ const epicKeyByStory = new Map();
192
+ const epicIdByStory = new Map();
193
+ const linkedTo = new Map();
194
+ const linkedFrom = new Map();
195
+ for (const t of tickets) {
196
+ if (t.isDeleted) continue;
197
+ for (const l of (t.links || [])) {
198
+ if (!l || typeof l.targetTicketId !== "string") continue;
199
+ const lt = l.linkTypeId || l.type;
200
+ const tgtKey = keyById.get(l.targetTicketId);
201
+ if (tgtKey) {
202
+ if (!linkedTo.has(t.id)) linkedTo.set(t.id, []);
203
+ linkedTo.get(t.id).push(tgtKey);
204
+ if (!linkedFrom.has(l.targetTicketId)) linkedFrom.set(l.targetTicketId, []);
205
+ linkedFrom.get(l.targetTicketId).push(t.ticketKey);
206
+ if (lt === "contains" && t.type === "epic") { epicKeyByStory.set(l.targetTicketId, t.ticketKey); epicIdByStory.set(l.targetTicketId, t.id); }
207
+ }
208
+ }
209
+ }
210
+ return { snapshot: snapshot, keyById, releaseById, stepById, epicKeyByStory, epicIdByStory, linkedTo, linkedFrom };
211
+ }
212
+
213
+ // -- evaluate + validate ---------------------------------------------------
214
+
215
+ function evaluate(node, ticket, ctx, fields) {
216
+ if (!node) return true; // null AST = match all
217
+ if (node.op === "AND") return (node.children || []).every((c) => evaluate(c, ticket, ctx, fields));
218
+ if (node.op === "OR") { const cs = node.children || []; return cs.length === 0 || cs.some((c) => evaluate(c, ticket, ctx, fields)); }
219
+ if (node.op === "NOT") return !evaluate(node.child, ticket, ctx, fields);
220
+ if (node.op === "CLAUSE") {
221
+ const f = fields[String(node.field).toLowerCase()];
222
+ if (!f) return false; // unknown field (validation catches first)
223
+ if (f.type === "ref" && typeof f.matchset === "function") return matchRef(f.matchset(ticket, ctx), node);
224
+ const matcher = MATCHERS[f.type] || matchScalar;
225
+ return matcher(f.resolve(ticket, ctx), node);
226
+ }
227
+ return true;
228
+ }
229
+
230
+ /** Semantic check: unknown field, operator not allowed on the field, unknown
231
+ * ORDER BY field. With strictRefs (SM-260) a positive ref clause (eq/in)
232
+ * whose value matches no known release/step/epic is reported as an error
233
+ * instead of silently returning 0 rows. Returns the first
234
+ * { message, field?, position } or null. */
235
+ function validateQuery(node, orderBy, fields, snapshot, strictRefs) {
236
+ let err = null;
237
+ (function walk(n) {
238
+ if (err || !n) return;
239
+ if (n.op === "AND" || n.op === "OR") { (n.children || []).forEach(walk); return; }
240
+ if (n.op === "NOT") { walk(n.child); return; }
241
+ if (n.op === "CLAUSE") {
242
+ const f = fields[String(n.field).toLowerCase()];
243
+ if (!f) { err = { message: "unknown field '" + n.field + "'", field: n.field, position: 0 }; return; }
244
+ if (f.ops.indexOf(n.clauseOp) < 0) {
245
+ err = { message: "operator '" + n.clauseOp + "' is not allowed on field '" + n.field + "' (allowed: " + f.ops.join(", ") + ")", field: n.field, position: 0 };
246
+ return;
247
+ }
248
+ if (strictRefs && f.type === "ref" && typeof f.known === "function"
249
+ && (n.clauseOp === "eq" || n.clauseOp === "in")) {
250
+ const knownSet = new Set((f.known(snapshot) || []).filter((x) => x != null).map((x) => ci(x)));
251
+ const vals = n.clauseOp === "in" ? (n.values || []) : [n.value];
252
+ for (const v of vals) {
253
+ if (!knownSet.has(ci(v))) {
254
+ err = { message: "no " + String(f.label || f.key).toLowerCase() + " matches '" + v + "'", field: n.field, position: 0 };
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ })(node);
261
+ if (!err && orderBy && !fields[String(orderBy.field).toLowerCase()]) {
262
+ err = { message: "unknown ORDER BY field '" + orderBy.field + "'", field: orderBy.field, position: 0 };
263
+ }
264
+ return err;
265
+ }
266
+
267
+ // -- ordering --------------------------------------------------------------
268
+
269
+ function defaultCompare(a, b) {
270
+ const sa = (a.position && a.position.sortOrder) || 0;
271
+ const sb = (b.position && b.position.sortOrder) || 0;
272
+ if (sa !== sb) return sa - sb;
273
+ return String(a.ticketKey || a.id).localeCompare(String(b.ticketKey || b.id));
274
+ }
275
+ function sortTickets(list, orderBy, fields, ctx) {
276
+ const out = list.slice();
277
+ if (!orderBy) { out.sort(defaultCompare); return out; }
278
+ const f = fields[String(orderBy.field).toLowerCase()];
279
+ const dir = orderBy.dir === "desc" ? -1 : 1;
280
+ const numeric = f && (f.type === "number" || f.type === "date");
281
+ out.sort((a, b) => {
282
+ let va = f ? f.resolve(a, ctx) : null, vb = f ? f.resolve(b, ctx) : null;
283
+ let c;
284
+ if (numeric) { c = (Number(va) || 0) - (Number(vb) || 0); }
285
+ else { c = ci(Array.isArray(va) ? va.join(",") : va).localeCompare(ci(Array.isArray(vb) ? vb.join(",") : vb)); }
286
+ if (c !== 0) return dir * c;
287
+ return defaultCompare(a, b); // stable tiebreak
288
+ });
289
+ return out;
290
+ }
291
+
292
+ // -- top-level -------------------------------------------------------------
293
+
294
+ /**
295
+ * Run a query (string or pre-built AST) over a snapshot.
296
+ * Returns { tickets: ordered[], orderBy?, error? }. A parse error (string
297
+ * input) or a semantic error (unknown field / bad operator) yields an empty
298
+ * list + the structured error. opts.orderBy overrides any ORDER BY in the
299
+ * string. opts.includeDeleted (default false) keeps soft-deleted tickets out.
300
+ */
301
+ function queryTickets(snapshot, queryOrAst, opts) {
302
+ opts = opts || {};
303
+ let ast = null, orderBy = opts.orderBy || null;
304
+ if (typeof queryOrAst === "string") {
305
+ if (!parseQuery) return { tickets: [], error: { message: "parser unavailable", position: 0 } };
306
+ const parsed = parseQuery(queryOrAst);
307
+ if (parsed.error) return { tickets: [], error: parsed.error };
308
+ ast = parsed.ast;
309
+ if (!orderBy && parsed.orderBy) orderBy = parsed.orderBy;
310
+ } else {
311
+ ast = queryOrAst || null;
312
+ }
313
+ // validate + evaluate are recursive; a pathologically deep (hand-built) AST
314
+ // could overflow the stack. Honour the no-throw contract on this path too
315
+ // (the string path is already guarded inside the parser).
316
+ try {
317
+ const fields = fieldsByKey();
318
+ const vErr = validateQuery(ast, orderBy, fields, snapshot, !!opts.strictRefs);
319
+ if (vErr) return { tickets: [], error: vErr };
320
+ const ctx = buildQueryCtx(snapshot);
321
+ const base = ((snapshot && snapshot.tickets) || [])
322
+ .filter((t) => t && (opts.includeDeleted || !t.isDeleted));
323
+ const matched = base.filter((t) => evaluate(ast, t, ctx, fields));
324
+ const ordered = sortTickets(matched, orderBy, fields, ctx);
325
+ return { tickets: ordered, orderBy: orderBy || null, error: null };
326
+ } catch (e) {
327
+ return { tickets: [], error: { message: "query too complex to evaluate", position: 0 } };
328
+ }
329
+ }
330
+
331
+ return {
332
+ QUERY_FIELDS: QUERY_FIELDS,
333
+ fieldsByKey: fieldsByKey,
334
+ buildQueryCtx: buildQueryCtx,
335
+ evaluate: evaluate,
336
+ validateQuery: validateQuery,
337
+ queryTickets: queryTickets
338
+ };
339
+ }));