secufusion-mcp 2.5.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +12 -11
- package/agents/AGENTS.md +7 -7
- package/agents/planner.md +128 -119
- package/agents/reviewer.md +123 -0
- package/mcp/dist/benchmark_fixture.js +46 -0
- package/mcp/dist/benchmark_phase4.js +135 -0
- package/mcp/dist/benchmark_phase5.js +121 -0
- package/mcp/dist/benchmark_phase6.js +83 -0
- package/mcp/dist/benchmark_phase7.js +106 -0
- package/mcp/dist/benchmark_phase8.js +75 -0
- package/mcp/dist/benchmark_runner.js +130 -0
- package/mcp/dist/benchmark_traceability.js +74 -0
- package/mcp/dist/experience_manager.js +226 -0
- package/mcp/dist/graph_builder.js +148 -0
- package/mcp/dist/implementation_plan.js +92 -0
- package/mcp/dist/investigation_engine.js +59 -0
- package/mcp/dist/regression_engine.js +196 -0
- package/mcp/dist/requirement_manager.js +84 -0
- package/mcp/dist/run_benchmarks.js +62 -0
- package/mcp/dist/server.js +727 -144
- package/mcp/dist/test_engine.js +113 -0
- package/mcp/dist/test_graph.js +60 -0
- package/mcp/dist/traceability_engine.js +106 -0
- package/mcp/dist/trigger_extraction.js +4 -0
- package/mcp/dist/ui_intelligence.js +74 -0
- package/mcp/dist/validation_engine.js +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { loadExperience } from "./experience_manager.js";
|
|
4
|
+
// --- Snapshot Management ---
|
|
5
|
+
export function loadSnapshot(ecosystemRoot, name) {
|
|
6
|
+
const p = path.join(ecosystemRoot, '.secufusion', `graph.${name}.json`);
|
|
7
|
+
if (!fs.existsSync(p))
|
|
8
|
+
return null;
|
|
9
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
10
|
+
}
|
|
11
|
+
export function saveSnapshot(ecosystemRoot, name, snap) {
|
|
12
|
+
const p = path.join(ecosystemRoot, '.secufusion', `graph.${name}.json`);
|
|
13
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
14
|
+
fs.writeFileSync(p, JSON.stringify(snap, null, 2), "utf-8");
|
|
15
|
+
}
|
|
16
|
+
export function compareRepositorySnapshots(base, current) {
|
|
17
|
+
const changes = [];
|
|
18
|
+
const baseNodes = new Map(base.nodes.map(n => [n.id, n]));
|
|
19
|
+
const currNodes = new Map(current.nodes.map(n => [n.id, n]));
|
|
20
|
+
for (const [id, curr] of currNodes.entries()) {
|
|
21
|
+
if (!baseNodes.has(id)) {
|
|
22
|
+
changes.push({ category: `${curr.type}_ADDED`, artifact_id: id, current_evidence: curr });
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
// Assume modified if it exists. A real diff would check metadata.
|
|
26
|
+
const b = baseNodes.get(id);
|
|
27
|
+
if (JSON.stringify(curr.metadata) !== JSON.stringify(b?.metadata)) {
|
|
28
|
+
changes.push({ category: `${curr.type}_MODIFIED`, artifact_id: id, base_evidence: b, current_evidence: curr });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const [id, b] of baseNodes.entries()) {
|
|
33
|
+
if (!currNodes.has(id)) {
|
|
34
|
+
changes.push({ category: `${b.type}_REMOVED`, artifact_id: id, base_evidence: b });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Edges
|
|
38
|
+
const baseEdges = new Map(base.edges.map(e => [e.id, e]));
|
|
39
|
+
const currEdges = new Map(current.edges.map(e => [e.id, e]));
|
|
40
|
+
for (const [id, curr] of currEdges.entries()) {
|
|
41
|
+
if (!baseEdges.has(id)) {
|
|
42
|
+
changes.push({ category: `RELATIONSHIP_ADDED`, artifact_id: id, current_evidence: curr });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [id, b] of baseEdges.entries()) {
|
|
46
|
+
if (!currEdges.has(id)) {
|
|
47
|
+
changes.push({ category: `RELATIONSHIP_REMOVED`, artifact_id: id, base_evidence: b });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return changes;
|
|
51
|
+
}
|
|
52
|
+
// --- Architecture Drift ---
|
|
53
|
+
export function detectArchitectureDrift(base, current) {
|
|
54
|
+
const drift = [];
|
|
55
|
+
const changes = compareRepositorySnapshots(base, current);
|
|
56
|
+
for (const ch of changes) {
|
|
57
|
+
if (ch.category === "RELATIONSHIP_REMOVED" || ch.category === "RELATIONSHIP_ADDED") {
|
|
58
|
+
const edge = ch.current_evidence || ch.base_evidence;
|
|
59
|
+
if (edge.type === "CONSUMES" || edge.type === "PRODUCES") {
|
|
60
|
+
drift.push({ category: "EVENT_DRIFT", affected_component: edge.source, base_evidence: ch.base_evidence, current_evidence: ch.current_evidence, status: "DRIFT_DETECTED" });
|
|
61
|
+
}
|
|
62
|
+
else if (edge.type === "CALLS") {
|
|
63
|
+
drift.push({ category: "DEPENDENCY_DRIFT", affected_component: edge.source, base_evidence: ch.base_evidence, current_evidence: ch.current_evidence, status: "DRIFT_DETECTED" });
|
|
64
|
+
}
|
|
65
|
+
else if (edge.type === "OWNS") {
|
|
66
|
+
drift.push({ category: "DATABASE_DRIFT", affected_component: edge.source, base_evidence: ch.base_evidence, current_evidence: ch.current_evidence, status: "DRIFT_DETECTED" });
|
|
67
|
+
}
|
|
68
|
+
else if (edge.type === "EXPOSES") {
|
|
69
|
+
drift.push({ category: "API_DRIFT", affected_component: edge.source, base_evidence: ch.base_evidence, current_evidence: ch.current_evidence, status: "DRIFT_DETECTED" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return drift;
|
|
74
|
+
}
|
|
75
|
+
// --- Requirement Drift ---
|
|
76
|
+
export function detectRequirementDrift(reqs, current) {
|
|
77
|
+
const drift = [];
|
|
78
|
+
const currNodes = new Map(current.nodes.map(n => [n.id, n]));
|
|
79
|
+
for (const req of reqs) {
|
|
80
|
+
if (req.status !== "CONFIRMED")
|
|
81
|
+
continue;
|
|
82
|
+
if (req.apis) {
|
|
83
|
+
for (const api of req.apis) {
|
|
84
|
+
if (!currNodes.has(`API:${api}`)) {
|
|
85
|
+
drift.push({ state: "DRIFT_DETECTED", condition: "API_NOT_IN_IMPLEMENTATION", requirement_id: req.id, expected_artifact: api });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return drift;
|
|
91
|
+
}
|
|
92
|
+
// --- Traceability Drift ---
|
|
93
|
+
export function detectTraceabilityDrift(links, current) {
|
|
94
|
+
const drift = [];
|
|
95
|
+
const currNodes = new Map(current.nodes.map(n => [n.id, n]));
|
|
96
|
+
for (const link of links) {
|
|
97
|
+
if (link.target && ["API", "CLASS", "SERVICE", "DATABASE_TABLE", "TOPIC"].includes(link.type) || link.target.startsWith("API:") || link.target.startsWith("CLASS:")) {
|
|
98
|
+
if (!currNodes.has(link.target)) {
|
|
99
|
+
drift.push({ condition: "CODE_WITHOUT_TRACEABILITY", source_id: link.source, target_id: link.target }); // Target disappeared
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return drift;
|
|
104
|
+
}
|
|
105
|
+
// --- Historical Matching ---
|
|
106
|
+
export function getChangeHistoryContext(ecosystemRoot, changes) {
|
|
107
|
+
const list = loadExperience(ecosystemRoot);
|
|
108
|
+
const affected = new Set(changes.map(c => {
|
|
109
|
+
if (c.current_evidence && c.current_evidence.source)
|
|
110
|
+
return c.current_evidence.source;
|
|
111
|
+
if (c.base_evidence && c.base_evidence.source)
|
|
112
|
+
return c.base_evidence.source;
|
|
113
|
+
return c.artifact_id;
|
|
114
|
+
}));
|
|
115
|
+
const results = [];
|
|
116
|
+
for (const exp of list) {
|
|
117
|
+
if (exp.status === "RETIRED")
|
|
118
|
+
continue;
|
|
119
|
+
if (exp.affected_components && exp.affected_components.some(c => affected.has(c))) {
|
|
120
|
+
results.push(exp);
|
|
121
|
+
}
|
|
122
|
+
if (results.length >= 10)
|
|
123
|
+
break; // Bounded
|
|
124
|
+
}
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
export function findRegressionTests(ecosystemRoot, changes, tests) {
|
|
128
|
+
const affected = new Set(changes.map(c => c.artifact_id));
|
|
129
|
+
return tests.filter(t => t.target_components && t.target_components.some((c) => affected.has(c)));
|
|
130
|
+
}
|
|
131
|
+
// --- Risk Indicators ---
|
|
132
|
+
export function analyzeRegressionRisk(changes, history, drift, reqDrift, traceDrift, regressionTests) {
|
|
133
|
+
const risks = [];
|
|
134
|
+
if (history.length > 0) {
|
|
135
|
+
const failures = history.filter(h => h.outcome === "FAILURE" || h.outcome === "BLOCKED");
|
|
136
|
+
if (failures.length > 0) {
|
|
137
|
+
risks.push({ indicator: "HISTORICAL_FAILURE_MATCH", evidence: { matching_history: failures.map(f => f.id) }, risk_level: "HIGH_EVIDENCE_RISK" });
|
|
138
|
+
}
|
|
139
|
+
const experiences = history.filter(h => h.status === "VALIDATED");
|
|
140
|
+
if (experiences.length > 0) {
|
|
141
|
+
risks.push({ indicator: "HISTORICAL_VALIDATED_EXPERIENCE_MATCH", evidence: { matching_experience: experiences.map(e => e.id) }, risk_level: "MODERATE_EVIDENCE_RISK" });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (drift.length > 0) {
|
|
145
|
+
risks.push({ indicator: "ARCHITECTURE_DRIFT", evidence: { drift_count: drift.length }, risk_level: "MODERATE_EVIDENCE_RISK" });
|
|
146
|
+
}
|
|
147
|
+
if (traceDrift.length > 0) {
|
|
148
|
+
risks.push({ indicator: "TRACEABILITY_GAP", evidence: { gaps: traceDrift }, risk_level: "MODERATE_EVIDENCE_RISK" });
|
|
149
|
+
}
|
|
150
|
+
// Cross-service
|
|
151
|
+
const services = new Set();
|
|
152
|
+
changes.forEach(c => {
|
|
153
|
+
const node = c.current_evidence || c.base_evidence;
|
|
154
|
+
if (node?.repository)
|
|
155
|
+
services.add(node.repository);
|
|
156
|
+
if (node?.id?.startsWith("SERVICE:"))
|
|
157
|
+
services.add(node.id);
|
|
158
|
+
});
|
|
159
|
+
if (services.size > 1) {
|
|
160
|
+
risks.push({ indicator: "CROSS_SERVICE_CHANGE", evidence: { services: Array.from(services) }, risk_level: "HIGH_EVIDENCE_RISK" });
|
|
161
|
+
}
|
|
162
|
+
return risks;
|
|
163
|
+
}
|
|
164
|
+
export function getRegressionContext(ecosystemRoot, base, current, reqs, links, allTests) {
|
|
165
|
+
const changes = compareRepositorySnapshots(base, current);
|
|
166
|
+
const archDrift = detectArchitectureDrift(base, current);
|
|
167
|
+
const reqDrift = detectRequirementDrift(reqs, current);
|
|
168
|
+
const traceDrift = detectTraceabilityDrift(links, current);
|
|
169
|
+
const history = getChangeHistoryContext(ecosystemRoot, changes);
|
|
170
|
+
const regTests = findRegressionTests(ecosystemRoot, changes, allTests);
|
|
171
|
+
// Check regression test gaps
|
|
172
|
+
if (history.some(h => h.outcome === "FAILURE") && regTests.length === 0) {
|
|
173
|
+
traceDrift.push({ condition: "REQUIRED_REGRESSION_TEST_MISSING", source_id: "MULTIPLE" });
|
|
174
|
+
}
|
|
175
|
+
const risks = analyzeRegressionRisk(changes, history, archDrift, reqDrift, traceDrift, regTests);
|
|
176
|
+
return {
|
|
177
|
+
current_changes: changes.slice(0, 50), // Bounded
|
|
178
|
+
architecture_drift: archDrift.slice(0, 20),
|
|
179
|
+
requirement_drift: reqDrift.slice(0, 20),
|
|
180
|
+
traceability_drift: traceDrift.slice(0, 20),
|
|
181
|
+
historical_experience: history.filter(h => h.status === "VALIDATED"),
|
|
182
|
+
historical_failures: history.filter(h => h.outcome === "FAILURE" || h.outcome === "BLOCKED"),
|
|
183
|
+
regression_tests: regTests,
|
|
184
|
+
risk_indicators: risks
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function recordRegressionObservation(ecosystemRoot, observation) {
|
|
188
|
+
const p = path.join(ecosystemRoot, '.secufusion', 'regressions.json');
|
|
189
|
+
let list = [];
|
|
190
|
+
if (fs.existsSync(p)) {
|
|
191
|
+
list = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
192
|
+
}
|
|
193
|
+
list.push({ ...observation, id: `OBS-${Date.now()}` });
|
|
194
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
195
|
+
fs.writeFileSync(p, JSON.stringify(list, null, 2), "utf-8");
|
|
196
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
export function getRequirementPath(ecosystemRoot, reqId) {
|
|
4
|
+
const reqDir = path.join(ecosystemRoot, ".secufusion", "requirements");
|
|
5
|
+
if (!fs.existsSync(reqDir)) {
|
|
6
|
+
fs.mkdirSync(reqDir, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
return path.join(reqDir, `${reqId}.json`);
|
|
9
|
+
}
|
|
10
|
+
export function loadRequirement(ecosystemRoot, reqId) {
|
|
11
|
+
const p = getRequirementPath(ecosystemRoot, reqId);
|
|
12
|
+
if (!fs.existsSync(p))
|
|
13
|
+
return null;
|
|
14
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
15
|
+
}
|
|
16
|
+
export function saveRequirement(ecosystemRoot, req) {
|
|
17
|
+
const p = getRequirementPath(ecosystemRoot, req.id);
|
|
18
|
+
fs.writeFileSync(p, JSON.stringify(req, null, 2));
|
|
19
|
+
}
|
|
20
|
+
export function validateStateTransition(current, next, req) {
|
|
21
|
+
if (next === "BLOCKED" || next === "SUPERSEDED")
|
|
22
|
+
return true;
|
|
23
|
+
const validFlows = {
|
|
24
|
+
"RAW": ["UNDERSTOOD", "INVESTIGATING", "BLOCKED", "SUPERSEDED"],
|
|
25
|
+
"UNDERSTOOD": ["INVESTIGATING", "BLOCKED", "SUPERSEDED"],
|
|
26
|
+
"INVESTIGATING": ["CONFIRMED", "CLARIFICATION_REQUIRED", "BLOCKED", "SUPERSEDED"],
|
|
27
|
+
"CLARIFICATION_REQUIRED": ["INVESTIGATING", "BLOCKED", "SUPERSEDED"],
|
|
28
|
+
"CONFIRMED": ["INVESTIGATING", "BLOCKED", "SUPERSEDED"],
|
|
29
|
+
"BLOCKED": ["INVESTIGATING", "SUPERSEDED"],
|
|
30
|
+
"SUPERSEDED": []
|
|
31
|
+
};
|
|
32
|
+
if (!validFlows[current].includes(next)) {
|
|
33
|
+
throw new Error(`Invalid transition from ${current} to ${next}`);
|
|
34
|
+
}
|
|
35
|
+
if (next === "CONFIRMED") {
|
|
36
|
+
if (req.blockers && req.blockers.length > 0) {
|
|
37
|
+
throw new Error("Cannot transition to CONFIRMED with active blockers.");
|
|
38
|
+
}
|
|
39
|
+
const openClarifications = req.clarification_questions.filter(c => c.state === "OPEN" && c.blocking);
|
|
40
|
+
if (openClarifications.length > 0) {
|
|
41
|
+
throw new Error("Cannot transition to CONFIRMED with OPEN blocking clarification questions.");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
export function generateConfirmedSpecification(req) {
|
|
47
|
+
let spec = `---
|
|
48
|
+
Requirement ID: ${req.id}
|
|
49
|
+
Title: ${req.title || "Untitled"}
|
|
50
|
+
Status: ${req.status}
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
# CONFIRMED SPECIFICATION
|
|
54
|
+
|
|
55
|
+
## WHAT THE USER REQUESTED
|
|
56
|
+
${req.raw_request || "Not provided."}
|
|
57
|
+
|
|
58
|
+
## WHAT THE REPOSITORY CURRENTLY DOES
|
|
59
|
+
(Based on gathered facts)
|
|
60
|
+
${req.facts ? req.facts.map(f => "- " + f).join("\\n") : "No facts recorded."}
|
|
61
|
+
|
|
62
|
+
## WHAT WAS CONFIRMED BY THE USER
|
|
63
|
+
${req.decisions ? req.decisions.map(d => "- " + d).join("\\n") : "No specific decisions recorded."}
|
|
64
|
+
|
|
65
|
+
## WHAT REMAINS UNKNOWN
|
|
66
|
+
${req.unknowns ? req.unknowns.map(u => "- " + u).join("\\n") : "None."}
|
|
67
|
+
|
|
68
|
+
## SCOPE & DETAILS
|
|
69
|
+
- Scope: ${req.scope || "Unknown"}
|
|
70
|
+
- Tenancy: ${req.tenancy || "Unknown"}
|
|
71
|
+
- Authorization: ${req.authorization || "Unknown"}
|
|
72
|
+
- Data Lifecycle: ${req.data_lifecycle || "Unknown"}
|
|
73
|
+
|
|
74
|
+
### Technical Implications
|
|
75
|
+
- APIs: ${req.apis ? req.apis.join(", ") : "None"}
|
|
76
|
+
- Events: ${req.events ? req.events.join(", ") : "None"}
|
|
77
|
+
- Persistence: ${req.persistence ? req.persistence.join(", ") : "None"}
|
|
78
|
+
- Frontend: ${req.frontend ? req.frontend.join(", ") : "None"}
|
|
79
|
+
|
|
80
|
+
## ACCEPTANCE CRITERIA
|
|
81
|
+
${req.acceptance_criteria ? req.acceptance_criteria.map(a => "- [ ] " + a).join("\\n") : "None specified."}
|
|
82
|
+
`;
|
|
83
|
+
return spec;
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
function runQuery(graph, query, depth) {
|
|
4
|
+
const queryLower = query.toLowerCase();
|
|
5
|
+
let startNodes = graph.nodes.filter((n) => (n.id.toLowerCase().includes(queryLower) || n.name.toLowerCase().includes(queryLower)));
|
|
6
|
+
const resultNodes = new Map();
|
|
7
|
+
const resultEdges = new Map();
|
|
8
|
+
let currentLayer = startNodes;
|
|
9
|
+
for (let d = 0; d <= depth; d++) {
|
|
10
|
+
const nextLayer = [];
|
|
11
|
+
for (const n of currentLayer) {
|
|
12
|
+
if (!resultNodes.has(n.id))
|
|
13
|
+
resultNodes.set(n.id, n);
|
|
14
|
+
}
|
|
15
|
+
if (d < depth) {
|
|
16
|
+
for (const edge of graph.edges) {
|
|
17
|
+
if (resultNodes.has(edge.source) || resultNodes.has(edge.target)) {
|
|
18
|
+
if (!resultEdges.has(edge.id)) {
|
|
19
|
+
resultEdges.set(edge.id, edge);
|
|
20
|
+
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
|
21
|
+
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
|
22
|
+
if (sourceNode && !resultNodes.has(sourceNode.id))
|
|
23
|
+
nextLayer.push(sourceNode);
|
|
24
|
+
if (targetNode && !resultNodes.has(targetNode.id))
|
|
25
|
+
nextLayer.push(targetNode);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
currentLayer = nextLayer;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
query,
|
|
34
|
+
nodes: Array.from(resultNodes.values()),
|
|
35
|
+
edges: Array.from(resultEdges.values())
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ecosystemRoot = "c:\\Users\\Yash\\Desktop\\mcp";
|
|
39
|
+
const dnaPath = path.join(ecosystemRoot, ".secufusion", "dna.json");
|
|
40
|
+
if (!fs.existsSync(dnaPath)) {
|
|
41
|
+
console.error("No dna.json found at", dnaPath);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const dna = JSON.parse(fs.readFileSync(dnaPath, 'utf8'));
|
|
45
|
+
const graph = dna.graph;
|
|
46
|
+
if (!graph) {
|
|
47
|
+
console.error("No graph in dna.json");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
console.log("--- User Deletion Benchmark ---");
|
|
51
|
+
const userRes = runQuery(graph, "User", 2);
|
|
52
|
+
console.log(`Found ${userRes.nodes.length} nodes and ${userRes.edges.length} edges for 'User'.`);
|
|
53
|
+
// Print paths
|
|
54
|
+
userRes.edges.slice(0, 10).forEach(e => {
|
|
55
|
+
console.log(` ${e.source} --[${e.type}]--> ${e.target}`);
|
|
56
|
+
});
|
|
57
|
+
console.log("\n--- Retention Policy Benchmark ---");
|
|
58
|
+
const retRes = runQuery(graph, "retention", 2);
|
|
59
|
+
console.log(`Found ${retRes.nodes.length} nodes and ${retRes.edges.length} edges for 'retention'.`);
|
|
60
|
+
retRes.edges.slice(0, 10).forEach(e => {
|
|
61
|
+
console.log(` ${e.source} --[${e.type}]--> ${e.target}`);
|
|
62
|
+
});
|