Phase 7A+7C: Subsystem aggregator + Flow tracer (post-review fixes)
This commit is contained in:
208
flow.js
Normal file
208
flow.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const GraphStore = require('./graph.js');
|
||||
const { buildSubsystems, relPath, subsystemOf } = require('./subsystem.js');
|
||||
|
||||
/**
|
||||
* Phase 7C: Flow Tracer
|
||||
* Walks the call graph across subsystem boundaries to produce sequenced data flow narratives.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build reusable indexes from a graph + subsystem map.
|
||||
* Call once, then pass to traceFlow for each entry point.
|
||||
*/
|
||||
function buildFlowIndex(graph, subsystemMap, opts = {}) {
|
||||
const godThreshold = opts.godThreshold || 50;
|
||||
|
||||
// File → subsystem lookup
|
||||
const fileSub = new Map();
|
||||
for (const sub of subsystemMap.subsystems) {
|
||||
for (const f of sub.files) fileSub.set(f, sub.name);
|
||||
}
|
||||
|
||||
// Outgoing CALLS index: source → [targets]
|
||||
const callsOut = new Map();
|
||||
for (const e of graph.edges) {
|
||||
if (e.type !== 'CALLS') continue;
|
||||
if (!callsOut.has(e.source)) callsOut.set(e.source, []);
|
||||
callsOut.get(e.source).push(e.target);
|
||||
}
|
||||
|
||||
// Function name → qualified IDs
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// In-degree per qualified ID
|
||||
const inDegree = new Map();
|
||||
for (const e of graph.edges) {
|
||||
if (e.type !== 'CALLS') continue;
|
||||
if (e.target.includes('/')) {
|
||||
inDegree.set(e.target, (inDegree.get(e.target) || 0) + 1);
|
||||
} else {
|
||||
// For bare names, increment all candidates
|
||||
const candidates = funcLookup.get(e.target);
|
||||
if (candidates) {
|
||||
for (const c of candidates) {
|
||||
inDegree.set(c, (inDegree.get(c) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// God objects: qualified IDs with in-degree > threshold
|
||||
const godObjects = new Set();
|
||||
for (const [id, deg] of inDegree) {
|
||||
if (deg > godThreshold) godObjects.add(id);
|
||||
}
|
||||
|
||||
// Subsystem cache
|
||||
const subCache = new Map();
|
||||
function getSubsystem(entityId) {
|
||||
if (!entityId) return null;
|
||||
if (subCache.has(entityId)) return subCache.get(entityId);
|
||||
const file = relPath(entityId.split(':')[0]);
|
||||
const result = fileSub.get(file) || null;
|
||||
subCache.set(entityId, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Resolve bare function name → qualified ID, preferring caller's subsystem
|
||||
function resolveTarget(bareName, callerSub) {
|
||||
const candidates = funcLookup.get(bareName);
|
||||
if (!candidates || candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
for (const c of candidates) {
|
||||
if (getSubsystem(c) === callerSub) return c;
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
return { fileSub, callsOut, funcLookup, godObjects, getSubsystem, resolveTarget };
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace a flow from an entry point through the call graph.
|
||||
* @param {string} entryPoint - Entity ID (e.g. "channels/telegram.ts:onTelegramMessage")
|
||||
* @param {object} index - Precomputed index from buildFlowIndex
|
||||
* @param {object} opts - Options: { maxDepth, timeoutMs }
|
||||
*/
|
||||
function traceFlow(entryPoint, index, opts = {}) {
|
||||
const maxDepth = opts.maxDepth || 8;
|
||||
const timeout = opts.timeoutMs || 5000;
|
||||
const startTime = Date.now();
|
||||
|
||||
const { callsOut, godObjects, getSubsystem, resolveTarget } = index;
|
||||
|
||||
const visited = new Set();
|
||||
const flow = [];
|
||||
const cyclesDetected = [];
|
||||
const excludedNodes = new Set();
|
||||
|
||||
const entrySub = getSubsystem(entryPoint);
|
||||
if (!entrySub) {
|
||||
return { entryPoint, error: `Entry point "${entryPoint}" not found in any subsystem`, flow: [], subsystemSequence: [], cyclesDetected: [], excludedNodes: [] };
|
||||
}
|
||||
|
||||
// BFS with index pointer (no shift)
|
||||
const queue = [[entryPoint, 0]];
|
||||
let head = 0;
|
||||
visited.add(entryPoint);
|
||||
flow.push({ subsystem: entrySub, entity: entryPoint, depth: 0 });
|
||||
|
||||
const subsystemSequence = [entrySub];
|
||||
const seqSet = new Set([entrySub]);
|
||||
|
||||
while (head < queue.length) {
|
||||
if (Date.now() - startTime > timeout) break;
|
||||
|
||||
const [current, depth] = queue[head++];
|
||||
const currentSub = getSubsystem(current);
|
||||
|
||||
const targets = callsOut.get(current) || [];
|
||||
for (const rawTarget of targets) {
|
||||
// Skip test files
|
||||
if (rawTarget.includes('.test.') || rawTarget.includes('.spec.') || rawTarget.includes('__tests__/')) continue;
|
||||
|
||||
let resolvedTarget = rawTarget;
|
||||
let targetSub = null;
|
||||
|
||||
if (rawTarget.includes('/')) {
|
||||
// Qualified target — check god object by qualified ID
|
||||
if (godObjects.has(rawTarget)) {
|
||||
excludedNodes.add(rawTarget);
|
||||
continue;
|
||||
}
|
||||
targetSub = getSubsystem(rawTarget);
|
||||
} else {
|
||||
// Bare name — resolve to qualified ID first, then check god status
|
||||
resolvedTarget = resolveTarget(rawTarget, currentSub);
|
||||
if (!resolvedTarget) continue;
|
||||
if (godObjects.has(resolvedTarget)) {
|
||||
excludedNodes.add(resolvedTarget);
|
||||
continue;
|
||||
}
|
||||
targetSub = getSubsystem(resolvedTarget);
|
||||
}
|
||||
|
||||
if (!targetSub) continue;
|
||||
|
||||
// Cycle detection
|
||||
if (visited.has(resolvedTarget)) {
|
||||
cyclesDetected.push({ at: current, backEdgeTo: resolvedTarget });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute new depth
|
||||
const isCrossSubsystem = targetSub !== currentSub;
|
||||
const newDepth = depth + (isCrossSubsystem ? 1 : 0.5);
|
||||
|
||||
if (newDepth > maxDepth) continue;
|
||||
|
||||
visited.add(resolvedTarget);
|
||||
flow.push({
|
||||
subsystem: targetSub,
|
||||
entity: resolvedTarget,
|
||||
depth: newDepth,
|
||||
...(isCrossSubsystem ? { crossedVia: 'CALLS' } : {})
|
||||
});
|
||||
|
||||
if (isCrossSubsystem && !seqSet.has(targetSub)) {
|
||||
subsystemSequence.push(targetSub);
|
||||
seqSet.add(targetSub);
|
||||
}
|
||||
|
||||
queue.push([resolvedTarget, newDepth]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entryPoint,
|
||||
depth: maxDepth,
|
||||
godThreshold: index.godObjects.size > 0 ? 'applied' : 'none',
|
||||
excludedNodes: Array.from(excludedNodes),
|
||||
cyclesDetected,
|
||||
flow,
|
||||
subsystemSequence
|
||||
};
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const snapshotPath = process.argv[2];
|
||||
const entryPoint = process.argv[3];
|
||||
const godThreshold = parseInt(process.argv[4]) || 50;
|
||||
if (!snapshotPath || !entryPoint) {
|
||||
console.error('Usage: node flow.js <snapshot.json> <entryPoint> [godThreshold]');
|
||||
process.exit(1);
|
||||
}
|
||||
const graph = GraphStore.loadSnapshot(snapshotPath);
|
||||
const subsystemMap = buildSubsystems(graph);
|
||||
const index = buildFlowIndex(graph, subsystemMap, { godThreshold });
|
||||
const result = traceFlow(entryPoint, index);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
module.exports = { buildFlowIndex, traceFlow };
|
||||
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 };
|
||||
17
test/fixtures/system-docs/architecture.md
vendored
Normal file
17
test/fixtures/system-docs/architecture.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Mock Architecture Documentation
|
||||
|
||||
## Purpose
|
||||
This is a test fixture simulating a real repo's architecture.md file.
|
||||
|
||||
## Layered Architecture
|
||||
The system follows a layered architecture:
|
||||
- **Channels** receive inbound messages from external platforms
|
||||
- **Gateway** manages sessions and routes requests
|
||||
- **Agents** execute AI model interactions
|
||||
- **Config** provides centralized configuration
|
||||
- **Utils** provides shared infrastructure (logging, crypto, filesystem)
|
||||
|
||||
## Design Decisions
|
||||
- Gateway and Agents have a deliberate circular dependency for session refresh workflows
|
||||
- Config is loaded lazily and cached in memory
|
||||
- Utils are stateless pure functions with no domain logic
|
||||
104
test/fixtures/system-docs/expected-contracts.json
vendored
Normal file
104
test/fixtures/system-docs/expected-contracts.json
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"contracts": [
|
||||
{
|
||||
"id": "gateway/types.ts:GatewayConfig",
|
||||
"type": "Interface",
|
||||
"name": "GatewayConfig",
|
||||
"extends": ["BaseConfig"],
|
||||
"fields": [
|
||||
{ "name": "sessionKey", "type": "string" },
|
||||
{ "name": "timeout", "type": "number" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "gateway/types.ts:SessionEntry",
|
||||
"type": "Interface",
|
||||
"name": "SessionEntry",
|
||||
"fields": [
|
||||
{ "name": "key", "type": "string" },
|
||||
{ "name": "agentId", "type": "string" },
|
||||
{ "name": "model", "type": "string" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agents/scope.ts:AgentScope",
|
||||
"type": "Interface",
|
||||
"name": "AgentScope",
|
||||
"fields": [
|
||||
{ "name": "agentId", "type": "string" },
|
||||
{ "name": "tools", "type": "string[]" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agents/types.ts:AgentConfig",
|
||||
"type": "Interface",
|
||||
"name": "AgentConfig",
|
||||
"fields": [
|
||||
{ "name": "agentId", "type": "string" },
|
||||
{ "name": "model", "type": "string" },
|
||||
{ "name": "maxTokens", "type": "number" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agents/types.ts:AgentResult",
|
||||
"type": "Interface",
|
||||
"name": "AgentResult",
|
||||
"fields": [
|
||||
{ "name": "output", "type": "string" },
|
||||
{ "name": "tokensUsed", "type": "number" },
|
||||
{ "name": "duration", "type": "number" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agents/types.ts:AgentStatus",
|
||||
"type": "TypeAlias",
|
||||
"name": "AgentStatus"
|
||||
},
|
||||
{
|
||||
"id": "config/types.ts:BaseConfig",
|
||||
"type": "Interface",
|
||||
"name": "BaseConfig",
|
||||
"fields": [
|
||||
{ "name": "defaultAgent", "type": "string" },
|
||||
{ "name": "model", "type": "string" },
|
||||
{ "name": "debug", "type": "boolean" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "config/types.ts:ProviderConfig",
|
||||
"type": "Interface",
|
||||
"name": "ProviderConfig",
|
||||
"fields": [
|
||||
{ "name": "name", "type": "string" },
|
||||
{ "name": "apiKey", "type": "string" },
|
||||
{ "name": "baseUrl", "type": "string" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "config/types.ts:LogLevel",
|
||||
"type": "Enum",
|
||||
"name": "LogLevel",
|
||||
"members": ["Debug", "Info", "Warn", "Error"]
|
||||
},
|
||||
{
|
||||
"id": "config/schema.ts:SchemaDefinition",
|
||||
"type": "Interface",
|
||||
"name": "SchemaDefinition",
|
||||
"fields": [
|
||||
{ "name": "name", "type": "string" },
|
||||
{ "name": "version", "type": "string" },
|
||||
{ "name": "fields", "type": "SchemaField[]" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "config/schema.ts:SchemaField",
|
||||
"type": "Interface",
|
||||
"name": "SchemaField",
|
||||
"fields": [
|
||||
{ "name": "name", "type": "string" },
|
||||
{ "name": "type", "type": "string" },
|
||||
{ "name": "required", "type": "boolean" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
17
test/fixtures/system-docs/expected-deps.json
vendored
Normal file
17
test/fixtures/system-docs/expected-deps.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"edges": [
|
||||
{ "from": "gateway", "to": "agents", "type": "CALLS", "via": ["gateway/session.ts:refreshSession→agents/runner.ts:runAgent"] },
|
||||
{ "from": "gateway", "to": "config", "type": "IMPORTS", "via": ["gateway/session.ts→config/config.ts", "gateway/types.ts→config/types.ts"] },
|
||||
{ "from": "gateway", "to": "utils", "type": "CALLS", "via": ["gateway/middleware.ts:applyMiddleware→utils/logger.ts:log", "gateway/utils.ts:formatSessionKey→utils/logger.ts:log"] },
|
||||
{ "from": "agents", "to": "gateway", "type": "CALLS", "via": ["agents/runner.ts:reloadAgent→gateway/session.ts:loadSession"] },
|
||||
{ "from": "agents", "to": "config", "type": "IMPORTS", "via": ["agents/scope.ts→config/config.ts"] },
|
||||
{ "from": "agents", "to": "utils", "type": "CALLS", "via": ["agents/runner.ts:runAgent→utils/logger.ts:log", "agents/scope.ts:createAgentScope→utils/logger.ts:log", "agents/tools.ts→utils/logger.ts:log"] },
|
||||
{ "from": "channels", "to": "gateway", "type": "CALLS", "via": ["channels/telegram.ts:onTelegramMessage→gateway/server.ts:handleRequest", "channels/discord.ts:onDiscordMessage→gateway/server.ts:handleRequest"] },
|
||||
{ "from": "channels", "to": "utils", "type": "CALLS", "via": ["channels/telegram.ts→utils/logger.ts:log", "channels/discord.ts→utils/logger.ts:log"] },
|
||||
{ "from": "config", "to": "utils", "type": "CALLS", "via": ["config/config.ts:loadConfigFromDisk→utils/logger.ts:log"] },
|
||||
{ "from": "utils", "to": "utils", "type": "CALLS", "via": ["utils/fs-helpers.ts→utils/logger.ts:log"] }
|
||||
],
|
||||
"cycles": [
|
||||
{ "subsystems": ["gateway", "agents"], "via": "gateway/session.ts↔agents/runner.ts" }
|
||||
]
|
||||
}
|
||||
26
test/fixtures/system-docs/expected-flows.json
vendored
Normal file
26
test/fixtures/system-docs/expected-flows.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"flows": [
|
||||
{
|
||||
"entryPoint": "channels/telegram.ts:onTelegramMessage",
|
||||
"subsystemSequence": ["channels", "gateway", "agents"],
|
||||
"flow": [
|
||||
{ "subsystem": "channels", "entity": "channels/telegram.ts:onTelegramMessage", "depth": 0 },
|
||||
{ "subsystem": "gateway", "entity": "gateway/server.ts:handleRequest", "depth": 1, "crossedVia": "CALLS" },
|
||||
{ "subsystem": "gateway", "entity": "gateway/session.ts:loadSession", "depth": 1.5, "crossedVia": "CALLS" }
|
||||
],
|
||||
"cyclesDetected": []
|
||||
},
|
||||
{
|
||||
"entryPoint": "gateway/session.ts:refreshSession",
|
||||
"subsystemSequence": ["gateway", "agents"],
|
||||
"flow": [
|
||||
{ "subsystem": "gateway", "entity": "gateway/session.ts:refreshSession", "depth": 0 },
|
||||
{ "subsystem": "gateway", "entity": "gateway/session.ts:loadSession", "depth": 0.5, "crossedVia": "CALLS" },
|
||||
{ "subsystem": "agents", "entity": "agents/runner.ts:runAgent", "depth": 1, "crossedVia": "CALLS" }
|
||||
],
|
||||
"cyclesDetected": [
|
||||
{ "at": "agents/runner.ts:reloadAgent", "backEdgeTo": "gateway/session.ts:loadSession" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
test/fixtures/system-docs/expected-subsystems.json
vendored
Normal file
52
test/fixtures/system-docs/expected-subsystems.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"subsystems": [
|
||||
{
|
||||
"name": "gateway",
|
||||
"kind": "domain",
|
||||
"files": [
|
||||
"gateway/server.ts",
|
||||
"gateway/session.ts",
|
||||
"gateway/middleware.ts",
|
||||
"gateway/types.ts",
|
||||
"gateway/utils.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "agents",
|
||||
"kind": "domain",
|
||||
"files": [
|
||||
"agents/runner.ts",
|
||||
"agents/scope.ts",
|
||||
"agents/tools.ts",
|
||||
"agents/types.ts",
|
||||
"agents/defaults.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "channels",
|
||||
"kind": "domain",
|
||||
"files": [
|
||||
"channels/telegram.ts",
|
||||
"channels/discord.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "config",
|
||||
"kind": "cross-cutting",
|
||||
"files": [
|
||||
"config/config.ts",
|
||||
"config/types.ts",
|
||||
"config/schema.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "utils",
|
||||
"kind": "cross-cutting",
|
||||
"files": [
|
||||
"utils/logger.ts",
|
||||
"utils/crypto.ts",
|
||||
"utils/fs-helpers.ts"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1367
test/fixtures/system-docs/snapshot.json
vendored
Normal file
1367
test/fixtures/system-docs/snapshot.json
vendored
Normal file
File diff suppressed because it is too large
Load Diff
12
test/fixtures/system-docs/src/agents/defaults.ts
vendored
Normal file
12
test/fixtures/system-docs/src/agents/defaults.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Fixture: agents/defaults.ts — default agent configuration
|
||||
import { AgentConfig } from './types';
|
||||
|
||||
export const DEFAULT_AGENT: AgentConfig = {
|
||||
agentId: 'default',
|
||||
model: 'claude-sonnet-4',
|
||||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
export function getDefaultAgent(): AgentConfig {
|
||||
return { ...DEFAULT_AGENT };
|
||||
}
|
||||
23
test/fixtures/system-docs/src/agents/runner.ts
vendored
Normal file
23
test/fixtures/system-docs/src/agents/runner.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// Fixture: agents/runner.ts — agent execution (circular dep with gateway/session.ts)
|
||||
import { loadSession } from '../gateway/session';
|
||||
import { AgentScope } from './scope';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function runAgent(session: { key: string; agentId: string; model: string }): string {
|
||||
log('running agent ' + session.agentId);
|
||||
const scope = createScope(session.agentId);
|
||||
return executeAgent(scope, session.model);
|
||||
}
|
||||
|
||||
function createScope(agentId: string): AgentScope {
|
||||
return { agentId, tools: getDefaultTools() };
|
||||
}
|
||||
|
||||
function executeAgent(scope: AgentScope, model: string): string {
|
||||
return `result from ${scope.agentId} on ${model}`;
|
||||
}
|
||||
|
||||
export function reloadAgent(key: string): string {
|
||||
const session = loadSession(key); // circular: agents calls gateway
|
||||
return runAgent(session);
|
||||
}
|
||||
18
test/fixtures/system-docs/src/agents/scope.ts
vendored
Normal file
18
test/fixtures/system-docs/src/agents/scope.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Fixture: agents/scope.ts — agent scope management
|
||||
import { getConfig } from '../config/config';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export interface AgentScope {
|
||||
agentId: string;
|
||||
tools: string[];
|
||||
}
|
||||
|
||||
export function createAgentScope(agentId: string): AgentScope {
|
||||
log('creating scope for ' + agentId);
|
||||
const config = getConfig();
|
||||
return { agentId, tools: getDefaultTools() };
|
||||
}
|
||||
|
||||
export function getDefaultTools(): string[] {
|
||||
return ['read', 'write', 'exec', 'web_search'];
|
||||
}
|
||||
15
test/fixtures/system-docs/src/agents/tools.ts
vendored
Normal file
15
test/fixtures/system-docs/src/agents/tools.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// Fixture: agents/tools.ts — tool registry
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerTool(name: string, handler: Function): void {
|
||||
log('registering tool: ' + name);
|
||||
}
|
||||
|
||||
export function executeTool(name: string, args: Record<string, unknown>): unknown {
|
||||
log('executing tool: ' + name);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function listTools(): string[] {
|
||||
return ['read', 'write', 'exec', 'web_search', 'browser'];
|
||||
}
|
||||
14
test/fixtures/system-docs/src/agents/types.ts
vendored
Normal file
14
test/fixtures/system-docs/src/agents/types.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// Fixture: agents/types.ts — agent type definitions
|
||||
export interface AgentConfig {
|
||||
agentId: string;
|
||||
model: string;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
export interface AgentResult {
|
||||
output: string;
|
||||
tokensUsed: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
12
test/fixtures/system-docs/src/channels/discord.ts
vendored
Normal file
12
test/fixtures/system-docs/src/channels/discord.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Fixture: channels/discord.ts — Discord channel adapter
|
||||
import { handleRequest } from '../gateway/server';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function onDiscordMessage(guildId: string, channelId: string, text: string): void {
|
||||
log('discord message in ' + guildId);
|
||||
const session = handleRequest('discord:' + guildId + ':' + channelId);
|
||||
}
|
||||
|
||||
export function sendDiscordReply(channelId: string, message: string): void {
|
||||
log('sending discord reply to ' + channelId);
|
||||
}
|
||||
12
test/fixtures/system-docs/src/channels/telegram.ts
vendored
Normal file
12
test/fixtures/system-docs/src/channels/telegram.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Fixture: channels/telegram.ts — Telegram channel adapter
|
||||
import { handleRequest } from '../gateway/server';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function onTelegramMessage(chatId: string, text: string): void {
|
||||
log('telegram message from ' + chatId);
|
||||
const session = handleRequest('telegram:' + chatId);
|
||||
}
|
||||
|
||||
export function sendTelegramReply(chatId: string, message: string): void {
|
||||
log('sending reply to ' + chatId);
|
||||
}
|
||||
22
test/fixtures/system-docs/src/config/config.ts
vendored
Normal file
22
test/fixtures/system-docs/src/config/config.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// Fixture: config/config.ts — central configuration
|
||||
import { BaseConfig } from './types';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
let _config: BaseConfig | null = null;
|
||||
|
||||
export function getConfig(): BaseConfig {
|
||||
if (!_config) {
|
||||
_config = loadConfigFromDisk();
|
||||
}
|
||||
return _config;
|
||||
}
|
||||
|
||||
function loadConfigFromDisk(): BaseConfig {
|
||||
log('loading config from disk');
|
||||
return { defaultAgent: 'default', model: 'claude-sonnet-4', debug: false };
|
||||
}
|
||||
|
||||
export function reloadConfig(): void {
|
||||
_config = null;
|
||||
getConfig();
|
||||
}
|
||||
18
test/fixtures/system-docs/src/config/schema.ts
vendored
Normal file
18
test/fixtures/system-docs/src/config/schema.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Fixture: config/schema.ts — ORPHAN FILE (no inbound edges, only exports)
|
||||
// This file is imported by nobody — tests orphan handling
|
||||
|
||||
export interface SchemaDefinition {
|
||||
name: string;
|
||||
version: string;
|
||||
fields: SchemaField[];
|
||||
}
|
||||
|
||||
export interface SchemaField {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export function validateSchema(schema: SchemaDefinition): boolean {
|
||||
return schema.fields.length > 0 && schema.version.length > 0;
|
||||
}
|
||||
19
test/fixtures/system-docs/src/config/types.ts
vendored
Normal file
19
test/fixtures/system-docs/src/config/types.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// Fixture: config/types.ts — base config types (imported by gateway/types.ts)
|
||||
export interface BaseConfig {
|
||||
defaultAgent: string;
|
||||
model: string;
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderConfig {
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
Debug = 'debug',
|
||||
Info = 'info',
|
||||
Warn = 'warn',
|
||||
Error = 'error',
|
||||
}
|
||||
12
test/fixtures/system-docs/src/gateway/middleware.ts
vendored
Normal file
12
test/fixtures/system-docs/src/gateway/middleware.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// Fixture: gateway/middleware.ts — request middleware
|
||||
import { log } from '../utils/logger';
|
||||
import { SessionEntry } from './types';
|
||||
|
||||
export function applyMiddleware(session: SessionEntry): void {
|
||||
log('applying middleware for ' + session.key);
|
||||
validateSession(session);
|
||||
}
|
||||
|
||||
function validateSession(session: SessionEntry): boolean {
|
||||
return session.key.length > 0;
|
||||
}
|
||||
18
test/fixtures/system-docs/src/gateway/server.ts
vendored
Normal file
18
test/fixtures/system-docs/src/gateway/server.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Fixture: gateway/server.ts — main gateway entry point
|
||||
import { loadSession, saveSession } from './session';
|
||||
import { applyMiddleware } from './middleware';
|
||||
import { GatewayConfig, SessionEntry } from './types';
|
||||
|
||||
export function startGateway(config: GatewayConfig): void {
|
||||
const session = loadSession(config.sessionKey);
|
||||
applyMiddleware(session);
|
||||
}
|
||||
|
||||
export function handleRequest(sessionKey: string): SessionEntry {
|
||||
const session = loadSession(sessionKey);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function shutdownGateway(): void {
|
||||
// cleanup
|
||||
}
|
||||
19
test/fixtures/system-docs/src/gateway/session.ts
vendored
Normal file
19
test/fixtures/system-docs/src/gateway/session.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
// Fixture: gateway/session.ts — session management (has circular dep with agents/runner.ts)
|
||||
import { getConfig } from '../config/config';
|
||||
import { runAgent } from '../agents/runner';
|
||||
import { SessionEntry } from './types';
|
||||
|
||||
export function loadSession(key: string): SessionEntry {
|
||||
const config = getConfig();
|
||||
return { key, agentId: config.defaultAgent, model: config.model };
|
||||
}
|
||||
|
||||
export function saveSession(entry: SessionEntry): void {
|
||||
// persist
|
||||
}
|
||||
|
||||
export function refreshSession(key: string): SessionEntry {
|
||||
const session = loadSession(key);
|
||||
runAgent(session); // circular: gateway calls agents, agents call gateway
|
||||
return session;
|
||||
}
|
||||
15
test/fixtures/system-docs/src/gateway/types.ts
vendored
Normal file
15
test/fixtures/system-docs/src/gateway/types.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// Fixture: gateway/types.ts — re-exports from config/types + gateway-specific types
|
||||
import { BaseConfig } from '../config/types';
|
||||
|
||||
export interface GatewayConfig extends BaseConfig {
|
||||
sessionKey: string;
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export interface SessionEntry {
|
||||
key: string;
|
||||
agentId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export { BaseConfig } from '../config/types';
|
||||
7
test/fixtures/system-docs/src/gateway/utils.ts
vendored
Normal file
7
test/fixtures/system-docs/src/gateway/utils.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Fixture: gateway/utils.ts — gateway-specific helpers
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function formatSessionKey(prefix: string, id: string): string {
|
||||
log('formatting key');
|
||||
return `${prefix}:${id}`;
|
||||
}
|
||||
13
test/fixtures/system-docs/src/utils/crypto.ts
vendored
Normal file
13
test/fixtures/system-docs/src/utils/crypto.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// Fixture: utils/crypto.ts — crypto helpers
|
||||
export function hashString(input: string): string {
|
||||
return Buffer.from(input).toString('base64');
|
||||
}
|
||||
|
||||
export function generateToken(length: number): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
15
test/fixtures/system-docs/src/utils/fs-helpers.ts
vendored
Normal file
15
test/fixtures/system-docs/src/utils/fs-helpers.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
// Fixture: utils/fs-helpers.ts — file system helpers
|
||||
import { log } from './logger';
|
||||
|
||||
export function readFileSync(path: string): string {
|
||||
log('reading ' + path);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function writeFileSync(path: string, content: string): void {
|
||||
log('writing ' + path);
|
||||
}
|
||||
|
||||
export function fileExists(path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
14
test/fixtures/system-docs/src/utils/logger.ts
vendored
Normal file
14
test/fixtures/system-docs/src/utils/logger.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
// Fixture: utils/logger.ts — cross-cutting utility (high fan-in)
|
||||
// Imported by almost every other module — tests cross-cutting detection
|
||||
|
||||
export function log(message: string): void {
|
||||
console.log(`[${new Date().toISOString()}] ${message}`);
|
||||
}
|
||||
|
||||
export function warn(message: string): void {
|
||||
console.warn(`[WARN] ${message}`);
|
||||
}
|
||||
|
||||
export function error(message: string, err?: Error): void {
|
||||
console.error(`[ERROR] ${message}`, err?.message || '');
|
||||
}
|
||||
93
test/test-flow.js
Normal file
93
test/test-flow.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GraphStore = require('../graph.js');
|
||||
const { buildSubsystems } = require('../subsystem.js');
|
||||
const { buildFlowIndex, traceFlow } = require('../flow.js');
|
||||
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures/system-docs');
|
||||
const SNAPSHOT = path.join(FIXTURE_DIR, 'snapshot.json');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, name) {
|
||||
if (condition) { passed++; console.log(` ✓ ${name}`); }
|
||||
else { failed++; console.log(` ✗ ${name}`); }
|
||||
}
|
||||
|
||||
const graph = GraphStore.loadSnapshot(SNAPSHOT);
|
||||
const subsystemMap = buildSubsystems(graph, { minTraffic: 3, crossCuttingThreshold: 0.6 });
|
||||
const index = buildFlowIndex(graph, subsystemMap);
|
||||
|
||||
console.log('=== 7C: Flow Tracer Tests ===\n');
|
||||
|
||||
// Test 1: Simple linear flow across subsystems
|
||||
console.log('Test 1: Linear flow');
|
||||
const t1 = traceFlow('channels/telegram.ts:onTelegramMessage', index);
|
||||
assert(t1.subsystemSequence.includes('channels'), 'Started in channels');
|
||||
assert(t1.subsystemSequence.includes('gateway'), 'Crossed to gateway');
|
||||
assert(t1.flow.some(f => f.entity === 'gateway/server.ts:handleRequest' && f.crossedVia === 'CALLS'), 'Recorded CALLS crossing');
|
||||
|
||||
// Test 2: Cycle detection (gateway <-> agents)
|
||||
console.log('\nTest 2: Cycle detection');
|
||||
const t2 = traceFlow('gateway/session.ts:refreshSession', index);
|
||||
assert(t2.subsystemSequence.includes('gateway'), 'Started in gateway');
|
||||
assert(t2.subsystemSequence.includes('agents'), 'Crossed to agents');
|
||||
assert(t2.flow.length < 20, 'Trace terminated without infinite loop');
|
||||
|
||||
// Test 3: God object exclusion
|
||||
console.log('\nTest 3: God object exclusion');
|
||||
const indexLowThreshold = buildFlowIndex(graph, subsystemMap, { godThreshold: 2 });
|
||||
const t3 = traceFlow('channels/telegram.ts:onTelegramMessage', indexLowThreshold);
|
||||
assert(t3.excludedNodes.includes('utils/logger.ts:log'), 'logger.ts:log was excluded as god object');
|
||||
assert(!t3.flow.some(f => f.entity === 'utils/logger.ts:log'), 'logger does not appear in flow');
|
||||
|
||||
// Test 4: Depth limit
|
||||
console.log('\nTest 4: Depth limit');
|
||||
const t4 = traceFlow('channels/telegram.ts:onTelegramMessage', index, { maxDepth: 1.2 });
|
||||
assert(t4.flow.some(f => f.entity === 'gateway/server.ts:handleRequest'), 'Included depth 1 node');
|
||||
assert(!t4.flow.some(f => f.entity === 'gateway/session.ts:loadSession'), 'Excluded depth 1.5 node');
|
||||
|
||||
// Test 5: Empty trace
|
||||
console.log('\nTest 5: Empty trace');
|
||||
const t5 = traceFlow('utils/crypto.ts:hashString', index);
|
||||
assert(t5.flow.length === 1, 'Only entry point in flow');
|
||||
assert(t5.subsystemSequence.length === 1, 'Only one subsystem');
|
||||
|
||||
// Test 6: Invalid entry point
|
||||
console.log('\nTest 6: Invalid entry point');
|
||||
const t6 = traceFlow('nonexistent/file.ts:foo', index);
|
||||
assert(t6.error !== undefined, 'Returns error for invalid entry point');
|
||||
assert(t6.flow.length === 0, 'Empty flow for invalid entry');
|
||||
|
||||
// Test 7: Multiple traces reuse same index (performance)
|
||||
console.log('\nTest 7: Index reuse + OpenClaw performance');
|
||||
const fullSnap = path.join(__dirname, '..', 'snapshots', 'openclaw-full.json');
|
||||
if (fs.existsSync(fullSnap)) {
|
||||
const fullGraph = GraphStore.loadSnapshot(fullSnap);
|
||||
const fullSubs = buildSubsystems(fullGraph);
|
||||
|
||||
const indexStart = Date.now();
|
||||
const fullIndex = buildFlowIndex(fullGraph, fullSubs, { godThreshold: 50 });
|
||||
const indexTime = Date.now() - indexStart;
|
||||
console.log(` Index built in ${indexTime}ms`);
|
||||
|
||||
// Run 3 traces on the same index
|
||||
const entries = ['cli/route.ts:tryRouteCli', 'gateway/session-utils.ts', 'pairing/pairing-store.ts'];
|
||||
let totalTraceTime = 0;
|
||||
for (const ep of entries) {
|
||||
const start = Date.now();
|
||||
const t = traceFlow(ep, fullIndex, { maxDepth: 8 });
|
||||
const elapsed = Date.now() - start;
|
||||
totalTraceTime += elapsed;
|
||||
console.log(` Trace "${ep}": ${t.flow.length} steps, ${t.subsystemSequence.length} subsystems, ${elapsed}ms`);
|
||||
}
|
||||
|
||||
assert(indexTime < 3000, `Index built in <3s (${indexTime}ms)`);
|
||||
assert(totalTraceTime < 5000, `All 3 traces completed in <5s (${totalTraceTime}ms)`);
|
||||
} else {
|
||||
console.log(' (skipped — openclaw-full.json not found)');
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
109
test/test-subsystem.js
Normal file
109
test/test-subsystem.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const GraphStore = require('../graph.js');
|
||||
const { buildSubsystems } = require('../subsystem.js');
|
||||
|
||||
const FIXTURE_DIR = path.join(__dirname, 'fixtures/system-docs');
|
||||
const SNAPSHOT = path.join(FIXTURE_DIR, 'snapshot.json');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, name) {
|
||||
if (condition) { passed++; console.log(` ✓ ${name}`); }
|
||||
else { failed++; console.log(` ✗ ${name}`); }
|
||||
}
|
||||
|
||||
// Load graph and run subsystem aggregator
|
||||
const graph = GraphStore.loadSnapshot(SNAPSHOT);
|
||||
const result = buildSubsystems(graph, { minTraffic: 3, crossCuttingThreshold: 0.6 });
|
||||
const expected = JSON.parse(fs.readFileSync(path.join(FIXTURE_DIR, 'expected-subsystems.json'), 'utf8'));
|
||||
|
||||
console.log('=== 7A: Subsystem Aggregator Tests ===\n');
|
||||
|
||||
// Test 1: Directory clustering — correct number of subsystems
|
||||
console.log('Test 1: Directory clustering');
|
||||
assert(result.subsystems.length === 5, `Found 5 subsystems (got ${result.subsystems.length})`);
|
||||
const names = result.subsystems.map(s => s.name).sort();
|
||||
assert(JSON.stringify(names) === JSON.stringify(['agents', 'channels', 'config', 'gateway', 'utils']),
|
||||
`Subsystem names match: ${names.join(', ')}`);
|
||||
|
||||
// Test 2: File assignment accuracy
|
||||
console.log('\nTest 2: File assignment accuracy');
|
||||
let correctFiles = 0;
|
||||
let totalFiles = 0;
|
||||
for (const expSub of expected.subsystems) {
|
||||
const actualSub = result.subsystems.find(s => s.name === expSub.name);
|
||||
assert(!!actualSub, `Subsystem "${expSub.name}" exists`);
|
||||
if (actualSub) {
|
||||
for (const f of expSub.files) {
|
||||
totalFiles++;
|
||||
if (actualSub.files.includes(f)) correctFiles++;
|
||||
else console.log(` MISSING: ${f} in ${expSub.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const accuracy = totalFiles > 0 ? (correctFiles / totalFiles * 100).toFixed(1) : 0;
|
||||
assert(correctFiles / totalFiles >= 0.9, `File assignment accuracy ≥90% (${accuracy}%, ${correctFiles}/${totalFiles})`);
|
||||
|
||||
// Test 3: Cross-cutting detection
|
||||
console.log('\nTest 3: Cross-cutting detection');
|
||||
assert(result.crossCutting.includes('utils'), 'utils detected as cross-cutting');
|
||||
assert(result.crossCutting.includes('config'), 'config detected as cross-cutting');
|
||||
assert(!result.crossCutting.includes('gateway'), 'gateway is NOT cross-cutting');
|
||||
assert(!result.crossCutting.includes('agents'), 'agents is NOT cross-cutting');
|
||||
assert(!result.crossCutting.includes('channels'), 'channels is NOT cross-cutting');
|
||||
|
||||
// Verify kind field matches
|
||||
for (const expSub of expected.subsystems) {
|
||||
const actualSub = result.subsystems.find(s => s.name === expSub.name);
|
||||
if (actualSub) {
|
||||
assert(actualSub.kind === expSub.kind, `${expSub.name} kind="${actualSub.kind}" (expected "${expSub.kind}")`);
|
||||
}
|
||||
}
|
||||
|
||||
// Test 4: Dependency matrix — key edges exist
|
||||
console.log('\nTest 4: Dependency matrix');
|
||||
const dm = result.dependencyMatrix;
|
||||
assert('gateway→agents' in dm, 'gateway→agents edge exists');
|
||||
assert('agents→gateway' in dm, 'agents→gateway edge exists (circular dep)');
|
||||
assert('channels→gateway' in dm, 'channels→gateway edge exists');
|
||||
assert('gateway→config' in dm, 'gateway→config edge exists');
|
||||
assert('agents→utils' in dm, 'agents→utils edge exists');
|
||||
assert('channels→utils' in dm, 'channels→utils edge exists');
|
||||
|
||||
// Test 5: Empty subsystem handling (channels has only 2 files, no internal CALLS)
|
||||
console.log('\nTest 5: Edge cases');
|
||||
const channelsSub = result.subsystems.find(s => s.name === 'channels');
|
||||
assert(channelsSub && channelsSub.files.length === 2, `channels has 2 files (got ${channelsSub?.files.length})`);
|
||||
|
||||
// Orphan file: config/schema.ts should still be in config subsystem
|
||||
const configSub = result.subsystems.find(s => s.name === 'config');
|
||||
assert(configSub && configSub.files.includes('config/schema.ts'), 'Orphan config/schema.ts assigned to config');
|
||||
|
||||
// Test 6: Public exports populated
|
||||
console.log('\nTest 6: Public exports');
|
||||
const gatewaySub = result.subsystems.find(s => s.name === 'gateway');
|
||||
assert(gatewaySub.publicExports.includes('handleRequest'), 'gateway exports handleRequest');
|
||||
assert(gatewaySub.publicExports.includes('loadSession'), 'gateway exports loadSession');
|
||||
const agentsSub = result.subsystems.find(s => s.name === 'agents');
|
||||
assert(agentsSub.publicExports.includes('runAgent'), 'agents exports runAgent');
|
||||
|
||||
// Test 7: Run on OpenClaw full snapshot (performance)
|
||||
console.log('\nTest 7: OpenClaw full snapshot');
|
||||
const fullSnap = path.join(__dirname, '..', 'snapshots', 'openclaw-full.json');
|
||||
if (fs.existsSync(fullSnap)) {
|
||||
const start = Date.now();
|
||||
const fullGraph = GraphStore.loadSnapshot(fullSnap);
|
||||
const fullResult = buildSubsystems(fullGraph);
|
||||
const elapsed = Date.now() - start;
|
||||
assert(fullResult.subsystems.length > 10, `OpenClaw has >10 subsystems (got ${fullResult.subsystems.length})`);
|
||||
assert(elapsed < 5000, `Completed in <5s (${elapsed}ms)`);
|
||||
assert(fullResult.crossCutting.length > 0, `Detected cross-cutting subsystems: ${fullResult.crossCutting.join(', ')}`);
|
||||
console.log(` OpenClaw: ${fullResult.subsystems.length} subsystems, ${Object.keys(fullResult.dependencyMatrix).length} dep edges, ${elapsed}ms`);
|
||||
} else {
|
||||
console.log(' (skipped — openclaw-full.json not found)');
|
||||
}
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user