const fs = require('fs'); const path = require('path'); const GraphStore = require('./graph.js'); /** * Phase 7A: Subsystem Aggregator * Groups files into subsystems and computes cross-subsystem dependency matrix. */ /** Normalize absolute paths to relative (strips up to /src/ or configurable srcDir) */ function relPath(file, srcMarker) { if (!file) return ''; srcMarker = srcMarker || '/src/'; if (file.startsWith('/')) { const idx = file.indexOf(srcMarker); if (idx !== -1) return file.substring(idx + srcMarker.length); return file.split('/').slice(-2).join('/'); } return file; } /** Get subsystem name from a relative file path */ function subsystemOf(relFile) { const parts = relFile.split('/'); return parts.length > 1 ? parts[0] : 'root'; } function buildSubsystems(graph, opts = {}) { const srcMarker = opts.srcDir || '/src/'; const crossCuttingMinTraffic = opts.minTraffic || 10; const crossCuttingThreshold = opts.crossCuttingThreshold || 0.75; const subsystems = new Map(); // Build function name → qualified node ID lookup const funcLookup = new Map(); for (const [id, node] of graph.nodes) { if (node.type === 'Function' || node.type === 'Class') { if (!funcLookup.has(node.name)) funcLookup.set(node.name, []); funcLookup.get(node.name).push(id); } } // 1. Directory-based clustering (using relPath for DRY) for (const file of graph.fileIndex.keys()) { const rel = relPath(file, srcMarker); if (rel.startsWith('dep:')) continue; const subName = subsystemOf(rel); if (!subsystems.has(subName)) { subsystems.set(subName, { name: subName, kind: 'domain', files: new Set(), entities: { functions: 0, classes: 0, modules: 0 }, publicExports: new Set(), }); } const sub = subsystems.get(subName); sub.files.add(rel); const ids = graph.fileIndex.get(file); if (ids) { for (const id of ids) { const node = graph.nodes.get(id); if (!node) continue; if (node.type === 'Module') sub.entities.modules++; else if (node.type === 'Function') { sub.entities.functions++; if (node.visibility === 'public') sub.publicExports.add(node.name); } else if (node.type === 'Class') { sub.entities.classes++; if (node.visibility === 'public') sub.publicExports.add(node.name); } } } } // 2. Compute inter-subsystem edges const matrix = {}; const inboundCross = {}; const outboundCross = {}; for (const name of subsystems.keys()) { inboundCross[name] = 0; outboundCross[name] = 0; } // Cache: nodeId → subsystem name const nodeSubCache = new Map(); function resolveSubsystem(nodeId) { if (!nodeId) return null; if (nodeSubCache.has(nodeId)) return nodeSubCache.get(nodeId); const rel = relPath(nodeId.split(':')[0], srcMarker); const sub = subsystemOf(rel); const result = subsystems.has(sub) ? sub : null; nodeSubCache.set(nodeId, result); return result; } function resolveCallTarget(callerSub, targetName) { const candidates = funcLookup.get(targetName); if (!candidates || candidates.length === 0) return null; if (candidates.length === 1) return resolveSubsystem(candidates[0]); let sameSub = null; let diffSub = null; for (const cid of candidates) { const s = resolveSubsystem(cid); if (s === callerSub) sameSub = s; else if (s) diffSub = s; } return sameSub || diffSub; } for (const e of graph.edges) { if (e.type === 'CONTAINS') continue; let srcSub = resolveSubsystem(e.source); if (!srcSub) continue; let tgtSub = null; if (e.type === 'IMPORTS') { const dep = e.target.replace('dep:', ''); tgtSub = subsystemOf(dep); } else if (e.type === 'CALLS') { if (e.target.includes('/')) tgtSub = resolveSubsystem(e.target); else tgtSub = resolveCallTarget(srcSub, e.target); } else if (e.type === 'IMPLEMENTS') { if (e.target.includes('/')) tgtSub = resolveSubsystem(e.target); } if (!tgtSub || !subsystems.has(tgtSub)) continue; if (srcSub === tgtSub) continue; const key = `${srcSub}→${tgtSub}`; if (!matrix[key]) matrix[key] = { calls: 0, imports: 0, via: new Set() }; if (e.type === 'CALLS') matrix[key].calls++; else if (e.type === 'IMPORTS') matrix[key].imports++; if (matrix[key].via.size < 5) { matrix[key].via.add(`${e.source}→${e.target}`); } inboundCross[tgtSub]++; outboundCross[srcSub]++; } // 3. Cross-cutting detection (high fan-in ratio + minimum traffic) const crossCutting = []; for (const name of subsystems.keys()) { const totalCross = inboundCross[name] + outboundCross[name]; if (totalCross >= crossCuttingMinTraffic) { const inboundRatio = inboundCross[name] / totalCross; if (inboundRatio > crossCuttingThreshold) { subsystems.get(name).kind = 'cross-cutting'; crossCutting.push(name); } } } // Format output const result = { subsystems: [], crossCutting, dependencyMatrix: {} }; for (const sub of subsystems.values()) { result.subsystems.push({ name: sub.name, kind: sub.kind, files: Array.from(sub.files).sort(), entities: sub.entities, publicExports: Array.from(sub.publicExports).sort() }); } for (const [key, val] of Object.entries(matrix)) { result.dependencyMatrix[key] = { calls: val.calls, imports: val.imports, via: Array.from(val.via) }; } return result; } if (require.main === module) { const snapshotPath = process.argv[2]; if (!snapshotPath) { console.error('Usage: node subsystem.js '); process.exit(1); } const graph = GraphStore.loadSnapshot(snapshotPath); const result = buildSubsystems(graph); console.log(JSON.stringify(result, null, 2)); } module.exports = { buildSubsystems, relPath, subsystemOf };