const fs = require('fs'); const path = require('path'); /** * Developer Intelligence Pipeline v2 - Graph Store * In-memory directed graph using a simple adjacency list. * No external dependencies. */ class GraphStore { constructor() { this.nodes = new Map(); // entityId -> entity object this.edges = []; // Array of {type, source, target} this._edgeSet = new Set(); // For O(1) dedup this.fileIndex = new Map(); // filePath -> Set of entityIds } /** * Builds the graph from an array of extract.js result objects. * @param {Array} extractResults * @returns {GraphStore} */ static buildGraph(extractResults) { const graph = new GraphStore(); for (const result of extractResults) { const filePath = result.file; if (!filePath) continue; if (!graph.fileIndex.has(filePath)) { graph.fileIndex.set(filePath, new Set()); } const fileEntities = graph.fileIndex.get(filePath); // Add nodes if (Array.isArray(result.entities)) { for (const entity of result.entities) { graph.nodes.set(entity.id, { ...entity, _file: filePath }); fileEntities.add(entity.id); } } // Add edges (deduplicated via Set) if (Array.isArray(result.relationships)) { for (const rel of result.relationships) { const key = `${rel.type}:${rel.source}->${rel.target}`; if (!graph._edgeSet.has(key)) { graph._edgeSet.add(key); graph.edges.push({ type: rel.type, source: rel.source, target: rel.target }); } } } } return graph; } /** * Serializes the graph to a JSON file. * @param {GraphStore} graph * @param {string} outputPath */ static saveSnapshot(graph, outputPath) { const serialized = { nodes: Object.fromEntries(graph.nodes), edges: graph.edges, fileIndex: Object.fromEntries( Array.from(graph.fileIndex.entries()).map(([k, v]) => [k, Array.from(v)]) ) }; fs.writeFileSync(outputPath, JSON.stringify(serialized, null, 2), 'utf8'); } /** * Deserializes a JSON file to a GraphStore. * @param {string} inputPath * @returns {GraphStore} */ static loadSnapshot(inputPath) { const data = JSON.parse(fs.readFileSync(inputPath, 'utf8')); const graph = new GraphStore(); for (const [id, entity] of Object.entries(data.nodes || {})) { graph.nodes.set(id, entity); } graph.edges = data.edges || []; for (const [filePath, entityIds] of Object.entries(data.fileIndex || {})) { graph.fileIndex.set(filePath, new Set(entityIds)); } return graph; } /** * Returns an entity and all its incoming/outgoing edges. * @param {GraphStore} graph * @param {string} entityId * @returns {Object} */ static query(graph, entityId) { const entity = graph.nodes.get(entityId); if (!entity) return null; const incoming = graph.edges.filter(e => e.target === entityId); const outgoing = graph.edges.filter(e => e.source === entityId); return { entity, incoming, outgoing }; } /** * Returns all entities that CALL this function. * @param {GraphStore} graph * @param {string} functionName (entityId) * @returns {Array} */ static findCallers(graph, functionName) { return graph.edges .filter(e => e.type === 'CALLS' && e.target === functionName) .map(e => graph.nodes.get(e.source)) .filter(Boolean); } /** * Returns all modules that IMPORT this module. * @param {GraphStore} graph * @param {string} moduleId (entityId) * @returns {Array} */ static findDependents(graph, moduleId) { return graph.edges .filter(e => (e.type === 'IMPORTS' || e.type === 'DEPENDS_ON') && e.target === moduleId) .map(e => graph.nodes.get(e.source)) .filter(Boolean); } /** * Returns all public entities in a file. * @param {GraphStore} graph * @param {string} filePath * @returns {Array} */ static getExports(graph, filePath) { const entityIds = graph.fileIndex.get(filePath); if (!entityIds) return []; return Array.from(entityIds) .map(id => graph.nodes.get(id)) .filter(entity => entity && entity.visibility === 'public'); } /** * Returns added/removed/modified entities and relationships between two snapshots. * @param {GraphStore} oldGraph * @param {GraphStore} newGraph * @returns {Object} */ static diffSnapshots(oldGraph, newGraph) { const diff = { entities: { added: [], removed: [], modified: [] }, relationships: { added: [], removed: [] } }; // Diff Entities for (const [id, oldEntity] of oldGraph.nodes.entries()) { if (!newGraph.nodes.has(id)) { diff.entities.removed.push(oldEntity); } } for (const [id, newEntity] of newGraph.nodes.entries()) { const oldEntity = oldGraph.nodes.get(id); if (!oldEntity) { diff.entities.added.push(newEntity); } else { // Deterministic deep comparison: sort keys, compare canonical JSON const canonicalize = (obj) => JSON.stringify(obj, Object.keys(obj).filter(k => k !== '_file').sort()); if (canonicalize(oldEntity) !== canonicalize(newEntity)) { diff.entities.modified.push({ old: oldEntity, new: newEntity }); } } } // Diff Relationships const edgeToString = (e) => `${e.type}:${e.source}->${e.target}`; const oldEdges = new Set(oldGraph.edges.map(edgeToString)); const newEdges = new Set(newGraph.edges.map(edgeToString)); for (const e of newGraph.edges) { if (!oldEdges.has(edgeToString(e))) diff.relationships.added.push(e); } for (const e of oldGraph.edges) { if (!newEdges.has(edgeToString(e))) diff.relationships.removed.push(e); } return diff; } } // CLI handling if (require.main === module) { const args = process.argv.slice(2); const command = args[0]; if (command === 'build') { const inputDir = args[1]; const outputPath = args[2]; if (!inputDir || !outputPath) { console.error('Usage: node graph.js build '); process.exit(1); } const files = fs.readdirSync(inputDir).filter(f => f.endsWith('.json')); const extractResults = files.map(f => { const content = fs.readFileSync(path.join(inputDir, f), 'utf8'); try { return JSON.parse(content); } catch (e) { console.error(`Error parsing ${f}:`, e.message); return null; } }).filter(Boolean); const graph = GraphStore.buildGraph(extractResults); GraphStore.saveSnapshot(graph, outputPath); console.log(`Built graph with ${graph.nodes.size} nodes and ${graph.edges.length} edges. Saved to ${outputPath}`); } else if (command === 'query') { const snapshotPath = args[1]; const entityId = args[2]; if (!snapshotPath || !entityId) { console.error('Usage: node graph.js query '); process.exit(1); } const graph = GraphStore.loadSnapshot(snapshotPath); const result = GraphStore.query(graph, entityId); if (!result) { console.log(`Entity ${entityId} not found.`); } else { console.log(JSON.stringify(result, null, 2)); } } else if (command === 'diff') { const oldSnapshotPath = args[1]; const newSnapshotPath = args[2]; if (!oldSnapshotPath || !newSnapshotPath) { console.error('Usage: node graph.js diff '); process.exit(1); } const oldGraph = GraphStore.loadSnapshot(oldSnapshotPath); const newGraph = GraphStore.loadSnapshot(newSnapshotPath); const diff = GraphStore.diffSnapshots(oldGraph, newGraph); console.log(JSON.stringify(diff, null, 2)); } else { console.error('Unknown command. Available commands: build, query, diff'); process.exit(1); } } module.exports = GraphStore;