const GraphStore = require('./graph.js'); /** * Phase 9: Change Impact Query * Given a node ID, traverse graph edges to find all affected downstream entities. * Uses reverse BFS on DEPENDS_ON, IMPORTS, CALLS, and CONTAINS edges. */ /** * Query the blast radius of changing a given node. * @param {GraphStore} graph - The populated knowledge graph. * @param {string} targetNodeId - The node being modified. * @param {number} maxDepth - Max traversal depth (default 10). * @returns {Object} { target, impacted: [...], tree: {...}, depth, circular: [...] } */ function queryImpact(graph, targetNodeId, maxDepth = 10) { const impacted = []; const visited = new Set(); const circular = []; const tree = { id: targetNodeId, depth: 0, children: [] }; const treeMap = new Map(); // nodeId → tree node for parent linking visited.add(targetNodeId); treeMap.set(targetNodeId, tree); // Build reverse adjacency: target → [sources] for relevant edge types const reverseAdj = new Map(); const IMPACT_EDGE_TYPES = new Set(['DEPENDS_ON', 'IMPORTS', 'CALLS', 'CONTAINS', 'USES']); for (const edge of graph.edges) { if (!IMPACT_EDGE_TYPES.has(edge.type)) continue; if (!reverseAdj.has(edge.target)) reverseAdj.set(edge.target, []); reverseAdj.get(edge.target).push({ source: edge.source, type: edge.type }); } // BFS const queue = [{ id: targetNodeId, depth: 0 }]; let head = 0; while (head < queue.length) { const { id: currentId, depth } = queue[head++]; if (depth >= maxDepth) continue; const dependents = reverseAdj.get(currentId) || []; for (const { source, type } of dependents) { if (visited.has(source)) { circular.push({ from: source, to: currentId, type }); continue; } visited.add(source); const node = graph.nodes.get(source); const entry = { id: source, name: node?.name || source, kind: node?.kind || node?.type || 'unknown', depth: depth + 1, via: type, from: currentId }; impacted.push(entry); // Build tree const treeNode = { id: source, depth: depth + 1, via: type, children: [] }; treeMap.set(source, treeNode); const parentTree = treeMap.get(currentId); if (parentTree) parentTree.children.push(treeNode); queue.push({ id: source, depth: depth + 1 }); } } return { target: targetNodeId, targetNode: graph.nodes.get(targetNodeId) || null, impacted, impactedCount: impacted.length, maxDepthReached: impacted.some(i => i.depth >= maxDepth), circular, tree }; } /** * Format impact result as markdown. */ function formatImpactMarkdown(result) { const lines = []; lines.push(`## Change Impact: \`${result.target}\``); lines.push(''); if (result.targetNode) { lines.push(`**Kind:** ${result.targetNode.kind || result.targetNode.type}`); lines.push(''); } lines.push(`**Total impacted:** ${result.impactedCount} entities`); lines.push(''); if (result.impacted.length === 0) { lines.push('No downstream dependents found.'); return lines.join('\n'); } // Group by depth const byDepth = new Map(); for (const item of result.impacted) { if (!byDepth.has(item.depth)) byDepth.set(item.depth, []); byDepth.get(item.depth).push(item); } for (const [depth, items] of [...byDepth.entries()].sort((a, b) => a[0] - b[0])) { lines.push(`### Depth ${depth}`); for (const item of items) { lines.push(`- \`${item.id}\` (${item.kind}) via ${item.via}`); } lines.push(''); } if (result.circular.length > 0) { lines.push('### Circular References'); for (const c of result.circular) { lines.push(`- ${c.from} ↔ ${c.to} (${c.type})`); } } return lines.join('\n'); } // CLI if (require.main === module) { const snapshotPath = process.argv[2]; const targetId = process.argv[3]; const maxDepth = parseInt(process.argv[4]) || 10; const format = process.argv[5] || 'json'; if (!snapshotPath || !targetId) { console.error('Usage: node impact.js [maxDepth] [json|md]'); process.exit(1); } const graph = GraphStore.loadSnapshot(snapshotPath); const result = queryImpact(graph, targetId, maxDepth); if (format === 'md') { console.log(formatImpactMarkdown(result)); } else { console.log(JSON.stringify(result, null, 2)); } } module.exports = { queryImpact, formatImpactMarkdown };