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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
package/shared/query.js
ADDED
|
@@ -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
|
+
}));
|