@maxgfr/codeindex 2.17.1 → 2.18.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/src/scan.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { basename } from "node:path";
2
- import type { FileRecord } from "./types.js";
3
- import { walk, readText, type WalkResult } from "./walk.js";
2
+ import type { FileRecord, FileKind } from "./types.js";
3
+ import { walk, readText, type WalkResult, type WalkedFile } from "./walk.js";
4
4
  import { headCommit } from "./git.js";
5
5
  import { sha1 } from "./hash.js";
6
6
  import { classify, MARKDOWN_EXT } from "./classify.js";
@@ -73,6 +73,18 @@ export interface ScanOptions {
73
73
  // from the tree. Exists for callers that already walked (e.g. a freshness
74
74
  // probe) so scanRepo does not pay a second full directory traversal.
75
75
  precomputedWalk?: WalkResult;
76
+ // Records a worker pool already extracted (rel → record + the stat the worker
77
+ // itself observed). Consulted after the cache fastpaths and before the read,
78
+ // and only when the worker's stat matches the walk's. Records are built by the
79
+ // shared buildCodeRecord, so a hit is byte-identical to extracting here.
80
+ // Populated by scanRepoParallel — never set this by hand.
81
+ extracted?: Map<string, ExtractedRecord>;
82
+ }
83
+
84
+ export interface ExtractedRecord {
85
+ size: number;
86
+ mtimeMs: number;
87
+ record: FileRecord;
76
88
  }
77
89
 
78
90
  function countLines(s: string): number {
@@ -82,9 +94,54 @@ function countLines(s: string): number {
82
94
  return n;
83
95
  }
84
96
 
85
- // Walk the repo once and turn every in-scope file into a FileRecord. Pure file
86
- // I/O + deterministic extraction never reads the repo into the model.
87
- export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
97
+ // The FileRecord for ONE code file. Factored out because the worker pool builds
98
+ // records off the main thread and they must be identical to the sequential
99
+ // ones, byte for byte sharing this function is what guarantees that rather
100
+ // than two copies of the same logic drifting apart.
101
+ export function buildCodeRecord(
102
+ rel: string,
103
+ ext: string,
104
+ size: number,
105
+ content: string,
106
+ hash: string,
107
+ lang: string,
108
+ opts: { maxCallsPerFile?: number } = {},
109
+ ): FileRecord {
110
+ const record: FileRecord = {
111
+ rel,
112
+ ext,
113
+ size,
114
+ lines: countLines(content),
115
+ hash,
116
+ kind: "code",
117
+ lang,
118
+ headings: [],
119
+ symbols: [],
120
+ refs: [],
121
+ };
122
+ if (content) {
123
+ const code = extractCode(rel, ext, content, { maxCallsPerFile: opts.maxCallsPerFile });
124
+ record.title = basename(rel);
125
+ record.summary = code.summary;
126
+ record.symbols = code.symbols;
127
+ record.refs = code.refs;
128
+ record.pkg = code.pkg;
129
+ record.idents = code.idents;
130
+ record.calls = code.calls;
131
+ record.importedNames = code.importedNames;
132
+ } else {
133
+ record.title = basename(rel);
134
+ }
135
+ return record;
136
+ }
137
+
138
+ // Which walked files this scan keeps, and how each is labelled. Shared by
139
+ // scanRepo and scanSummary so the two can never disagree on a file count or a
140
+ // language histogram — the summary path is exactly this loop, stopped early.
141
+ function* keptFiles(
142
+ root: string,
143
+ opts: ScanOptions,
144
+ ): Generator<{ f: WalkedFile; kind: FileKind; lang: string }, WalkTotals, void> {
88
145
  const scoped = opts.scope ? [...(opts.include ?? []), `${opts.scope.replace(/\/+$/, "")}/**`] : opts.include;
89
146
  const include = compileGlobs(scoped);
90
147
  const exclude = compileGlobs(opts.exclude);
@@ -100,6 +157,61 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
100
157
  // would describe the encyclopedia instead of the code.
101
158
  const outPrefix = opts.out ? opts.out.replace(/\/+$/, "") + "/" : null;
102
159
 
160
+ for (const f of walked) {
161
+ if (outPrefix && (f.abs === opts.out || f.abs.startsWith(outPrefix))) continue;
162
+ if (include && !include(f.rel)) continue;
163
+ if (exclude && exclude(f.rel)) continue;
164
+ yield { f, kind: classify(f.rel, f.ext), lang: extToLang(f.ext) };
165
+ }
166
+ return { capped, excluded };
167
+ }
168
+
169
+ interface WalkTotals {
170
+ capped: boolean;
171
+ excluded: number;
172
+ }
173
+
174
+ // The code files this scan would extract, in the same order scanRepo sees them.
175
+ // The worker pool builds its job list from here so the set it extracts is
176
+ // exactly the set scanRepo would have extracted — no file gets a worker record
177
+ // that the sequential loop would have skipped, and vice versa.
178
+ export function keptCodeFiles(root: string, opts: ScanOptions = {}): { f: WalkedFile; lang: string }[] {
179
+ const out: { f: WalkedFile; lang: string }[] = [];
180
+ const it = keptFiles(root, opts);
181
+ for (let step = it.next(); !step.done; step = it.next()) {
182
+ if (step.value.kind === "code") out.push({ f: step.value.f, lang: step.value.lang });
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // File count + language histogram WITHOUT reading or parsing a single file.
188
+ // `codeindex scan` and the MCP `scan_summary` tool only ever report these, but
189
+ // used to pay a full scanRepo — i.e. tree-sitter over the whole repo — to get
190
+ // them (6.5s on a 7k-file repo, versus ~0.1s here).
191
+ export interface ScanSummary {
192
+ root: string;
193
+ commit?: string;
194
+ fileCount: number;
195
+ languages: Record<string, number>;
196
+ capped: boolean;
197
+ excluded: number;
198
+ }
199
+
200
+ export function scanSummary(root: string, opts: ScanOptions = {}): ScanSummary {
201
+ const languages: Record<string, number> = {};
202
+ let fileCount = 0;
203
+ const it = keptFiles(root, opts);
204
+ let step = it.next();
205
+ for (; !step.done; step = it.next()) {
206
+ languages[step.value.lang] = (languages[step.value.lang] ?? 0) + 1;
207
+ fileCount++;
208
+ }
209
+ return { root, commit: headCommit(root), fileCount, languages, capped: step.value.capped, excluded: step.value.excluded };
210
+ }
211
+
212
+ // Walk the repo once and turn every in-scope file into a FileRecord. Pure file
213
+ // I/O + deterministic extraction — never reads the repo into the model.
214
+ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
103
215
  const files: FileRecord[] = [];
104
216
  const languages: Record<string, number> = {};
105
217
  const docText = new Map<string, string>();
@@ -114,13 +226,10 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
114
226
  // With no cache, persisting one is all new bytes — dirty by definition.
115
227
  let cacheDirty = cache === undefined;
116
228
 
117
- for (const f of walked) {
118
- if (outPrefix && (f.abs === opts.out || f.abs.startsWith(outPrefix))) continue;
119
- if (include && !include(f.rel)) continue;
120
- if (exclude && exclude(f.rel)) continue;
121
-
122
- const kind = classify(f.rel, f.ext);
123
- const lang = extToLang(f.ext);
229
+ const it = keptFiles(root, opts);
230
+ let step = it.next();
231
+ for (; !step.done; step = it.next()) {
232
+ const { f, kind, lang } = step.value;
124
233
  languages[lang] = (languages[lang] ?? 0) + 1;
125
234
  mtimes.set(f.rel, f.mtimeMs); // persist the fastpath key for the next build
126
235
 
@@ -147,12 +256,23 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
147
256
  continue;
148
257
  }
149
258
 
259
+ // A record a worker already read, hashed and extracted for this exact file
260
+ // (see pool.ts). Accepted only when the worker's own stat agrees with the
261
+ // walk's, so a file rewritten between the two is re-read here instead.
262
+ //
263
+ // It supplies the HASH as well as the record, which is what lets the cache
264
+ // comparison below stay in its original position. Consulting it any earlier
265
+ // would skip the hash-hit branch, and a bare `touch` — same content, new
266
+ // mtime — would then report the scan as changed and rewrite every artifact.
267
+ const pre = opts.extracted?.get(f.rel);
268
+ const preUsable = pre && pre.size === f.size && pre.mtimeMs === f.mtimeMs ? pre : undefined;
269
+
150
270
  // Read + hash (the staleness oracle stays exact); only EXTRACTION is cached. A
151
271
  // hash hit reuses the previous record — content is byte-identical, so every
152
272
  // derived field is too. classify()/extToLang() depend only on the path, so
153
273
  // kind/lang are stable across the hit.
154
- const content = readText(f.abs);
155
- const hash = sha1(content);
274
+ const content = preUsable ? undefined : readText(f.abs);
275
+ const hash = preUsable ? preUsable.record.hash : sha1(content!);
156
276
  if (cached && cached.hash === hash) {
157
277
  files.push(cached.record);
158
278
  if (kind === "doc" && content) docText.set(f.rel, content);
@@ -167,11 +287,16 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
167
287
  allReused = false;
168
288
  cacheDirty = true;
169
289
 
290
+ if (preUsable) {
291
+ files.push(preUsable.record);
292
+ continue;
293
+ }
294
+
170
295
  const record: FileRecord = {
171
296
  rel: f.rel,
172
297
  ext: f.ext,
173
298
  size: f.size,
174
- lines: countLines(content),
299
+ lines: countLines(content!),
175
300
  hash,
176
301
  kind,
177
302
  lang,
@@ -230,8 +355,8 @@ export function scanRepo(root: string, opts: ScanOptions = {}): RepoScan {
230
355
  languages,
231
356
  docText,
232
357
  mtimes,
233
- capped,
234
- excluded,
358
+ capped: step.value.capped,
359
+ excluded: step.value.excluded,
235
360
  contentUnchanged: allReused,
236
361
  cacheDirty,
237
362
  };
@@ -0,0 +1,164 @@
1
+ // Graph traversal over a built link-graph: "what depends on this" (impact) and
2
+ // "what sits next to this" (neighbors). Both are pure functions of a `Graph` —
3
+ // no scan, no filesystem — so a consumer holding a persisted graph.json can
4
+ // answer them without re-walking the repo.
5
+ //
6
+ // Both share one hub gate. A hyper-connected node (a barrel, a types module)
7
+ // otherwise drags the entire graph into any depth-≥2 neighbourhood, which is
8
+ // the difference between an answer and a dump.
9
+ import type { Edge, Graph } from "./types.js";
10
+ import { byStr } from "./sort.js";
11
+
12
+ // Only these edge kinds carry a real "depends on" relation. A doc-link or a
13
+ // mention says something references the name, not that it would break.
14
+ const DEPENDS_KINDS = new Set(["import", "use", "call"]);
15
+
16
+ // The hub-gating threshold over a degree distribution: max(50, p99). p99 = the
17
+ // degree at index min(n-1, floor(0.99n)) of the ASCENDING degree array (numeric
18
+ // sort — deterministic without byStr).
19
+ //
20
+ // Deliberately DEGREE-based even though the graph carries pagerank/betweenness:
21
+ // the gate exists to bound traversal fan-out, and degree IS the fan-out cost —
22
+ // a high-pagerank/low-degree node is safe to expand through. It is also
23
+ // computed per call, so a kind-filtered view gates on that same subgraph rather
24
+ // than on a whole-graph metric that does not describe it. The 50 floor makes it
25
+ // a no-op on small graphs (never worse than ungated).
26
+ export function hubThreshold(degrees: number[]): number {
27
+ const sorted = degrees.slice().sort((a, b) => a - b);
28
+ const n = sorted.length;
29
+ const p99 = n === 0 ? 0 : sorted[Math.min(n - 1, Math.floor(0.99 * n))]!;
30
+ return Math.max(50, p99);
31
+ }
32
+
33
+ export interface ImpactedFile {
34
+ rel: string;
35
+ module: string;
36
+ depth: number; // hops from the target (1 = direct dependent)
37
+ }
38
+
39
+ export interface ImpactResult {
40
+ target: string;
41
+ scope: "module" | "file";
42
+ seeds: string[]; // the files whose dependents we traced
43
+ files: ImpactedFile[]; // transitive dependents, nearest first
44
+ modules: string[]; // distinct modules touched
45
+ }
46
+
47
+ // Reverse dependency closure: every file that transitively IMPORTS, USES, or
48
+ // CALLS one of `seeds`, out to `depth` hops (default: the full closure).
49
+ export function reverseClosure(edges: Edge[], seeds: string[], depth = Infinity): Map<string, number> {
50
+ const dependents = new Map<string, Edge[]>(); // target file → incoming depends-on edges
51
+ for (const e of edges) {
52
+ if (e.dangling || !DEPENDS_KINDS.has(e.kind)) continue;
53
+ let arr = dependents.get(e.to);
54
+ if (!arr) dependents.set(e.to, (arr = []));
55
+ arr.push(e);
56
+ }
57
+ const depthOf = new Map<string, number>();
58
+ const seen = new Set<string>(seeds);
59
+ let frontier = [...seeds];
60
+ for (let d = 1; d <= depth && frontier.length; d++) {
61
+ const next: string[] = [];
62
+ for (const node of frontier) {
63
+ for (const e of (dependents.get(node) ?? []).slice().sort((a, b) => byStr(a.from, b.from))) {
64
+ if (seen.has(e.from)) continue;
65
+ seen.add(e.from);
66
+ depthOf.set(e.from, d);
67
+ next.push(e.from);
68
+ }
69
+ }
70
+ frontier = next;
71
+ }
72
+ return depthOf;
73
+ }
74
+
75
+ // "What breaks if I change this." Accepts a module slug or a file rel.
76
+ export function impactOf(graph: Graph, target: string, depth = Infinity): ImpactResult | undefined {
77
+ const moduleOf = new Map(graph.files.map((f) => [f.rel, f.module]));
78
+ const mod = graph.modules.find((m) => m.slug === target);
79
+ const file = mod ? undefined : graph.files.find((f) => f.rel === target);
80
+ if (!mod && !file) return undefined;
81
+
82
+ const seeds = mod ? mod.members : [file!.rel];
83
+ const depthOf = reverseClosure(graph.fileEdges, seeds, depth);
84
+ const files: ImpactedFile[] = [...depthOf.entries()]
85
+ .map(([rel, d]) => ({ rel, module: moduleOf.get(rel) ?? "root", depth: d }))
86
+ .sort((a, b) => a.depth - b.depth || byStr(a.rel, b.rel));
87
+ const modules = [...new Set(files.map((f) => f.module).filter((m) => m !== target))].sort(byStr);
88
+
89
+ return { target, scope: mod ? "module" : "file", seeds, files, modules };
90
+ }
91
+
92
+ export interface NeighborLink {
93
+ node: string;
94
+ direction: "out" | "in";
95
+ kind: string;
96
+ weight: number;
97
+ depth: number;
98
+ confidence?: "extracted" | "inferred"; // only on a `call` edge — see Edge.confidence
99
+ }
100
+
101
+ export interface NeighborResult {
102
+ target: string;
103
+ scope: "module" | "file";
104
+ links: NeighborLink[];
105
+ members?: string[]; // for a module target
106
+ }
107
+
108
+ // Breadth-first walk from `start`, out to `depth` hops, in BOTH directions.
109
+ // With `kinds` set, only those edge kinds are traversed — and the degree
110
+ // distribution feeding the hub gate is measured over that same filtered
111
+ // subgraph, so the gate reflects the view the caller asked for.
112
+ function bfs(edges: Edge[], start: string, depth: number, kinds?: Set<string>): NeighborLink[] {
113
+ const out = new Map<string, Edge[]>();
114
+ const inn = new Map<string, Edge[]>();
115
+ const degree = new Map<string, number>();
116
+ for (const e of edges) {
117
+ if (e.dangling) continue;
118
+ if (kinds && !kinds.has(e.kind)) continue;
119
+ (out.get(e.from) ?? out.set(e.from, []).get(e.from)!).push(e);
120
+ (inn.get(e.to) ?? inn.set(e.to, []).get(e.to)!).push(e);
121
+ degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
122
+ degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
123
+ }
124
+ // A non-start node at or above the threshold is EMITTED as a link but never
125
+ // expanded THROUGH. Only bites at depth ≥ 2 — depth-1 links all come from
126
+ // `start`, which always expands.
127
+ const threshold = hubThreshold([...degree.values()]);
128
+ const seen = new Set<string>([start]);
129
+ const links: NeighborLink[] = [];
130
+ let frontier = [start];
131
+ for (let d = 1; d <= depth; d++) {
132
+ const next: string[] = [];
133
+ for (const node of frontier) {
134
+ if (node !== start && (degree.get(node) ?? 0) >= threshold) continue;
135
+ for (const e of (out.get(node) ?? []).slice().sort((a, b) => byStr(a.to, b.to))) {
136
+ if (seen.has(e.to)) continue;
137
+ links.push({ node: e.to, direction: "out", kind: e.kind, weight: e.weight, depth: d, confidence: e.confidence });
138
+ seen.add(e.to);
139
+ next.push(e.to);
140
+ }
141
+ for (const e of (inn.get(node) ?? []).slice().sort((a, b) => byStr(a.from, b.from))) {
142
+ if (seen.has(e.from)) continue;
143
+ links.push({ node: e.from, direction: "in", kind: e.kind, weight: e.weight, depth: d, confidence: e.confidence });
144
+ seen.add(e.from);
145
+ next.push(e.from);
146
+ }
147
+ }
148
+ frontier = next;
149
+ }
150
+ return links;
151
+ }
152
+
153
+ // What links to / from a module slug or a file rel, out to `depth` hops.
154
+ export function neighborsOf(graph: Graph, target: string, depth = 1, kinds?: Set<string>): NeighborResult | undefined {
155
+ const mod = graph.modules.find((m) => m.slug === target);
156
+ if (mod) {
157
+ return { target, scope: "module", links: bfs(graph.moduleEdges, target, depth, kinds), members: mod.members };
158
+ }
159
+ const file = graph.files.find((f) => f.rel === target);
160
+ if (file) {
161
+ return { target, scope: "file", links: bfs(graph.fileEdges, target, depth, kinds) };
162
+ }
163
+ return undefined;
164
+ }
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single source of truth for the engine version the bundle reports. Kept in
2
2
  // lockstep with package.json by the release pipeline. Do not edit by hand
3
3
  // outside a release.
4
- export const ENGINE_VERSION = "2.17.1";
4
+ export const ENGINE_VERSION = "2.18.0";
5
5
 
6
6
  // Bumped whenever the on-disk artifact shape changes, so a consumer can reject
7
7
  // an index written by an incompatible engine instead of misreading it. The
package/src/viz.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Mermaid rendering of the module graph — inline-renderable in Claude, GitHub
2
2
  // and most markdown viewers with zero infrastructure (the counter to graph-DB
3
3
  // browsers). Deterministic: sorted nodes/edges, stable ids.
4
- import type { Graph } from "./types.js";
4
+ import type { Graph, ModuleNode, Tier } from "./types.js";
5
5
  import { byStr } from "./sort.js";
6
6
 
7
7
  export interface MermaidOptions {
@@ -42,3 +42,93 @@ export function renderMermaid(graph: Graph, opts: MermaidOptions = {}): string {
42
42
  if (dropped) lines.push(` %% ${dropped} lighter edges omitted (maxEdges=${maxEdges})`);
43
43
  return lines.join("\n") + "\n";
44
44
  }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Clustered whole-graph view. `renderMermaid` above answers "what sits around
48
+ // THIS module" and returns raw mermaid; this one answers "what does the whole
49
+ // repo look like" — grouped into tier subgraphs, capped by MODULE count as well
50
+ // as edge count, fenced, and reporting exactly what it dropped.
51
+ //
52
+ // Two functions rather than one flag because the truncation contract differs:
53
+ // the neighborhood view caps edges only and notes the drop in a comment, while
54
+ // a whole-graph view has to bound nodes first (a 400-module repo is unreadable
55
+ // long before it hits 80 edges) and hand the caller the counts so it can say so
56
+ // in prose. Returning counts from the existing function would have been a
57
+ // breaking change for its CLI and MCP callers.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export interface ClusteredMermaidResult {
61
+ content: string; // a fenced ```mermaid block, ready to embed in markdown
62
+ shownModules: number;
63
+ totalModules: number;
64
+ shownEdges: number;
65
+ totalEdges: number;
66
+ }
67
+
68
+ export interface ClusteredMermaidOptions {
69
+ maxModules?: number; // default 40 — most-connected first
70
+ maxEdges?: number; // default 80 — heaviest first
71
+ title?: string; // leading %% comment; defaults to "module graph"
72
+ }
73
+
74
+ const TIER_LABEL: Record<Tier, string> = { 0: "Foundations", 1: "Features", 2: "Tail" };
75
+ const CLUSTER_MAX_MODULES = 40;
76
+ const CLUSTER_MAX_EDGES = 80;
77
+
78
+ const degreeOf = (m: ModuleNode): number => m.degIn + m.degOut;
79
+
80
+ // Mermaid node ids must be identifier-safe; slugs may contain dashes. Prefixed
81
+ // so a slug starting with a digit still yields a valid id.
82
+ function clusterNodeId(slug: string): string {
83
+ return "m_" + slug.replace(/[^A-Za-z0-9_]/g, "_");
84
+ }
85
+
86
+ export function renderMermaidClustered(
87
+ graph: Graph,
88
+ opts: ClusteredMermaidOptions = {},
89
+ ): ClusteredMermaidResult {
90
+ const maxModules = opts.maxModules ?? CLUSTER_MAX_MODULES;
91
+ const maxEdges = opts.maxEdges ?? CLUSTER_MAX_EDGES;
92
+
93
+ const ranked = graph.modules.slice().sort((a, b) => degreeOf(b) - degreeOf(a) || byStr(a.slug, b.slug));
94
+ const shown = ranked.slice(0, maxModules);
95
+ const shownSet = new Set(shown.map((m) => m.slug));
96
+
97
+ // Only edges whose BOTH ends survived the module cap — a dangling half-edge
98
+ // would render as a node the caller was told was dropped.
99
+ const edges = graph.moduleEdges
100
+ .filter((e) => shownSet.has(e.from) && shownSet.has(e.to))
101
+ .sort((a, b) => b.weight - a.weight || byStr(a.from, b.from) || byStr(a.to, b.to))
102
+ .slice(0, maxEdges);
103
+
104
+ const lines: string[] = [];
105
+ lines.push(
106
+ `%% ${opts.title ?? "module graph"} — ${shown.length} of ${graph.modules.length} modules, ` +
107
+ `${edges.length} of ${graph.moduleEdges.length} edges`,
108
+ );
109
+ if (shown.length < graph.modules.length || edges.length < graph.moduleEdges.length) {
110
+ lines.push("%% truncated to the most-connected modules/edges; see graph.json for the full graph");
111
+ }
112
+ lines.push("flowchart LR");
113
+
114
+ for (const tier of [0, 1, 2] as Tier[]) {
115
+ const inTier = shown.filter((m) => m.tier === tier);
116
+ if (!inTier.length) continue;
117
+ lines.push(` subgraph ${TIER_LABEL[tier]}`);
118
+ for (const m of inTier) lines.push(` ${clusterNodeId(m.slug)}["${m.path.replace(/"/g, "'")}"]`);
119
+ lines.push(" end");
120
+ }
121
+
122
+ for (const e of edges) {
123
+ const label = e.weight > 1 ? `|${e.weight}| ` : "";
124
+ lines.push(` ${clusterNodeId(e.from)} -->${label ? " " + label : " "}${clusterNodeId(e.to)}`);
125
+ }
126
+
127
+ return {
128
+ content: "```mermaid\n" + lines.join("\n") + "\n```\n",
129
+ shownModules: shown.length,
130
+ totalModules: graph.modules.length,
131
+ shownEdges: edges.length,
132
+ totalEdges: graph.moduleEdges.length,
133
+ };
134
+ }