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,280 @@
1
+ /**
2
+ * SM-188 — JQL-angelehnter Query-Parser (Story S1 of epic SM-187).
3
+ *
4
+ * Pure. No DOM, no I/O. UMD-wrapped so the SAME source is `require()`d on the
5
+ * server (SM-190 MCP `query_tickets`) AND `<script src>`'d in the browser
6
+ * (SM-191 smart-bar) — via the frontend/js/query.js symlink.
7
+ *
8
+ * Text query → Boolean-Tree AST (the SAME node shapes filter.js evaluates,
9
+ * SM-82) + an optional ORDER BY. The engine (SM-189) resolves fields + runs it;
10
+ * the autocomplete (SM-216) reuses `tokenize` + the keyword/operator catalogue.
11
+ *
12
+ * Grammar (recursive descent, AND binds tighter than OR):
13
+ * query := orExpr (ORDER BY orderClause)? EOF
14
+ * orExpr := andExpr (OR andExpr)*
15
+ * andExpr := unary (AND unary)*
16
+ * unary := NOT unary | primary
17
+ * primary := '(' orExpr ')' | clause
18
+ * clause := FIELD operator value
19
+ * operator := '=' | '!=' | '~' | '<' | '>' | '<=' | '>=' | IN | NOT IN
20
+ * value := scalar | '(' scalar (',' scalar)* ')' // list for IN/NOT IN
21
+ * scalar := QUOTED | WORD
22
+ * orderClause := FIELD (ASC | DESC)?
23
+ *
24
+ * AST node shapes (plain objects, == filter.js):
25
+ * { op:"AND", children:Node[] } | { op:"OR", children:Node[] }
26
+ * | { op:"NOT", child:Node }
27
+ * | { op:"CLAUSE", field, clauseOp, value? , values? }
28
+ * clauseOp ∈ eq | neq | contains | in | not-in | lt | gt | lte | gte
29
+ *
30
+ * `parseQuery(text)` NEVER throws — it returns { ast, orderBy, error } so the
31
+ * UI can render an inline error at `error.position`. An empty / whitespace-only
32
+ * query yields { ast:null, orderBy:null, error:null } (ast null = match all).
33
+ */
34
+ (function (global, factory) {
35
+ if (typeof module === "object" && module.exports) module.exports = factory();
36
+ else {
37
+ const ns = (global.STORYMAP = global.STORYMAP || {});
38
+ ns.query = factory();
39
+ }
40
+ }(typeof window !== "undefined" ? window : globalThis, function () {
41
+ "use strict";
42
+
43
+ // Reserved keywords (matched case-insensitively on word tokens). A field name
44
+ // may not be one of these; in value position a bareword keyword is still a
45
+ // value (scalar reads exactly one token).
46
+ const KEYWORDS = Object.freeze(["AND", "OR", "NOT", "IN", "ORDER", "BY", "ASC", "DESC"]);
47
+ // Symbolic operators → clauseOp. Multi-char first so "<=" beats "<".
48
+ const OPERATORS = Object.freeze(["!=", "<=", ">=", "=", "~", "<", ">"]);
49
+ const OP_TO_CLAUSE = Object.freeze({
50
+ "=": "eq", "!=": "neq", "~": "contains", "<": "lt", ">": "gt", "<=": "lte", ">=": "gte"
51
+ });
52
+
53
+ const WORD_RE = /[A-Za-z0-9_.\-]/;
54
+
55
+ function isKeyword(value, kw) {
56
+ return typeof value === "string" && value.toUpperCase() === kw;
57
+ }
58
+
59
+ function mkError(message, position, expected) {
60
+ const e = new Error(message);
61
+ e.isParseError = true;
62
+ e.position = position;
63
+ if (expected) e.expected = expected;
64
+ return e;
65
+ }
66
+
67
+ /**
68
+ * Tokenize into { type, value, start, end } tokens, terminated by an `eof`
69
+ * token. Throws a parse error (caught by parseQuery) on an unterminated
70
+ * string or an unexpected character. Exposed for the autocomplete engine.
71
+ * Types: word | string | op | lparen | rparen | comma | eof.
72
+ */
73
+ function tokenize(input) {
74
+ const s = String(input == null ? "" : input);
75
+ const tokens = [];
76
+ let i = 0;
77
+ while (i < s.length) {
78
+ const c = s[i];
79
+ if (/\s/.test(c)) { i++; continue; }
80
+ if (c === "(") { tokens.push({ type: "lparen", value: "(", start: i, end: i + 1 }); i++; continue; }
81
+ if (c === ")") { tokens.push({ type: "rparen", value: ")", start: i, end: i + 1 }); i++; continue; }
82
+ if (c === ",") { tokens.push({ type: "comma", value: ",", start: i, end: i + 1 }); i++; continue; }
83
+ if (c === '"' || c === "'") {
84
+ const quote = c;
85
+ let j = i + 1, val = "";
86
+ while (j < s.length && s[j] !== quote) {
87
+ if (s[j] === "\\" && j + 1 < s.length) { val += s[j + 1]; j += 2; }
88
+ else { val += s[j]; j++; }
89
+ }
90
+ if (j >= s.length) throw mkError("unterminated string literal", i);
91
+ tokens.push({ type: "string", value: val, start: i, end: j + 1 });
92
+ i = j + 1;
93
+ continue;
94
+ }
95
+ let matchedOp = null;
96
+ for (let k = 0; k < OPERATORS.length; k++) {
97
+ const op = OPERATORS[k];
98
+ if (s.substr(i, op.length) === op) { matchedOp = op; break; }
99
+ }
100
+ if (matchedOp) { tokens.push({ type: "op", value: matchedOp, start: i, end: i + matchedOp.length }); i += matchedOp.length; continue; }
101
+ if (WORD_RE.test(c)) {
102
+ let j = i;
103
+ while (j < s.length && WORD_RE.test(s[j])) j++;
104
+ tokens.push({ type: "word", value: s.slice(i, j), start: i, end: j });
105
+ i = j;
106
+ continue;
107
+ }
108
+ throw mkError("unexpected character '" + c + "'", i);
109
+ }
110
+ tokens.push({ type: "eof", value: "", start: s.length, end: s.length });
111
+ return tokens;
112
+ }
113
+
114
+ const OPERATOR_HINT = ["=", "!=", "~", "IN", "NOT IN", "<", ">", "<=", ">="];
115
+
116
+ function parseQuery(input) {
117
+ let tokens;
118
+ try { tokens = tokenize(input); }
119
+ catch (e) { return { ast: null, orderBy: null, error: errOf(e) }; }
120
+
121
+ // Empty / whitespace-only → match-all, no error.
122
+ if (tokens.length === 1 && tokens[0].type === "eof") {
123
+ return { ast: null, orderBy: null, error: null };
124
+ }
125
+
126
+ let pos = 0;
127
+ const peek = () => tokens[pos];
128
+ const next = () => tokens[pos++];
129
+ const atEof = () => peek().type === "eof";
130
+ const isWordKw = (kw) => peek().type === "word" && isKeyword(peek().value, kw);
131
+
132
+ function expect(type, label) {
133
+ const t = peek();
134
+ if (t.type !== type) throw mkError("expected " + (label || type) + ", got '" + tokenLabel(t) + "'", t.start, label ? [label] : undefined);
135
+ return next();
136
+ }
137
+
138
+ function parseOr() {
139
+ const children = [parseAnd()];
140
+ while (isWordKw("OR")) { next(); children.push(parseAnd()); }
141
+ return children.length === 1 ? children[0] : { op: "OR", children: children };
142
+ }
143
+ function parseAnd() {
144
+ const children = [parseUnary()];
145
+ while (isWordKw("AND")) { next(); children.push(parseUnary()); }
146
+ return children.length === 1 ? children[0] : { op: "AND", children: children };
147
+ }
148
+ function parseUnary() {
149
+ if (isWordKw("NOT")) { next(); return { op: "NOT", child: parseUnary() }; }
150
+ return parsePrimary();
151
+ }
152
+ function parsePrimary() {
153
+ if (peek().type === "lparen") {
154
+ next();
155
+ const inner = parseOr();
156
+ expect("rparen", ")");
157
+ return inner;
158
+ }
159
+ return parseClause();
160
+ }
161
+ function parseClause() {
162
+ const ft = peek();
163
+ if (ft.type !== "word" || KEYWORDS.some((k) => isKeyword(ft.value, k))) {
164
+ throw mkError("expected a field name, got '" + tokenLabel(ft) + "'", ft.start, ["<field>"]);
165
+ }
166
+ const field = next().value;
167
+
168
+ // Operator.
169
+ let clauseOp, isList = false;
170
+ const ot = peek();
171
+ if (ot.type === "op") { clauseOp = OP_TO_CLAUSE[next().value]; }
172
+ else if (isWordKw("IN")) { next(); clauseOp = "in"; isList = true; }
173
+ else if (isWordKw("NOT")) {
174
+ next();
175
+ if (!isWordKw("IN")) throw mkError("expected IN after NOT", peek().start, ["IN"]);
176
+ next(); clauseOp = "not-in"; isList = true;
177
+ } else {
178
+ throw mkError("expected an operator after field '" + field + "'", ot.start, OPERATOR_HINT);
179
+ }
180
+
181
+ // Value.
182
+ if (isList) {
183
+ expect("lparen", "(");
184
+ const values = [parseScalar()];
185
+ while (peek().type === "comma") { next(); values.push(parseScalar()); }
186
+ expect("rparen", ")");
187
+ return { op: "CLAUSE", field: field, clauseOp: clauseOp, values: values };
188
+ }
189
+ return { op: "CLAUSE", field: field, clauseOp: clauseOp, value: parseScalar() };
190
+ }
191
+ function parseScalar() {
192
+ const t = peek();
193
+ if (t.type === "string") return next().value;
194
+ if (t.type === "word") return next().value;
195
+ throw mkError("expected a value, got '" + tokenLabel(t) + "'", t.start, ["<value>"]);
196
+ }
197
+ function parseOrderBy() {
198
+ // caller has confirmed the current token is the ORDER keyword
199
+ next(); // ORDER
200
+ if (!isWordKw("BY")) throw mkError("expected BY after ORDER", peek().start, ["BY"]);
201
+ next(); // BY
202
+ const ft = peek();
203
+ if (ft.type !== "word" || KEYWORDS.some((k) => isKeyword(ft.value, k))) {
204
+ throw mkError("expected a field name after ORDER BY", ft.start, ["<field>"]);
205
+ }
206
+ const field = next().value;
207
+ let dir = "asc";
208
+ if (isWordKw("ASC")) { next(); dir = "asc"; }
209
+ else if (isWordKw("DESC")) { next(); dir = "desc"; }
210
+ return { field: field, dir: dir };
211
+ }
212
+
213
+ try {
214
+ const ast = parseOr();
215
+ let orderBy = null;
216
+ if (isWordKw("ORDER")) orderBy = parseOrderBy();
217
+ if (!atEof()) {
218
+ const t = peek();
219
+ throw mkError("unexpected '" + tokenLabel(t) + "'", t.start);
220
+ }
221
+ return { ast: ast, orderBy: orderBy, error: null };
222
+ } catch (e) {
223
+ // parseQuery NEVER throws (the smart-bar parses on every keystroke). A
224
+ // structured parse error is returned as-is; anything else (e.g. a
225
+ // RangeError from pathologically deep paren nesting) collapses to a generic
226
+ // positioned error so the contract holds honestly.
227
+ if (e.isParseError) return { ast: null, orderBy: null, error: errOf(e) };
228
+ return { ast: null, orderBy: null, error: { message: "query too complex to parse", position: 0 } };
229
+ }
230
+ }
231
+
232
+ function tokenLabel(t) {
233
+ if (!t) return "";
234
+ return t.type === "eof" ? "end of query" : String(t.value);
235
+ }
236
+ function errOf(e) {
237
+ const out = { message: e.message, position: typeof e.position === "number" ? e.position : 0 };
238
+ if (e.expected) out.expected = e.expected;
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Heuristic for the unified smart-bar (SM-191): does the input look like a
244
+ * structured query rather than free-text search? A query is a `field operator …`
245
+ * clause. When `fieldKeys` is supplied (the UI always does — from QUERY_FIELDS),
246
+ * the heuristic is precise: the input must contain a KNOWN field immediately
247
+ * followed by an operator (or `IN (`). That keeps plain searches like
248
+ * "find in files", "price < 100", "Search and replace" as simple search (their
249
+ * leading word isn't a real field). Without fieldKeys it falls back to a looser
250
+ * "any word followed by an operator" rule (used by unit tests / older callers).
251
+ */
252
+ function looksLikeQuery(input, fieldKeys) {
253
+ const s = String(input == null ? "" : input).trim();
254
+ if (!s) return false;
255
+ const OP = "(?:!=|<=|>=|[=~<>])";
256
+ const IN = "(?:NOT\\s+)?IN\\s*\\("; // IN must be followed by '(' to count
257
+ if (Array.isArray(fieldKeys) && fieldKeys.length) {
258
+ const keys = fieldKeys.map((k) => String(k).toLowerCase());
259
+ // A clause start: beginning, or just after AND / OR / NOT / '('.
260
+ const re = new RegExp("(?:^|[\\s(]|\\bAND\\b|\\bOR\\b|\\bNOT\\b)\\s*([A-Za-z0-9_.\\-]+)\\s*(?:" + OP + "|" + IN + ")", "ig");
261
+ let m;
262
+ while ((m = re.exec(s))) { if (keys.indexOf(m[1].toLowerCase()) >= 0) return true; }
263
+ return false;
264
+ }
265
+ // Loose fallback: any word directly followed by an operator, or `… IN (`.
266
+ if (new RegExp("[A-Za-z0-9_.\\-]\\s*" + OP).test(s)) return true;
267
+ if (new RegExp("[A-Za-z0-9_.\\-]\\s+" + IN, "i").test(s)) return true;
268
+ return false;
269
+ }
270
+
271
+ return {
272
+ KEYWORDS: KEYWORDS,
273
+ OPERATORS: OPERATORS,
274
+ OP_TO_CLAUSE: OP_TO_CLAUSE,
275
+ OPERATOR_HINT: OPERATOR_HINT,
276
+ tokenize: tokenize,
277
+ parseQuery: parseQuery,
278
+ looksLikeQuery: looksLikeQuery
279
+ };
280
+ }));