151 lines
4.4 KiB
JavaScript
151 lines
4.4 KiB
JavaScript
|
|
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 <snapshot.json> <targetNodeId> [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 };
|