2026-03-09 06:51:32 +00:00
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
feat: confluence benchmark, pattern extractor, agent KB, UX spec
- extract-patterns.js: mines layered arch, ArgoCD appsets, cloud regions,
CIDR allocations, naming conventions, sync waves, tech stack from code
- agent-kb.js: token-efficient JSON rendering of same doc tree
- eval-confluence-ref-questions.json: 32 reference-only benchmark questions
- wiggum-v2.sh: Ralph Wiggum loop targeting confluence baseline (77.8%)
- docs/human-ux-spec.md: BMad UX designer spec for human doc structure
- Eval results: V2 at 28.7% vs confluence 77.8% baseline
- Hub/spoke ownership now correctly extracted (95% on that question)
- Naming conventions, regions, CIDRs surfaced in system-architecture.md
2026-03-10 14:20:35 +00:00
|
|
|
/**
|
|
|
|
|
* Auto-detect entry points from the graph.
|
|
|
|
|
* Heuristics:
|
|
|
|
|
* 1. Helm Deployments/StatefulSets with incoming Service/Ingress edges
|
|
|
|
|
* 2. Python files with __main__ guard
|
|
|
|
|
* 3. Shell scripts with main() function
|
|
|
|
|
* 4. CI pipeline files (.github/workflows/, .gitlab-ci.yml)
|
|
|
|
|
* 5. Go files with main() function in package main
|
|
|
|
|
*/
|
|
|
|
|
function detectEntryPoints(graph) {
|
|
|
|
|
const entryPoints = [];
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
for (const [id, node] of graph.nodes) {
|
|
|
|
|
// 1. Helm: Deployments/StatefulSets that have a Service pointing to them
|
|
|
|
|
if (node.kind === 'helm-resource' || node.kind === 'HelmWorkloads') {
|
|
|
|
|
const resourceType = node.resourceType || node.name || '';
|
|
|
|
|
if (resourceType.includes('Deployment') || resourceType.includes('StatefulSet') ||
|
|
|
|
|
node.type === 'Deployment' || node.type === 'StatefulSet') {
|
|
|
|
|
// Check if any Service/Ingress references this
|
|
|
|
|
const hasService = graph.edges.some(e =>
|
|
|
|
|
(e.target === id || e.source === id) &&
|
|
|
|
|
(e.type === 'EXPOSES' || e.type === 'ROUTES_TO' || e.type === 'DEPENDS_ON')
|
|
|
|
|
);
|
|
|
|
|
if (hasService && !seen.has(id)) {
|
|
|
|
|
seen.add(id);
|
|
|
|
|
entryPoints.push({ id, name: node.name, kind: 'helm-workload', reason: 'Deployment/StatefulSet with service' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Python __main__
|
|
|
|
|
if (node._file && node._file.endsWith('.py') && node.type === 'Function' && node.name === '__main__') {
|
|
|
|
|
if (!seen.has(id)) {
|
|
|
|
|
seen.add(id);
|
|
|
|
|
entryPoints.push({ id, name: node.name, kind: 'python-main', reason: 'Python __main__ guard' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Shell main()
|
|
|
|
|
if (node._file && (node._file.endsWith('.sh') || node._file.endsWith('.bash'))) {
|
|
|
|
|
if (node.type === 'Function' && node.name === 'main') {
|
|
|
|
|
if (!seen.has(id)) {
|
|
|
|
|
seen.add(id);
|
|
|
|
|
entryPoints.push({ id, name: node.name, kind: 'shell-main', reason: 'Shell main() function' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 4. Go main()
|
|
|
|
|
if (node._file && node._file.endsWith('.go') && node.type === 'Function' && node.name === 'main') {
|
|
|
|
|
if (!seen.has(id)) {
|
|
|
|
|
seen.add(id);
|
|
|
|
|
entryPoints.push({ id, name: node.name, kind: 'go-main', reason: 'Go main() function' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. CI pipeline files
|
|
|
|
|
for (const [filePath] of graph.fileIndex) {
|
|
|
|
|
const rel = filePath;
|
|
|
|
|
if (rel.includes('.github/workflows/') || rel.includes('.gitlab-ci') ||
|
|
|
|
|
rel.includes('Jenkinsfile') || rel.includes('.circleci/')) {
|
|
|
|
|
const fileEntities = graph.fileIndex.get(filePath);
|
|
|
|
|
if (fileEntities && !seen.has(filePath)) {
|
|
|
|
|
seen.add(filePath);
|
|
|
|
|
entryPoints.push({ id: filePath, name: filePath, kind: 'ci-pipeline', reason: 'CI/CD pipeline file' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return entryPoints;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 06:51:32 +00:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
feat: confluence benchmark, pattern extractor, agent KB, UX spec
- extract-patterns.js: mines layered arch, ArgoCD appsets, cloud regions,
CIDR allocations, naming conventions, sync waves, tech stack from code
- agent-kb.js: token-efficient JSON rendering of same doc tree
- eval-confluence-ref-questions.json: 32 reference-only benchmark questions
- wiggum-v2.sh: Ralph Wiggum loop targeting confluence baseline (77.8%)
- docs/human-ux-spec.md: BMad UX designer spec for human doc structure
- Eval results: V2 at 28.7% vs confluence 77.8% baseline
- Hub/spoke ownership now correctly extracted (95% on that question)
- Naming conventions, regions, CIDRs surfaced in system-architecture.md
2026-03-10 14:20:35 +00:00
|
|
|
module.exports = { buildFlowIndex, traceFlow, detectEntryPoints };
|