206 lines
5.9 KiB
JavaScript
206 lines
5.9 KiB
JavaScript
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 };
|