@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.
- package/README.md +303 -19
- package/bin/vbounce.mjs +44 -0
- package/brains/AGENTS.md +51 -120
- package/brains/CHANGELOG.md +135 -0
- package/brains/CLAUDE.md +58 -133
- package/brains/GEMINI.md +68 -149
- package/brains/claude-agents/developer.md +6 -4
- package/brains/copilot/copilot-instructions.md +5 -0
- package/brains/cursor-rules/vbounce-process.mdc +3 -0
- package/brains/windsurf/.windsurfrules +5 -0
- package/package.json +1 -1
- package/scripts/close_sprint.mjs +41 -1
- package/scripts/complete_story.mjs +8 -0
- package/scripts/init_sprint.mjs +8 -0
- package/scripts/post_sprint_improve.mjs +486 -0
- package/scripts/product_graph.mjs +387 -0
- package/scripts/product_impact.mjs +167 -0
- package/scripts/suggest_improvements.mjs +206 -43
- package/skills/agent-team/SKILL.md +63 -28
- package/skills/agent-team/references/discovery.md +97 -0
- package/skills/agent-team/references/mid-sprint-triage.md +40 -26
- package/skills/doc-manager/SKILL.md +172 -19
- package/skills/improve/SKILL.md +151 -60
- package/skills/lesson/SKILL.md +14 -0
- package/skills/product-graph/SKILL.md +102 -0
- package/templates/bug.md +90 -0
- package/templates/change_request.md +105 -0
- package/templates/epic.md +19 -16
- package/templates/spike.md +143 -0
- package/templates/sprint.md +51 -17
- package/templates/sprint_report.md +6 -4
- package/templates/story.md +23 -8
|
@@ -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
|
+
}
|