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,825 @@
1
+ /**
2
+ * storymap — graph algorithms (single source of truth)
3
+ *
4
+ * UMD single source of truth (shared/). Consumed two ways from ONE file:
5
+ * - Node: require() — server/core/graph.js is a 1-line shim, tests require directly.
6
+ * - Browser: <script src> attaches to window.STORYMAP.coreGraph.
7
+ * Pure functions only — no I/O, no DOM. Edit ONLY this file; the server shim
8
+ * and the frontend symlink both resolve here, so drift is impossible.
9
+ */
10
+ (function (root, factory) {
11
+ if (typeof module === "object" && module.exports) module.exports = factory();
12
+ else (root.STORYMAP = root.STORYMAP || {}).coreGraph = factory();
13
+ }(typeof self !== "undefined" ? self : this, function () {
14
+ "use strict";
15
+
16
+ /**
17
+ * storymap — graph algorithms over the ticket link graph.
18
+ *
19
+ * Pure functions only (no I/O). Mirrored 1:1 into
20
+ * `frontend/js/core/graph.js` via UMD wrapper; the mirror-check at the
21
+ * bottom of `tests/test-core-graph.js` enforces parity.
22
+ *
23
+ * Snapshot shape (see server/core.js): every ticket has
24
+ * ticket.links: [{ id, linkTypeId, targetTicketId, label? }]
25
+ * — all forward edges; backward links are discovered by inverted scan.
26
+ * Project carries `linkTypes: [{id, semantic, ...}]`; semantic ∈
27
+ * "precedence" | "blocking" | "sequence" | "containment" | "validation" | "freeform".
28
+ *
29
+ * Workflow statuses live on `project.workflow.statuses` as
30
+ * [{ id, name, category: "todo"|"doing"|"blocked"|"done" }]
31
+ * and we treat a ticket as "done" iff its status's category === "done".
32
+ */
33
+
34
+ // Link-semantics that model "X must be done before Y". Used by both
35
+ // criticalPath and impactSetForTicket to filter the edge set down to the
36
+ // dependency subgraph.
37
+ const DEPENDENCY_SEMANTICS = ["blocking", "precedence"];
38
+
39
+ // Test-typed tickets recognised by traceabilityFor. Defensive trio so any
40
+ // of the conventional names (Jira-style 'test', or split lifecycle of
41
+ // definition vs. execution) is picked up.
42
+ const TEST_TYPES = ["test", "test-definition", "test-execution"];
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Internals
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Build a fast lookup of linkTypeId → semantic from project.linkTypes.
50
+ * Missing/unknown linkTypeId → undefined; callers treat that as "skip".
51
+ */
52
+ function buildLinkTypeSemanticMap(project) {
53
+ const out = new Map();
54
+ if (project && Array.isArray(project.linkTypes)) {
55
+ for (const lt of project.linkTypes) {
56
+ if (lt && typeof lt.id === "string") out.set(lt.id, lt.semantic);
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Build a status-id → category lookup from project.workflow.statuses.
64
+ * Unknown / non-object statuses → "doing" (conservative: not-done).
65
+ */
66
+ function buildStatusCategoryMap(project) {
67
+ const out = new Map();
68
+ const wf = project && project.workflow;
69
+ if (wf && Array.isArray(wf.statuses)) {
70
+ for (const s of wf.statuses) {
71
+ if (s && typeof s === "object" && typeof s.id === "string") {
72
+ out.set(s.id, s.category || "doing");
73
+ } else if (typeof s === "string") {
74
+ out.set(s, "doing");
75
+ }
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function isDoneTicket(ticket, statusCategoryMap) {
82
+ if (!ticket) return false;
83
+ const cat = statusCategoryMap.get(ticket.status);
84
+ return cat === "done";
85
+ }
86
+
87
+ // SM-242: a ticket is TERMINAL when done OR cancelled — i.e. it no longer
88
+ // blocks downstream work (cancelled = the prerequisite was deliberately dropped,
89
+ // so dependents shouldn't wait on it forever).
90
+ function isTerminalTicket(ticket, statusCategoryMap) {
91
+ if (!ticket) return false;
92
+ const cat = statusCategoryMap.get(ticket.status);
93
+ return cat === "done" || cat === "cancelled";
94
+ }
95
+
96
+ /**
97
+ * Build the dependency-edge adjacency: source ticket id → set of target
98
+ * ticket ids reachable via a single blocking/precedence edge.
99
+ *
100
+ * Only tickets present in `eligibleIds` (a Set) are kept; edges that
101
+ * leave the eligible subgraph are dropped silently. `eligibleIds=null`
102
+ * means "no filter" — every ticket counts.
103
+ */
104
+ function buildDependencyAdjacency(snapshot, semanticMap, eligibleIds) {
105
+ const adj = new Map();
106
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return adj;
107
+ for (const t of snapshot.tickets) {
108
+ if (!t || typeof t.id !== "string") continue;
109
+ if (eligibleIds && !eligibleIds.has(t.id)) continue;
110
+ if (!Array.isArray(t.links)) continue;
111
+ for (const l of t.links) {
112
+ if (!l || typeof l.targetTicketId !== "string") continue;
113
+ if (eligibleIds && !eligibleIds.has(l.targetTicketId)) continue;
114
+ const sem = semanticMap.get(l.linkTypeId);
115
+ if (DEPENDENCY_SEMANTICS.indexOf(sem) < 0) continue;
116
+ if (!adj.has(t.id)) adj.set(t.id, new Set());
117
+ adj.get(t.id).add(l.targetTicketId);
118
+ }
119
+ }
120
+ return adj;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // criticalPath
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Find the longest chain of unresolved tickets connected by
129
+ * blocking/precedence edges.
130
+ *
131
+ * Edge convention: A → B means "A must be done before B" (predecessor-of
132
+ * and blocks both point in that direction).
133
+ *
134
+ * @param {Snapshot} snapshot
135
+ * @param {Object} [opts]
136
+ * @param {string} [opts.filterByRelease] restrict to tickets whose
137
+ * `position.releaseId === opts.filterByRelease`.
138
+ * @returns {{ path: string[], length: number }} `path` is the longest
139
+ * chain in topological order (predecessor first); `length` is its size.
140
+ * Empty graph or no edges → `{ path: [], length: 0 }`.
141
+ */
142
+ function criticalPath(snapshot, opts) {
143
+ opts = opts || {};
144
+ if (!snapshot || !Array.isArray(snapshot.tickets) || snapshot.tickets.length === 0) {
145
+ return { path: [], length: 0 };
146
+ }
147
+ const project = snapshot.project || {};
148
+ const semanticMap = buildLinkTypeSemanticMap(project);
149
+ const statusCatMap = buildStatusCategoryMap(project);
150
+
151
+ // Eligible set = not-terminal tickets, optionally release-filtered.
152
+ // SM-242: cancelled is terminal (deliberately dropped) — off the critical path.
153
+ const eligibleIds = new Set();
154
+ for (const t of snapshot.tickets) {
155
+ if (!t || typeof t.id !== "string") continue;
156
+ if (isTerminalTicket(t, statusCatMap)) continue;
157
+ if (opts.filterByRelease) {
158
+ const rel = (t.position && t.position.releaseId) || null;
159
+ if (rel !== opts.filterByRelease) continue;
160
+ }
161
+ eligibleIds.add(t.id);
162
+ }
163
+ if (eligibleIds.size === 0) return { path: [], length: 0 };
164
+
165
+ const adj = buildDependencyAdjacency(snapshot, semanticMap, eligibleIds);
166
+
167
+ // Compute in-degree for Kahn's topo-sort, scoped to eligible nodes.
168
+ const inDeg = new Map();
169
+ for (const id of eligibleIds) inDeg.set(id, 0);
170
+ for (const [src, targets] of adj) {
171
+ for (const tgt of targets) {
172
+ inDeg.set(tgt, (inDeg.get(tgt) || 0) + 1);
173
+ }
174
+ }
175
+
176
+ // Kahn's algorithm: seed with in-degree 0 nodes.
177
+ const queue = [];
178
+ for (const [id, d] of inDeg) {
179
+ if (d === 0) queue.push(id);
180
+ }
181
+ const topo = [];
182
+ // Use index pointer instead of shift() for O(n) instead of O(n^2).
183
+ let qIdx = 0;
184
+ while (qIdx < queue.length) {
185
+ const cur = queue[qIdx++];
186
+ topo.push(cur);
187
+ const ts = adj.get(cur);
188
+ if (!ts) continue;
189
+ for (const tgt of ts) {
190
+ const d = (inDeg.get(tgt) || 0) - 1;
191
+ inDeg.set(tgt, d);
192
+ if (d === 0) queue.push(tgt);
193
+ }
194
+ }
195
+ // Defensive: cycle leftover (shouldn't happen because cycle-checked
196
+ // semantics block at write-time, but if a snapshot was force-loaded
197
+ // with a cycle, skip the leftover nodes rather than throwing).
198
+ if (topo.length < eligibleIds.size) {
199
+ for (const id of eligibleIds) {
200
+ if (!topo.includes(id)) topo.push(id);
201
+ }
202
+ }
203
+
204
+ // DP: longest path ending at each node. We track ALL predecessors that
205
+ // produce the maximum length so we can later enumerate every longest
206
+ // path (not just one). Diamond A→B, A→C, B→D, C→D has TWO longest
207
+ // chains (A→B→D and A→C→D) and the user expects both to be highlighted.
208
+ const dist = new Map(); // id → length (node count) of longest path ending here
209
+ const preds = new Map(); // id → Set<predecessorId> contributing the max
210
+ let bestLen = 0;
211
+ for (const id of topo) {
212
+ if (!dist.has(id)) { dist.set(id, 1); preds.set(id, new Set()); }
213
+ const myLen = dist.get(id);
214
+ const ts = adj.get(id);
215
+ if (ts) {
216
+ for (const tgt of ts) {
217
+ const candidate = myLen + 1;
218
+ const cur = dist.get(tgt) || 0;
219
+ if (candidate > cur) {
220
+ dist.set(tgt, candidate);
221
+ preds.set(tgt, new Set([id]));
222
+ } else if (candidate === cur && cur > 0) {
223
+ // Tie — another predecessor reaches `tgt` at the same length;
224
+ // record it so the enumeration includes both paths.
225
+ if (!preds.has(tgt)) preds.set(tgt, new Set());
226
+ preds.get(tgt).add(id);
227
+ }
228
+ }
229
+ }
230
+ if (myLen > bestLen) bestLen = myLen;
231
+ }
232
+ if (bestLen === 0) return { path: [], paths: [], length: 0 };
233
+
234
+ // All nodes whose longest-path-ending-here equals bestLen are "best
235
+ // sinks" — every longest path ends at one of them.
236
+ const bestSinks = [];
237
+ for (const [id, d] of dist) if (d === bestLen) bestSinks.push(id);
238
+
239
+ // Enumerate every longest path by walking back via `preds`. DFS from each
240
+ // best-sink; deduplicate via stringified path-key. Cap at MAX_PATHS so a
241
+ // pathological wide diamond doesn't explode the count.
242
+ const MAX_PATHS = 32;
243
+ const allPaths = [];
244
+ const seen = new Set();
245
+ function dfs(node, acc) {
246
+ if (allPaths.length >= MAX_PATHS) return;
247
+ const ps = preds.get(node);
248
+ if (!ps || ps.size === 0) {
249
+ const fullForward = acc.slice().reverse();
250
+ const key = fullForward.join("|");
251
+ if (!seen.has(key)) { seen.add(key); allPaths.push(fullForward); }
252
+ return;
253
+ }
254
+ for (const p of ps) {
255
+ acc.push(p);
256
+ dfs(p, acc);
257
+ acc.pop();
258
+ }
259
+ }
260
+ for (const sink of bestSinks) dfs(sink, [sink]);
261
+
262
+ // Backward-compatibility: `path` is the FIRST longest path; `paths` is
263
+ // every longest path. Both forms ship so callers can pick.
264
+ const primary = allPaths[0] || [];
265
+ return { path: primary, paths: allPaths, length: bestLen };
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // impactSetForTicket
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Forward-closure: all tickets that `ticketId` directly OR transitively
274
+ * blocks / is-predecessor-of. Only edges with semantic in
275
+ * {"blocking", "precedence"} are followed.
276
+ *
277
+ * @returns {string[]} ticket ids in BFS order, deduped, NOT including
278
+ * `ticketId` itself. Unknown ticketId → [].
279
+ */
280
+ function impactSetForTicket(snapshot, ticketId) {
281
+ if (!snapshot || !Array.isArray(snapshot.tickets) || typeof ticketId !== "string") {
282
+ return [];
283
+ }
284
+ const exists = snapshot.tickets.some(t => t && t.id === ticketId);
285
+ if (!exists) return [];
286
+ const semanticMap = buildLinkTypeSemanticMap(snapshot.project || {});
287
+ const adj = buildDependencyAdjacency(snapshot, semanticMap, null);
288
+
289
+ const out = [];
290
+ const seen = new Set([ticketId]);
291
+ const queue = [ticketId];
292
+ let qIdx = 0;
293
+ while (qIdx < queue.length) {
294
+ const cur = queue[qIdx++];
295
+ const ts = adj.get(cur);
296
+ if (!ts) continue;
297
+ for (const tgt of ts) {
298
+ if (seen.has(tgt)) continue;
299
+ seen.add(tgt);
300
+ out.push(tgt);
301
+ queue.push(tgt);
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // traceabilityFor
309
+ // ---------------------------------------------------------------------------
310
+
311
+ /**
312
+ * Resolve which stories sit under an epic and which test-typed tickets
313
+ * cover at least one of those stories.
314
+ *
315
+ * @returns {{ stories: string[], tests: string[] }}
316
+ * `stories`: ticket ids contained by the epic (via the epic's outgoing
317
+ * `contains` link — SM-52 canonical form, with legacy
318
+ * `position.epicId` as a fallback for pre-migration snapshots),
319
+ * excluding sub-epics (type === "epic").
320
+ * `tests`: ticket ids with type ∈ TEST_TYPES that have a link whose
321
+ * targetTicketId is in `stories`. Link-type semantic is NOT
322
+ * filtered here — any link from test → story counts as
323
+ * coverage (typically "validation", but we accept all so a
324
+ * freeform "relates-to" coverage note still counts).
325
+ */
326
+ function traceabilityFor(snapshot, epicId) {
327
+ const empty = { stories: [], tests: [] };
328
+ if (!snapshot || !Array.isArray(snapshot.tickets) || typeof epicId !== "string") {
329
+ return empty;
330
+ }
331
+ // SM-52: read containment from the epic's outgoing contains-links.
332
+ const epicTicket = snapshot.tickets.find(t => t && t.id === epicId);
333
+ const containedIds = new Set();
334
+ if (epicTicket && Array.isArray(epicTicket.links)) {
335
+ for (const l of epicTicket.links) {
336
+ if (!l || typeof l.targetTicketId !== "string") continue;
337
+ const lt = l.linkTypeId || l.type;
338
+ if (lt === "contains") containedIds.add(l.targetTicketId);
339
+ }
340
+ }
341
+ const stories = [];
342
+ for (const t of snapshot.tickets) {
343
+ if (!t || typeof t.id !== "string") continue;
344
+ // SM-196: a spec object accidentally contained by an epic is still not a
345
+ // realising story — never count it toward the feature's traceability.
346
+ if (t.type === "epic" || t.type === "requirement" || t.type === "spec-module") continue;
347
+ // Canonical (contains-link) wins; fall back to legacy field for
348
+ // unmigrated snapshots.
349
+ const inContainer = containedIds.has(t.id)
350
+ || ((t.position && t.position.epicId) === epicId);
351
+ if (!inContainer) continue;
352
+ stories.push(t.id);
353
+ }
354
+ if (stories.length === 0) return { stories: [], tests: [] };
355
+
356
+ const storySet = new Set(stories);
357
+ const tests = [];
358
+ const seenTests = new Set();
359
+ for (const t of snapshot.tickets) {
360
+ if (!t || typeof t.id !== "string") continue;
361
+ if (TEST_TYPES.indexOf(t.type) < 0) continue;
362
+ if (!Array.isArray(t.links)) continue;
363
+ for (const l of t.links) {
364
+ if (!l || typeof l.targetTicketId !== "string") continue;
365
+ if (storySet.has(l.targetTicketId)) {
366
+ if (!seenTests.has(t.id)) {
367
+ seenTests.add(t.id);
368
+ tests.push(t.id);
369
+ }
370
+ break;
371
+ }
372
+ }
373
+ }
374
+ return { stories: stories, tests: tests };
375
+ }
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // actionableTickets (SM-172) — "what can I start now?"
379
+ // ---------------------------------------------------------------------------
380
+
381
+ /** All required DoR items are checked (or there are none). */
382
+ function _dorSatisfied(ticket) {
383
+ const items = (ticket.definitionOfReady && ticket.definitionOfReady.items) || [];
384
+ for (const i of items) if (i && i.required && !i.checked) return false;
385
+ return true;
386
+ }
387
+
388
+ /**
389
+ * Return the ids of tickets that are ready to pull RIGHT NOW:
390
+ * - a work item (epics are containers → excluded) and not soft-deleted,
391
+ * - status category "todo" (open, not yet doing/blocked/done),
392
+ * - DoR met: every required DoR item checked — OR the ticket is already at
393
+ * status "ready" (it passed the gate at transition time),
394
+ * - NOT blocked: no incoming blocking/precedence edge from a ticket that
395
+ * isn't done. Edge convention (as in criticalPath): A → B means "A must
396
+ * be done before B", so B is blocked while any such A is unresolved.
397
+ * Same DEPENDENCY_SEMANTICS the rest of the graph uses (blocking +
398
+ * precedence; sequence/relates/contains never block).
399
+ *
400
+ * @param {Snapshot} snapshot
401
+ * @param {Object} [opts]
402
+ * @param {string} [opts.releaseId] restrict to this release.
403
+ * @param {string} [opts.type] restrict to this ticket type.
404
+ * @returns {string[]} ticket ids, ordered by position.sortOrder asc then
405
+ * ticketKey — a stable "do these next" queue.
406
+ */
407
+ function actionableTickets(snapshot, opts) {
408
+ opts = opts || {};
409
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return [];
410
+ const project = snapshot.project || {};
411
+ const semanticMap = buildLinkTypeSemanticMap(project);
412
+ const statusCatMap = buildStatusCategoryMap(project);
413
+
414
+ // A ticket is blocked if some unresolved (non-done, non-deleted) ticket
415
+ // points to it via a blocking/precedence edge.
416
+ const blocked = new Set();
417
+ for (const src of snapshot.tickets) {
418
+ if (!src || src.isDeleted || !Array.isArray(src.links)) continue;
419
+ if (isTerminalTicket(src, statusCatMap)) continue; // SM-242: cancelled predecessor doesn't block
420
+ for (const l of src.links) {
421
+ if (!l || typeof l.targetTicketId !== "string") continue;
422
+ if (DEPENDENCY_SEMANTICS.indexOf(semanticMap.get(l.linkTypeId)) < 0) continue;
423
+ blocked.add(l.targetTicketId);
424
+ }
425
+ }
426
+
427
+ const out = [];
428
+ for (const t of snapshot.tickets) {
429
+ // SM-196: epics are containers, spec-layer types (requirement/spec-module)
430
+ // are SpecObjects — none is pullable work, so all are excluded from the
431
+ // actionable queue. (Mirrors core.SPEC_TYPES; graph.js stays core-free.)
432
+ if (!t || t.isDeleted || t.type === "epic"
433
+ || t.type === "requirement" || t.type === "spec-module") continue;
434
+ if (statusCatMap.get(t.status) !== "todo") continue;
435
+ if (opts.releaseId && ((t.position && t.position.releaseId) || null) !== opts.releaseId) continue;
436
+ if (opts.type && t.type !== opts.type) continue;
437
+ if (t.status !== "ready" && !_dorSatisfied(t)) continue;
438
+ if (blocked.has(t.id)) continue;
439
+ out.push(t);
440
+ }
441
+ out.sort((a, b) => {
442
+ const sa = (a.position && a.position.sortOrder) || 0;
443
+ const sb = (b.position && b.position.sortOrder) || 0;
444
+ if (sa !== sb) return sa - sb;
445
+ return String(a.ticketKey || a.id).localeCompare(String(b.ticketKey || b.id));
446
+ });
447
+ return out.map(t => t.id);
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // foldProductDescription (SM-176) — fold the ticket changelog into the
452
+ // current-state product model, grouped by feature (epic).
453
+ // ---------------------------------------------------------------------------
454
+
455
+ function _storySummary(t) {
456
+ return {
457
+ id: t.id, ticketKey: t.ticketKey, type: t.type, status: t.status,
458
+ title: t.title,
459
+ acceptanceCriteria: Array.isArray(t.acceptanceCriteria) ? t.acceptanceCriteria : []
460
+ };
461
+ }
462
+
463
+ // Roll a feature's stories up to a single status: shipped (all done) /
464
+ // in-progress (some done or actively worked) / planned (all still todo).
465
+ function _deriveFeatureStatus(stories, statusCatMap) {
466
+ if (!stories.length) return "planned";
467
+ // SM-243: cancelled stories are terminal scope-reduction — they neither keep
468
+ // a feature open nor count as work. Consistent with deriveEpicStatus: all
469
+ // terminal (done ∪ cancelled) with ≥1 done → shipped.
470
+ let anyActive = false, anyDone = false, anyOpen = false;
471
+ for (const s of stories) {
472
+ const cat = statusCatMap.get(s.status);
473
+ if (cat === "done") { anyDone = true; }
474
+ else if (cat === "cancelled") { /* terminal — ignore */ }
475
+ else { anyOpen = true; if (cat === "doing" || cat === "blocked") anyActive = true; }
476
+ }
477
+ if (!anyOpen && anyDone) return "shipped";
478
+ if (anyActive || anyDone) return "in-progress";
479
+ return "planned";
480
+ }
481
+
482
+ /**
483
+ * Fold the tickets into a current-state product model (SM-176, direction B —
484
+ * Tickets → product description). Epics are feature-spec units; their
485
+ * contained stories (via traceabilityFor — contains-graph) are the
486
+ * realisation, grouped by release (the time axis); linked test-definitions
487
+ * carry the validation status (derivedHealth). Non-epic work items not claimed
488
+ * by any feature land in `orphans`.
489
+ *
490
+ * @param {Snapshot} snapshot
491
+ * @param {Object} [opts]
492
+ * @param {string} [opts.releaseId] restrict stories (and the features that
493
+ * have any) to this release.
494
+ * @param {string[]} [opts.types] allowlist of work-item types that count as
495
+ * content (e.g. ["epic","user-story","technical-task-backend"] for a PRD —
496
+ * excludes bug/test noise). Narrows shown stories + orphans; epics stay.
497
+ * @returns {{ features: Array, orphans: Array }}
498
+ */
499
+ function foldProductDescription(snapshot, opts) {
500
+ opts = opts || {};
501
+ const out = { features: [], orphans: [] };
502
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return out;
503
+ const statusCatMap = buildStatusCategoryMap(snapshot.project || {});
504
+ const byId = new Map();
505
+ for (const t of snapshot.tickets) if (t && typeof t.id === "string") byId.set(t.id, t);
506
+
507
+ const releases = (snapshot.releases || []).filter(r => r && !r.isDeleted)
508
+ .slice().sort((a, b) => ((a.sortOrder || 0) - (b.sortOrder || 0)));
509
+ const releaseName = new Map(releases.map(r => [r.id, r.name]));
510
+ const releaseOrder = new Map(releases.map((r, i) => [r.id, i]));
511
+ const orderOf = (rid) => (releaseOrder.has(rid) ? releaseOrder.get(rid) : Number.MAX_SAFE_INTEGER);
512
+
513
+ const relOf = (t) => (t.position && t.position.releaseId) || null;
514
+ const inReleaseScope = (t) => !opts.releaseId || relOf(t) === opts.releaseId;
515
+ const releaseObj = (rid) => rid === null ? null : { id: rid, name: releaseName.get(rid) || null };
516
+ // SM-186: opts.types restricts which work-item types count as content (e.g. a
517
+ // PRD wants epic/user-story/technical-task, NOT bug/test). Undefined = all.
518
+ const typeOk = (t) => !Array.isArray(opts.types) || opts.types.indexOf(t.type) >= 0;
519
+
520
+ const claimed = new Set();
521
+ const epics = snapshot.tickets.filter(t => t && !t.isDeleted && t.type === "epic");
522
+ // Order features by their release, then sortOrder, then key.
523
+ epics.sort((a, b) => {
524
+ const ra = orderOf(relOf(a)), rb = orderOf(relOf(b));
525
+ if (ra !== rb) return ra - rb;
526
+ const sa = (a.position && a.position.sortOrder) || 0;
527
+ const sb = (b.position && b.position.sortOrder) || 0;
528
+ if (sa !== sb) return sa - sb;
529
+ return String(a.ticketKey || a.id).localeCompare(String(b.ticketKey || b.id));
530
+ });
531
+ const epicById = new Map(epics.map(e => [e.id, e]));
532
+
533
+ // Cache traceability per epic + claim EVERY epic's stories up front (incl.
534
+ // superseded epics) so an old version's stories never leak into orphans.
535
+ const traceById = new Map();
536
+ for (const e of epics) {
537
+ const tr = traceabilityFor(snapshot, e.id);
538
+ traceById.set(e.id, tr);
539
+ tr.stories.forEach(sid => claimed.add(sid));
540
+ }
541
+
542
+ // SM-180: supersession edges among epics (`A supersedes/replaces B` → B is the
543
+ // older version). The superseding epic is the current feature; superseded
544
+ // epics fold into its history and drop out of the top-level feature list.
545
+ const semanticMap = buildLinkTypeSemanticMap(snapshot.project || {});
546
+ const supersedesTarget = new Map(); // epicId → [older epicId, …]
547
+ const supersededIds = new Set();
548
+ for (const e of epics) {
549
+ for (const l of (e.links || [])) {
550
+ if (!l || typeof l.targetTicketId !== "string") continue;
551
+ if (semanticMap.get(l.linkTypeId) !== "supersession") continue;
552
+ if (!epicById.has(l.targetTicketId)) continue; // epic→epic only
553
+ if (!supersedesTarget.has(e.id)) supersedesTarget.set(e.id, []);
554
+ supersedesTarget.get(e.id).push(l.targetTicketId);
555
+ supersededIds.add(l.targetTicketId);
556
+ }
557
+ }
558
+ // The visited-guard is load-bearing, not just defensive: the link cycle-check
559
+ // is per-linkTypeId, so a `supersedes A→B` + `replaces B→A` pair (two ids,
560
+ // same "supersession" semantic) is NOT rejected at link-create time. The
561
+ // guard keeps this fold terminating regardless. (SM-180 review nit.)
562
+ function historyChain(headId) {
563
+ const out = [];
564
+ const visited = new Set([headId]);
565
+ let frontier = (supersedesTarget.get(headId) || []).slice();
566
+ while (frontier.length) {
567
+ const next = [];
568
+ for (const id of frontier) {
569
+ if (visited.has(id)) continue;
570
+ visited.add(id);
571
+ const ep = epicById.get(id);
572
+ if (ep) out.push({
573
+ epic: { id: ep.id, ticketKey: ep.ticketKey, title: ep.title },
574
+ release: releaseObj(relOf(ep))
575
+ });
576
+ for (const t of (supersedesTarget.get(id) || [])) next.push(t);
577
+ }
578
+ frontier = next;
579
+ }
580
+ return out.reverse(); // oldest-first (V1 → V2 → … → head is current)
581
+ }
582
+
583
+ // v1 scope note (SM-180 review nit): under opts.releaseId, a feature is shown
584
+ // by its HEAD epic's release. Scoping to an OLD release therefore won't surface
585
+ // a superseded version that lived there — its head was filtered out. Acceptable
586
+ // for v1 (release-scope is a "what's the current state of this release" lens);
587
+ // a future "as-of release" mode would walk the history instead.
588
+ for (const epic of epics) {
589
+ if (supersededIds.has(epic.id)) continue; // an older version → in some head's history
590
+ const trace = traceById.get(epic.id);
591
+ const allStories = trace.stories.map(id => byId.get(id)).filter(s => s && !s.isDeleted);
592
+ // SM-67: contained stories inherit the epic's release. Release-scope filters
593
+ // features wholesale; the type filter only narrows the SHOWN stories (the
594
+ // epic stays — it IS the feature).
595
+ const inRelease = allStories.filter(inReleaseScope);
596
+ if (opts.releaseId && inRelease.length === 0) continue;
597
+ const stories = inRelease.filter(typeOk);
598
+ const tests = trace.tests.map(id => byId.get(id)).filter(Boolean).map(t => ({
599
+ id: t.id, ticketKey: t.ticketKey, title: t.title, type: t.type,
600
+ health: t.type === "test-definition" ? (t.derivedHealth || "unused") : null
601
+ }));
602
+ out.features.push({
603
+ epic: { id: epic.id, ticketKey: epic.ticketKey, title: epic.title, status: epic.status },
604
+ release: releaseObj(relOf(epic)),
605
+ status: _deriveFeatureStatus(stories, statusCatMap),
606
+ stories: stories.map(_storySummary),
607
+ tests,
608
+ history: historyChain(epic.id)
609
+ });
610
+ }
611
+
612
+ // Orphans: non-epic, non-test work items not claimed by any feature. Unlike
613
+ // contained stories they keep their own release, so carry it on each entry.
614
+ const orphans = snapshot.tickets.filter(t =>
615
+ t && !t.isDeleted && t.type !== "epic"
616
+ && TEST_TYPES.indexOf(t.type) < 0
617
+ // SM-196: spec objects (requirement/spec-module) are not work items — they
618
+ // never appear as orphan features in the product-doc fold.
619
+ && t.type !== "requirement" && t.type !== "spec-module"
620
+ && typeOk(t)
621
+ && !claimed.has(t.id)
622
+ && inReleaseScope(t));
623
+ orphans.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
624
+ - ((b.position && b.position.sortOrder) || 0));
625
+ out.orphans = orphans.map(o => Object.assign(_storySummary(o), { release: releaseObj(relOf(o)) }));
626
+ return out;
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // traceCoverage (SM-202 R-6) — the direction-A mirror of foldProductDescription.
631
+ // For each `requirement` SpecObject, count the incoming traceability links:
632
+ // realisedBy = tickets with a `realises` link → this requirement (DOORS
633
+ // "satisfies"); testedBy = tickets with a `tests` link.
634
+ // Classify each requirement and roll up a summary. This is the completeness/
635
+ // quality signal for a sliced PRD: orphan requirements have no implementation.
636
+ // ---------------------------------------------------------------------------
637
+
638
+ function traceCoverage(snapshot, opts) {
639
+ opts = opts || {};
640
+ const out = { requirements: [], summary: { total: 0, covered: 0, orphan: 0, overCovered: 0, suspect: 0 } };
641
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return out;
642
+ const byId = new Map(snapshot.tickets.map(t => [t.id, t]));
643
+
644
+ // Optional scope: only the requirements contained by a given spec module.
645
+ let scopeIds = null;
646
+ if (opts.moduleId) {
647
+ scopeIds = new Set();
648
+ const mod = byId.get(opts.moduleId);
649
+ if (mod && Array.isArray(mod.links)) {
650
+ for (const l of mod.links) {
651
+ if ((l.linkTypeId || l.type) === "contains") scopeIds.add(l.targetTicketId);
652
+ }
653
+ }
654
+ }
655
+
656
+ // Index incoming realises/tests links per requirement target.
657
+ const realisedBy = new Map();
658
+ const testedBy = new Map();
659
+ for (const t of snapshot.tickets) {
660
+ if (!t || t.isDeleted || !Array.isArray(t.links)) continue;
661
+ for (const l of t.links) {
662
+ if (!l || typeof l.targetTicketId !== "string") continue;
663
+ const lt = l.linkTypeId || l.type;
664
+ const bucket = lt === "realises" ? realisedBy : (lt === "tests" ? testedBy : null);
665
+ if (!bucket) continue;
666
+ if (!bucket.has(l.targetTicketId)) bucket.set(l.targetTicketId, []);
667
+ bucket.get(l.targetTicketId).push(t.id);
668
+ }
669
+ }
670
+
671
+ for (const t of snapshot.tickets) {
672
+ if (!t || t.isDeleted || t.type !== "requirement") continue;
673
+ if (scopeIds && !scopeIds.has(t.id)) continue;
674
+ const rb = realisedBy.get(t.id) || [];
675
+ const tb = testedBy.get(t.id) || [];
676
+ // suspect: tested but not implemented — an inverted/incomplete trace.
677
+ let status;
678
+ if (rb.length === 0) status = tb.length > 0 ? "suspect" : "orphan";
679
+ else if (rb.length > 1) status = "over-covered";
680
+ else status = "covered";
681
+ out.requirements.push({
682
+ id: t.id, ticketKey: t.ticketKey, sectionPath: t.sectionPath, title: t.title,
683
+ status: status, realisedBy: rb, testedBy: tb
684
+ });
685
+ out.summary.total++;
686
+ out.summary[status === "over-covered" ? "overCovered" : status]++;
687
+ }
688
+ return out;
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // driftReport (SM-182 Phase 4) — close the PRD ↔ Tickets round-trip + keep it
693
+ // honest as a living document. Three drift signals on the trace graph:
694
+ //
695
+ // 1. orphan/suspect requirements — a PRD requirement no implementation ticket
696
+ // `realises` (orphan), or one only a test `tests` without an implementer
697
+ // (suspect). Reuses traceCoverage; honours opts.moduleId.
698
+ // 2. danglingLinks — a `realises`/`tests` link whose target is missing, soft-
699
+ // deleted, or not a requirement. The anchor points at nothing real.
700
+ // 3. supersededTrace — a `realises` anchored on a HISTORICAL feature version
701
+ // (a superseded epic, or a story contained by one). The implementation has
702
+ // moved on but the trace anchor stayed behind — the requirement looks
703
+ // covered by code that is no longer the current feature.
704
+ //
705
+ // Pure + read-only: the MCP layer wraps this, and (separately) re-generates the
706
+ // product doc and writes it back as an attachment to close the loop.
707
+ // ---------------------------------------------------------------------------
708
+
709
+ function driftReport(snapshot, opts) {
710
+ opts = opts || {};
711
+ const out = {
712
+ orphanRequirements: [],
713
+ suspectRequirements: [],
714
+ danglingLinks: [],
715
+ supersededTrace: [],
716
+ summary: {
717
+ orphanRequirements: 0, suspectRequirements: 0,
718
+ danglingLinks: 0, supersededTrace: 0, clean: true
719
+ }
720
+ };
721
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return out;
722
+ const byId = new Map(snapshot.tickets.map(t => [t.id, t]));
723
+
724
+ // 1. Orphan / suspect requirements (delegates classification to traceCoverage).
725
+ const cov = traceCoverage(snapshot, opts.moduleId ? { moduleId: opts.moduleId } : {});
726
+ for (const r of cov.requirements) {
727
+ const entry = { id: r.id, ticketKey: r.ticketKey, sectionPath: r.sectionPath, title: r.title };
728
+ if (r.status === "orphan") out.orphanRequirements.push(entry);
729
+ if (r.status === "suspect") out.suspectRequirements.push(entry);
730
+ }
731
+
732
+ // 2. Dangling realises/tests links — target missing / deleted / wrong-kind.
733
+ // `realises` MUST point at a requirement (DOORS satisfies), so a non-
734
+ // requirement target is drift. `tests` is overloaded by design: a
735
+ // test-definition `tests` either a requirement (direction-A validates) OR a
736
+ // work-item feature/bug (the publish-gate pattern, core.js#linked_definitions_health)
737
+ // — both are healthy, so for `tests` only a missing/deleted target is dangling.
738
+ for (const t of snapshot.tickets) {
739
+ if (!t || t.isDeleted || !Array.isArray(t.links)) continue;
740
+ for (const l of t.links) {
741
+ if (!l || typeof l.targetTicketId !== "string") continue;
742
+ const lt = l.linkTypeId || l.type;
743
+ if (lt !== "realises" && lt !== "tests") continue;
744
+ const target = byId.get(l.targetTicketId);
745
+ let reason = null;
746
+ if (!target) reason = "target-missing";
747
+ else if (target.isDeleted) reason = "target-deleted";
748
+ else if (lt === "realises" && target.type !== "requirement") reason = "target-not-requirement";
749
+ if (reason) {
750
+ out.danglingLinks.push({
751
+ sourceId: t.id, sourceKey: t.ticketKey, linkType: lt,
752
+ targetId: l.targetTicketId, reason: reason
753
+ });
754
+ }
755
+ }
756
+ }
757
+
758
+ // 3. Superseded trace — realises anchored on a historical feature version.
759
+ // Superseded epics = the targets of supersession links (same rule the
760
+ // product-doc fold uses). Their contained stories are historical too.
761
+ const semanticMap = buildLinkTypeSemanticMap(snapshot.project || {});
762
+ const epicById = new Map();
763
+ for (const t of snapshot.tickets) {
764
+ if (t && !t.isDeleted && t.type === "epic") epicById.set(t.id, t);
765
+ }
766
+ const supersededIds = new Set();
767
+ for (const e of epicById.values()) {
768
+ for (const l of (e.links || [])) {
769
+ if (!l || typeof l.targetTicketId !== "string") continue;
770
+ if (semanticMap.get(l.linkTypeId) !== "supersession") continue;
771
+ if (epicById.has(l.targetTicketId)) supersededIds.add(l.targetTicketId);
772
+ }
773
+ }
774
+ // staleSources = superseded epics + the stories they contain. v1 limitation
775
+ // (shared with foldProductDescription's `claimed` set): a story dual-contained
776
+ // by both a superseded AND a current epic is treated as stale here — the model
777
+ // permits dual containment but it's degenerate; not worth a current-epic
778
+ // subtraction pass for v1.
779
+ const staleSources = new Set();
780
+ for (const eid of supersededIds) {
781
+ staleSources.add(eid);
782
+ traceabilityFor(snapshot, eid).stories.forEach(sid => staleSources.add(sid));
783
+ }
784
+ for (const t of snapshot.tickets) {
785
+ if (!t || t.isDeleted || !staleSources.has(t.id) || !Array.isArray(t.links)) continue;
786
+ for (const l of t.links) {
787
+ if (!l || typeof l.targetTicketId !== "string") continue;
788
+ if ((l.linkTypeId || l.type) !== "realises") continue;
789
+ const target = byId.get(l.targetTicketId);
790
+ if (!target || target.isDeleted || target.type !== "requirement") continue; // dangling, already counted in #2
791
+ out.supersededTrace.push({
792
+ sourceId: t.id, sourceKey: t.ticketKey,
793
+ requirementId: target.id, requirementKey: target.ticketKey, sectionPath: target.sectionPath
794
+ });
795
+ }
796
+ }
797
+
798
+ out.summary.orphanRequirements = out.orphanRequirements.length;
799
+ out.summary.suspectRequirements = out.suspectRequirements.length;
800
+ out.summary.danglingLinks = out.danglingLinks.length;
801
+ out.summary.supersededTrace = out.supersededTrace.length;
802
+ out.summary.clean = out.summary.orphanRequirements === 0
803
+ && out.summary.suspectRequirements === 0
804
+ && out.summary.danglingLinks === 0
805
+ && out.summary.supersededTrace === 0;
806
+ return out;
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Exports
811
+ // ---------------------------------------------------------------------------
812
+
813
+ return {
814
+ DEPENDENCY_SEMANTICS,
815
+ TEST_TYPES,
816
+ criticalPath,
817
+ impactSetForTicket,
818
+ traceabilityFor,
819
+ actionableTickets,
820
+ foldProductDescription,
821
+ traceCoverage,
822
+ driftReport
823
+ };
824
+
825
+ }));