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
|
@@ -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
|
+
}));
|