Phase 7A+7C: Subsystem aggregator + Flow tracer (post-review fixes)
This commit is contained in:
205
subsystem.js
Normal file
205
subsystem.js
Normal file
@@ -0,0 +1,205 @@
|
||||
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 <snapshot.json>');
|
||||
process.exit(1);
|
||||
}
|
||||
const graph = GraphStore.loadSnapshot(snapshotPath);
|
||||
const result = buildSubsystems(graph);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
module.exports = { buildSubsystems, relPath, subsystemOf };
|
||||
Reference in New Issue
Block a user