@sandrinio/vbounce 1.9.0 → 2.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.
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * product_graph.mjs
5
+ * Scans product_plans/ for planning documents, extracts YAML frontmatter,
6
+ * and outputs a lightweight JSON graph to .bounce/product-graph.json.
7
+ *
8
+ * The graph gives AI instant awareness of all product documents and their
9
+ * relationships without reading every file.
10
+ *
11
+ * Usage:
12
+ * node scripts/product_graph.mjs
13
+ * node scripts/product_graph.mjs --json # output to stdout instead of file
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+
20
+ let yaml;
21
+ try {
22
+ yaml = await import('js-yaml');
23
+ } catch {
24
+ console.error('ERROR: js-yaml not installed. Run: npm install js-yaml');
25
+ process.exit(1);
26
+ }
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ const ROOT = path.resolve(__dirname, '..');
30
+
31
+ const SCAN_DIRS = ['strategy', 'backlog', 'sprints', 'hotfixes'];
32
+ const PRODUCT_PLANS = path.join(ROOT, 'product_plans');
33
+ const OUTPUT_PATH = path.join(ROOT, '.bounce', 'product-graph.json');
34
+
35
+ const args = process.argv.slice(2);
36
+ const toStdout = args.includes('--json');
37
+
38
+ // ── Document type detection ──────────────────────────────────────
39
+
40
+ const DOC_PATTERNS = [
41
+ { pattern: /^EPIC-(\d+)/i, type: 'epic' },
42
+ { pattern: /^STORY-(\d+)-(\d+)/i, type: 'story' },
43
+ { pattern: /^SPIKE-(\d+)-(\d+)/i, type: 'spike' },
44
+ { pattern: /^HOTFIX-/i, type: 'hotfix' },
45
+ { pattern: /sprint-(\d+)\.md$/i, type: 'sprint-plan' },
46
+ { pattern: /sprint-report/i, type: 'sprint-report' },
47
+ { pattern: /charter/i, type: 'charter' },
48
+ { pattern: /roadmap/i, type: 'roadmap' },
49
+ { pattern: /delivery[_-]plan/i, type: 'delivery-plan' },
50
+ { pattern: /risk[_-]registry/i, type: 'risk-registry' },
51
+ ];
52
+
53
+ /**
54
+ * Detect document type from filename.
55
+ * @param {string} filename
56
+ * @returns {string}
57
+ */
58
+ function detectType(filename) {
59
+ for (const { pattern, type } of DOC_PATTERNS) {
60
+ if (pattern.test(filename)) return type;
61
+ }
62
+ return 'unknown';
63
+ }
64
+
65
+ /**
66
+ * Derive a document ID from filename and frontmatter.
67
+ * @param {string} filename
68
+ * @param {object} frontmatter
69
+ * @param {string} docType
70
+ * @returns {string}
71
+ */
72
+ function deriveId(filename, frontmatter, docType) {
73
+ // Use explicit ID fields from frontmatter if available
74
+ if (frontmatter.epic_id) return frontmatter.epic_id;
75
+ if (frontmatter.story_id) return frontmatter.story_id;
76
+ if (frontmatter.spike_id) return frontmatter.spike_id;
77
+ if (frontmatter.sprint_id) return frontmatter.sprint_id;
78
+ if (frontmatter.hotfix_id) return frontmatter.hotfix_id;
79
+ if (frontmatter.delivery_id) return frontmatter.delivery_id;
80
+
81
+ // Derive from filename
82
+ const base = path.basename(filename, '.md');
83
+
84
+ const epicMatch = base.match(/^(EPIC-\d+)/i);
85
+ if (epicMatch) return epicMatch[1].toUpperCase();
86
+
87
+ const storyMatch = base.match(/^(STORY-\d+-\d+)/i);
88
+ if (storyMatch) return storyMatch[1].toUpperCase();
89
+
90
+ const spikeMatch = base.match(/^(SPIKE-\d+-\d+)/i);
91
+ if (spikeMatch) return spikeMatch[1].toUpperCase();
92
+
93
+ const sprintMatch = base.match(/^sprint-(\d+)$/i);
94
+ if (sprintMatch) return `S-${sprintMatch[1].padStart(2, '0')}`;
95
+
96
+ const hotfixMatch = base.match(/^(HOTFIX-[^.]+)/i);
97
+ if (hotfixMatch) return hotfixMatch[1].toUpperCase();
98
+
99
+ // Fallback: use docType + sanitized filename
100
+ if (docType === 'charter') return 'CHARTER';
101
+ if (docType === 'roadmap') return 'ROADMAP';
102
+ if (docType === 'risk-registry') return 'RISK-REGISTRY';
103
+ if (docType === 'delivery-plan') {
104
+ const dpMatch = base.match(/^(D-\d+)/i);
105
+ if (dpMatch) return dpMatch[1].toUpperCase();
106
+ return `DP-${base}`;
107
+ }
108
+
109
+ return base.toUpperCase();
110
+ }
111
+
112
+ // ── YAML extraction ──────────────────────────────────────────────
113
+
114
+ /**
115
+ * Extract YAML frontmatter from a markdown file.
116
+ * @param {string} filePath
117
+ * @returns {{ frontmatter: object|null, title: string|null }}
118
+ */
119
+ function extractFrontmatter(filePath) {
120
+ let content;
121
+ try {
122
+ content = fs.readFileSync(filePath, 'utf8');
123
+ } catch {
124
+ return { frontmatter: null, title: null };
125
+ }
126
+
127
+ // Extract title from first heading
128
+ const titleMatch = content.match(/^#\s+(.+)$/m);
129
+ const title = titleMatch ? titleMatch[1].trim() : null;
130
+
131
+ // Extract YAML frontmatter
132
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
133
+ if (!fmMatch) return { frontmatter: null, title };
134
+
135
+ try {
136
+ const frontmatter = yaml.default?.load
137
+ ? yaml.default.load(fmMatch[1])
138
+ : yaml.load(fmMatch[1]);
139
+ return { frontmatter: frontmatter || {}, title };
140
+ } catch (err) {
141
+ console.error(` WARN: Malformed YAML in ${path.relative(ROOT, filePath)}: ${err.message}`);
142
+ return { frontmatter: null, title };
143
+ }
144
+ }
145
+
146
+ // ── Edge extraction ──────────────────────────────────────────────
147
+
148
+ /**
149
+ * Extract edges from frontmatter fields and document content.
150
+ * @param {string} docId
151
+ * @param {object} fm - frontmatter
152
+ * @param {string} docType
153
+ * @param {string} filePath
154
+ * @returns {Array<{from: string, to: string, type: string}>}
155
+ */
156
+ function extractEdges(docId, fm, docType, filePath) {
157
+ const edges = [];
158
+
159
+ // parent_epic_ref → parent edge
160
+ if (fm.parent_epic_ref) {
161
+ edges.push({ from: fm.parent_epic_ref, to: docId, type: 'parent' });
162
+ }
163
+
164
+ // Derive parent epic from story/spike ID pattern (STORY-003-01 → EPIC-003)
165
+ if (!fm.parent_epic_ref && (docType === 'story' || docType === 'spike')) {
166
+ const epicNum = docId.match(/(?:STORY|SPIKE)-(\d+)/i);
167
+ if (epicNum) {
168
+ const parentId = `EPIC-${epicNum[1].padStart(3, '0')}`;
169
+ edges.push({ from: parentId, to: docId, type: 'parent' });
170
+ }
171
+ }
172
+
173
+ // charter_ref → context-source edge
174
+ if (fm.charter_ref) {
175
+ edges.push({ from: fm.charter_ref, to: docId, type: 'context-source' });
176
+ }
177
+
178
+ // roadmap_ref → context-source edge
179
+ if (fm.roadmap_ref) {
180
+ edges.push({ from: fm.roadmap_ref, to: docId, type: 'context-source' });
181
+ }
182
+
183
+ // context_source → context-source edge (text field, try to match doc IDs)
184
+ if (fm.context_source && typeof fm.context_source === 'string') {
185
+ const refs = fm.context_source.match(/(?:EPIC|STORY|SPIKE|CHARTER|ROADMAP|S|D)-[\w-]+/gi) || [];
186
+ for (const ref of refs) {
187
+ edges.push({ from: ref.toUpperCase(), to: docId, type: 'context-source' });
188
+ }
189
+ }
190
+
191
+ // release → feeds edge (e.g., release: "D-02")
192
+ if (fm.release) {
193
+ const releaseId = fm.release.match(/(D-\d+)/i);
194
+ if (releaseId) {
195
+ edges.push({ from: docId, to: releaseId[1].toUpperCase(), type: 'feeds' });
196
+ }
197
+ }
198
+
199
+ // risk_registry_ref → context-source edge
200
+ if (fm.risk_registry_ref) {
201
+ edges.push({ from: 'RISK-REGISTRY', to: docId, type: 'context-source' });
202
+ }
203
+
204
+ // delivery field in sprint plans
205
+ if (fm.delivery) {
206
+ const dpId = fm.delivery.match(/(D-\d+)/i);
207
+ if (dpId) {
208
+ edges.push({ from: docId, to: dpId[1].toUpperCase(), type: 'feeds' });
209
+ }
210
+ }
211
+
212
+ // depends_on / dependencies (array or string)
213
+ const deps = fm.depends_on || fm.dependencies || [];
214
+ const depList = Array.isArray(deps) ? deps : [deps];
215
+ for (const dep of depList) {
216
+ if (typeof dep === 'string') {
217
+ const depIds = dep.match(/(?:EPIC|STORY|SPIKE|S|D)-[\w-]+/gi) || [];
218
+ for (const depId of depIds) {
219
+ edges.push({ from: depId.toUpperCase(), to: docId, type: 'depends-on' });
220
+ }
221
+ }
222
+ }
223
+
224
+ // Extract dependency table from document content (§4.2 pattern)
225
+ try {
226
+ const content = fs.readFileSync(filePath, 'utf8');
227
+ const depTableMatch = content.match(/(?:depend|block|prerequisite)[\s\S]*?\|[\s\S]*?\|/gi);
228
+ if (depTableMatch) {
229
+ for (const tableBlock of depTableMatch) {
230
+ const docRefs = tableBlock.match(/(?:EPIC|STORY|SPIKE)-\d+(?:-\d+)?/gi) || [];
231
+ for (const ref of docRefs) {
232
+ const refId = ref.toUpperCase();
233
+ if (refId !== docId) {
234
+ edges.push({ from: refId, to: docId, type: 'depends-on' });
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ // Extract "unlocks" references
241
+ const unlocksMatch = content.match(/unlocks?[:\s]+([^\n]+)/gi) || [];
242
+ for (const line of unlocksMatch) {
243
+ const refs = line.match(/(?:EPIC|STORY|SPIKE)-\d+(?:-\d+)?/gi) || [];
244
+ for (const ref of refs) {
245
+ edges.push({ from: docId, to: ref.toUpperCase(), type: 'unlocks' });
246
+ }
247
+ }
248
+ } catch {
249
+ // Content extraction is best-effort
250
+ }
251
+
252
+ return edges;
253
+ }
254
+
255
+ // ── File scanning ────────────────────────────────────────────────
256
+
257
+ /**
258
+ * Recursively find all .md files in a directory.
259
+ * @param {string} dir
260
+ * @returns {string[]}
261
+ */
262
+ function findMarkdownFiles(dir) {
263
+ const results = [];
264
+ if (!fs.existsSync(dir)) return results;
265
+
266
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
267
+ for (const entry of entries) {
268
+ const fullPath = path.join(dir, entry.name);
269
+ if (entry.isDirectory()) {
270
+ results.push(...findMarkdownFiles(fullPath));
271
+ } else if (entry.name.endsWith('.md')) {
272
+ results.push(fullPath);
273
+ }
274
+ }
275
+ return results;
276
+ }
277
+
278
+ // ── Main ─────────────────────────────────────────────────────────
279
+
280
+ function buildGraph() {
281
+ const nodes = {};
282
+ const edges = [];
283
+ const warnings = [];
284
+
285
+ if (!fs.existsSync(PRODUCT_PLANS)) {
286
+ // Graceful: empty graph for missing product_plans/
287
+ const graph = { generated_at: new Date().toISOString(), nodes: {}, edges: [] };
288
+ return graph;
289
+ }
290
+
291
+ // Scan active directories only (not archive/)
292
+ for (const subdir of SCAN_DIRS) {
293
+ const scanPath = path.join(PRODUCT_PLANS, subdir);
294
+ const files = findMarkdownFiles(scanPath);
295
+
296
+ for (const filePath of files) {
297
+ const filename = path.basename(filePath);
298
+ const docType = detectType(filename);
299
+
300
+ if (docType === 'unknown' || docType === 'sprint-report') continue;
301
+
302
+ const { frontmatter, title } = extractFrontmatter(filePath);
303
+ const relPath = path.relative(ROOT, filePath);
304
+
305
+ // Build node even without frontmatter (use filename-derived data)
306
+ const fm = frontmatter || {};
307
+ const docId = deriveId(filename, fm, docType);
308
+
309
+ nodes[docId] = {
310
+ type: docType,
311
+ status: fm.status || null,
312
+ ambiguity: fm.ambiguity || null,
313
+ path: relPath,
314
+ title: title || docId,
315
+ };
316
+
317
+ // Extract edges
318
+ if (frontmatter) {
319
+ const docEdges = extractEdges(docId, fm, docType, filePath);
320
+ edges.push(...docEdges);
321
+ }
322
+ }
323
+ }
324
+
325
+ // Also scan root of product_plans/ for charter files
326
+ const rootFiles = fs.readdirSync(PRODUCT_PLANS, { withFileTypes: true })
327
+ .filter(e => !e.isDirectory() && e.name.endsWith('.md'));
328
+
329
+ for (const entry of rootFiles) {
330
+ const filePath = path.join(PRODUCT_PLANS, entry.name);
331
+ const docType = detectType(entry.name);
332
+ if (docType === 'unknown') continue;
333
+
334
+ const { frontmatter, title } = extractFrontmatter(filePath);
335
+ const relPath = path.relative(ROOT, filePath);
336
+ const fm = frontmatter || {};
337
+ const docId = deriveId(entry.name, fm, docType);
338
+
339
+ if (!nodes[docId]) {
340
+ nodes[docId] = {
341
+ type: docType,
342
+ status: fm.status || null,
343
+ ambiguity: fm.ambiguity || null,
344
+ path: relPath,
345
+ title: title || docId,
346
+ };
347
+
348
+ if (frontmatter) {
349
+ const docEdges = extractEdges(docId, fm, docType, filePath);
350
+ edges.push(...docEdges);
351
+ }
352
+ }
353
+ }
354
+
355
+ // Deduplicate edges
356
+ const edgeSet = new Set();
357
+ const uniqueEdges = edges.filter(e => {
358
+ const key = `${e.from}→${e.to}:${e.type}`;
359
+ if (edgeSet.has(key)) return false;
360
+ edgeSet.add(key);
361
+ return true;
362
+ });
363
+
364
+ return {
365
+ generated_at: new Date().toISOString(),
366
+ node_count: Object.keys(nodes).length,
367
+ edge_count: uniqueEdges.length,
368
+ nodes,
369
+ edges: uniqueEdges,
370
+ };
371
+ }
372
+
373
+ // ── Execute ──────────────────────────────────────────────────────
374
+
375
+ const graph = buildGraph();
376
+
377
+ if (toStdout) {
378
+ console.log(JSON.stringify(graph, null, 2));
379
+ } else {
380
+ // Ensure .bounce/ exists
381
+ const bounceDir = path.join(ROOT, '.bounce');
382
+ fs.mkdirSync(bounceDir, { recursive: true });
383
+
384
+ fs.writeFileSync(OUTPUT_PATH, JSON.stringify(graph, null, 2) + '\n');
385
+ console.log(`✓ Product graph generated: .bounce/product-graph.json`);
386
+ console.log(` Nodes: ${graph.node_count} | Edges: ${graph.edge_count}`);
387
+ }
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * product_impact.mjs
5
+ * Query "what's affected by changing document X?" using BFS traversal
6
+ * of the product graph.
7
+ *
8
+ * Usage:
9
+ * node scripts/product_impact.mjs EPIC-002
10
+ * node scripts/product_impact.mjs EPIC-002 --json
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { fileURLToPath } from 'url';
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = path.resolve(__dirname, '..');
19
+ const GRAPH_PATH = path.join(ROOT, '.bounce', 'product-graph.json');
20
+
21
+ const args = process.argv.slice(2);
22
+ const docId = args.find(a => !a.startsWith('--'));
23
+ const jsonOutput = args.includes('--json');
24
+
25
+ if (!docId) {
26
+ console.error('Usage: product_impact.mjs <DOC-ID> [--json]');
27
+ console.error(' Example: product_impact.mjs EPIC-002');
28
+ console.error(' Run `vbounce graph` first to generate the product graph.');
29
+ process.exit(1);
30
+ }
31
+
32
+ // ── Load graph ───────────────────────────────────────────────────
33
+
34
+ if (!fs.existsSync(GRAPH_PATH)) {
35
+ console.error('ERROR: .bounce/product-graph.json not found.');
36
+ console.error('Run `vbounce graph` first to generate the product graph.');
37
+ process.exit(1);
38
+ }
39
+
40
+ const graph = JSON.parse(fs.readFileSync(GRAPH_PATH, 'utf8'));
41
+ const targetId = docId.toUpperCase();
42
+
43
+ if (!graph.nodes[targetId]) {
44
+ console.error(`ERROR: Document "${targetId}" not found in the product graph.`);
45
+ console.error(`Available documents: ${Object.keys(graph.nodes).join(', ')}`);
46
+ process.exit(1);
47
+ }
48
+
49
+ // ── Build adjacency lists ────────────────────────────────────────
50
+
51
+ // Downstream: edges where targetId is the source (from)
52
+ // "What does this document feed into?"
53
+ const downstream = new Map(); // from → [{to, type}]
54
+ const upstream = new Map(); // to → [{from, type}]
55
+
56
+ for (const edge of graph.edges) {
57
+ if (!downstream.has(edge.from)) downstream.set(edge.from, []);
58
+ downstream.get(edge.from).push({ to: edge.to, type: edge.type });
59
+
60
+ if (!upstream.has(edge.to)) upstream.set(edge.to, []);
61
+ upstream.get(edge.to).push({ from: edge.from, type: edge.type });
62
+ }
63
+
64
+ // ── BFS: direct + transitive dependents ──────────────────────────
65
+
66
+ /**
67
+ * BFS traversal from a node following outgoing edges.
68
+ * @param {string} startId
69
+ * @param {Map} adjList - adjacency list (from → targets)
70
+ * @param {'downstream'|'upstream'} direction
71
+ * @returns {{ direct: Array, transitive: Array }}
72
+ */
73
+ function bfs(startId, adjList, direction) {
74
+ const visited = new Set([startId]);
75
+ const direct = [];
76
+ const transitive = [];
77
+ const queue = [{ id: startId, depth: 0 }];
78
+
79
+ while (queue.length > 0) {
80
+ const { id, depth } = queue.shift();
81
+ const neighbors = adjList.get(id) || [];
82
+
83
+ for (const neighbor of neighbors) {
84
+ const neighborId = direction === 'downstream' ? neighbor.to : neighbor.from;
85
+
86
+ if (visited.has(neighborId)) continue; // Cycle protection
87
+ visited.add(neighborId);
88
+
89
+ const entry = {
90
+ id: neighborId,
91
+ type: neighbor.type,
92
+ via: id,
93
+ node: graph.nodes[neighborId] || null,
94
+ };
95
+
96
+ if (depth === 0) {
97
+ direct.push(entry);
98
+ } else {
99
+ transitive.push(entry);
100
+ }
101
+
102
+ queue.push({ id: neighborId, depth: depth + 1 });
103
+ }
104
+ }
105
+
106
+ return { direct, transitive };
107
+ }
108
+
109
+ // ── Run analysis ─────────────────────────────────────────────────
110
+
111
+ const dependents = bfs(targetId, downstream, 'downstream');
112
+ const feeders = bfs(targetId, upstream, 'upstream');
113
+
114
+ const result = {
115
+ document: targetId,
116
+ document_info: graph.nodes[targetId],
117
+ direct_dependents: dependents.direct,
118
+ transitive_dependents: dependents.transitive,
119
+ upstream_feeders: feeders.direct.concat(feeders.transitive),
120
+ graph_generated_at: graph.generated_at,
121
+ };
122
+
123
+ // ── Output ───────────────────────────────────────────────────────
124
+
125
+ if (jsonOutput) {
126
+ console.log(JSON.stringify(result, null, 2));
127
+ } else {
128
+ const node = graph.nodes[targetId];
129
+ console.log(`\n📊 Impact Analysis: ${targetId}`);
130
+ console.log(` ${node.title}`);
131
+ console.log(` Status: ${node.status || 'N/A'} | Type: ${node.type}`);
132
+ console.log(` Path: ${node.path}`);
133
+
134
+ if (dependents.direct.length > 0) {
135
+ console.log(`\n🔽 Direct Dependents (${dependents.direct.length}):`);
136
+ for (const dep of dependents.direct) {
137
+ const label = dep.node ? dep.node.title : dep.id;
138
+ const status = dep.node?.status ? ` [${dep.node.status}]` : '';
139
+ console.log(` ${dep.type}: ${label}${status}`);
140
+ }
141
+ } else {
142
+ console.log('\n🔽 Direct Dependents: none');
143
+ }
144
+
145
+ if (dependents.transitive.length > 0) {
146
+ console.log(`\n🔽 Transitive Dependents (${dependents.transitive.length}):`);
147
+ for (const dep of dependents.transitive) {
148
+ const label = dep.node ? dep.node.title : dep.id;
149
+ const status = dep.node?.status ? ` [${dep.node.status}]` : '';
150
+ console.log(` ${dep.type}: ${label}${status} (via ${dep.via})`);
151
+ }
152
+ }
153
+
154
+ if (feeders.direct.length > 0 || feeders.transitive.length > 0) {
155
+ const allFeeders = [...feeders.direct, ...feeders.transitive];
156
+ console.log(`\n🔼 Upstream Feeders (${allFeeders.length}):`);
157
+ for (const f of allFeeders) {
158
+ const label = f.node ? f.node.title : f.id;
159
+ console.log(` ${f.type}: ${label}`);
160
+ }
161
+ } else {
162
+ console.log('\n🔼 Upstream Feeders: none');
163
+ }
164
+
165
+ console.log(`\nGraph generated: ${graph.generated_at}`);
166
+ console.log('');
167
+ }