ts-knowledge-graph 0.1.4 → 0.1.6

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.
Files changed (136) hide show
  1. package/README.md +26 -8
  2. package/contribs/{web_visualisation → webview}/README.md +7 -7
  3. package/contribs/webview/web/css/style.css +310 -0
  4. package/contribs/{web_visualisation → webview}/web/index.html +40 -5
  5. package/contribs/{web_visualisation → webview}/web/js/app.js +378 -39
  6. package/contribs/{web_visualisation/web/data → webview/web/js_autogenerated}/kind_descriptions.js +2 -1
  7. package/contribs/{web_visualisation → webview}/web/types/app_globals.d.ts +11 -3
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +6 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/cluster/cluster_weights.d.ts +20 -0
  12. package/dist/cluster/cluster_weights.d.ts.map +1 -0
  13. package/dist/cluster/cluster_weights.js +32 -0
  14. package/dist/cluster/cluster_weights.js.map +1 -0
  15. package/dist/cluster/community_detector.d.ts +61 -0
  16. package/dist/cluster/community_detector.d.ts.map +1 -0
  17. package/dist/cluster/community_detector.js +120 -0
  18. package/dist/cluster/community_detector.js.map +1 -0
  19. package/dist/cluster/community_labeler.d.ts +84 -0
  20. package/dist/cluster/community_labeler.d.ts.map +1 -0
  21. package/dist/cluster/community_labeler.js +194 -0
  22. package/dist/cluster/community_labeler.js.map +1 -0
  23. package/dist/cluster/graph_clusterer.d.ts +47 -0
  24. package/dist/cluster/graph_clusterer.d.ts.map +1 -0
  25. package/dist/cluster/graph_clusterer.js +126 -0
  26. package/dist/cluster/graph_clusterer.js.map +1 -0
  27. package/dist/commands/benchmark_command.d.ts.map +1 -1
  28. package/dist/commands/benchmark_command.js +13 -10
  29. package/dist/commands/benchmark_command.js.map +1 -1
  30. package/dist/commands/blast_radius_command.d.ts.map +1 -1
  31. package/dist/commands/blast_radius_command.js +6 -5
  32. package/dist/commands/blast_radius_command.js.map +1 -1
  33. package/dist/commands/cluster_command.d.ts +7 -0
  34. package/dist/commands/cluster_command.d.ts.map +1 -0
  35. package/dist/commands/cluster_command.js +55 -0
  36. package/dist/commands/cluster_command.js.map +1 -0
  37. package/dist/commands/command_helpers.d.ts +9 -4
  38. package/dist/commands/command_helpers.d.ts.map +1 -1
  39. package/dist/commands/command_helpers.js +13 -8
  40. package/dist/commands/command_helpers.js.map +1 -1
  41. package/dist/commands/cost_command.d.ts.map +1 -1
  42. package/dist/commands/cost_command.js +25 -8
  43. package/dist/commands/cost_command.js.map +1 -1
  44. package/dist/commands/enrich_command.d.ts.map +1 -1
  45. package/dist/commands/enrich_command.js +7 -5
  46. package/dist/commands/enrich_command.js.map +1 -1
  47. package/dist/commands/extract_command.d.ts.map +1 -1
  48. package/dist/commands/extract_command.js +12 -6
  49. package/dist/commands/extract_command.js.map +1 -1
  50. package/dist/commands/hotspots_command.d.ts.map +1 -1
  51. package/dist/commands/hotspots_command.js +6 -5
  52. package/dist/commands/hotspots_command.js.map +1 -1
  53. package/dist/commands/install_command.d.ts +15 -5
  54. package/dist/commands/install_command.d.ts.map +1 -1
  55. package/dist/commands/install_command.js +61 -23
  56. package/dist/commands/install_command.js.map +1 -1
  57. package/dist/commands/load_command.d.ts.map +1 -1
  58. package/dist/commands/load_command.js +18 -13
  59. package/dist/commands/load_command.js.map +1 -1
  60. package/dist/commands/neighbors_command.d.ts.map +1 -1
  61. package/dist/commands/neighbors_command.js +6 -5
  62. package/dist/commands/neighbors_command.js.map +1 -1
  63. package/dist/commands/references_command.d.ts.map +1 -1
  64. package/dist/commands/references_command.js +6 -5
  65. package/dist/commands/references_command.js.map +1 -1
  66. package/dist/commands/report_command.d.ts +16 -0
  67. package/dist/commands/report_command.d.ts.map +1 -0
  68. package/dist/commands/report_command.js +115 -0
  69. package/dist/commands/report_command.js.map +1 -0
  70. package/dist/commands/webview_command.d.ts +36 -0
  71. package/dist/commands/webview_command.d.ts.map +1 -0
  72. package/dist/commands/webview_command.js +186 -0
  73. package/dist/commands/webview_command.js.map +1 -0
  74. package/dist/enrich/cpu_profile.d.ts +33 -0
  75. package/dist/enrich/cpu_profile.d.ts.map +1 -1
  76. package/dist/enrich/cpu_profile.js +88 -0
  77. package/dist/enrich/cpu_profile.js.map +1 -1
  78. package/dist/enrich/runtime_enricher.d.ts +8 -0
  79. package/dist/enrich/runtime_enricher.d.ts.map +1 -1
  80. package/dist/enrich/runtime_enricher.js +18 -0
  81. package/dist/enrich/runtime_enricher.js.map +1 -1
  82. package/dist/enrich/runtime_join.d.ts +25 -1
  83. package/dist/enrich/runtime_join.d.ts.map +1 -1
  84. package/dist/enrich/runtime_join.js +43 -0
  85. package/dist/enrich/runtime_join.js.map +1 -1
  86. package/dist/extract/git_source.d.ts +23 -0
  87. package/dist/extract/git_source.d.ts.map +1 -0
  88. package/dist/extract/git_source.js +75 -0
  89. package/dist/extract/git_source.js.map +1 -0
  90. package/dist/query/graph_query.d.ts +36 -1
  91. package/dist/query/graph_query.d.ts.map +1 -1
  92. package/dist/query/graph_query.js +69 -6
  93. package/dist/query/graph_query.js.map +1 -1
  94. package/dist/report/graph_report.d.ts +51 -0
  95. package/dist/report/graph_report.d.ts.map +1 -0
  96. package/dist/report/graph_report.js +312 -0
  97. package/dist/report/graph_report.js.map +1 -0
  98. package/dist/report/pdf_renderer.d.ts +22 -0
  99. package/dist/report/pdf_renderer.d.ts.map +1 -0
  100. package/dist/report/pdf_renderer.js +54 -0
  101. package/dist/report/pdf_renderer.js.map +1 -0
  102. package/dist/report/report_data.d.ts +128 -0
  103. package/dist/report/report_data.d.ts.map +1 -0
  104. package/dist/report/report_data.js +191 -0
  105. package/dist/report/report_data.js.map +1 -0
  106. package/dist/schema/edge.d.ts +5 -5
  107. package/dist/schema/edge.d.ts.map +1 -1
  108. package/dist/schema/edge.js +3 -0
  109. package/dist/schema/edge.js.map +1 -1
  110. package/dist/schema/source_manifest.d.ts +30 -0
  111. package/dist/schema/source_manifest.d.ts.map +1 -0
  112. package/dist/schema/source_manifest.js +21 -0
  113. package/dist/schema/source_manifest.js.map +1 -0
  114. package/dist/store/jsonl_reader.d.ts +4 -0
  115. package/dist/store/jsonl_reader.d.ts.map +1 -1
  116. package/dist/store/jsonl_reader.js +13 -1
  117. package/dist/store/jsonl_reader.js.map +1 -1
  118. package/dist/store/jsonl_store.d.ts +2 -1
  119. package/dist/store/jsonl_store.d.ts.map +1 -1
  120. package/dist/store/jsonl_store.js +4 -1
  121. package/dist/store/jsonl_store.js.map +1 -1
  122. package/dist/store/kuzu_store.d.ts +13 -0
  123. package/dist/store/kuzu_store.d.ts.map +1 -1
  124. package/dist/store/kuzu_store.js +29 -0
  125. package/dist/store/kuzu_store.js.map +1 -1
  126. package/dist/store/output_folder.d.ts +43 -0
  127. package/dist/store/output_folder.d.ts.map +1 -0
  128. package/dist/store/output_folder.js +61 -0
  129. package/dist/store/output_folder.js.map +1 -0
  130. package/dotclaude_folder/commands/code-graph-interview.md +123 -0
  131. package/dotclaude_folder/commands/code-graph-optimize.md +65 -0
  132. package/dotclaude_folder/skills/code-graph-query/SKILL.md +4 -4
  133. package/package.json +72 -62
  134. package/contribs/web_visualisation/web/css/style.css +0 -219
  135. package/contribs/web_visualisation/web/tsconfig.json +0 -18
  136. /package/contribs/{web_visualisation/web/data → webview/web/js_autogenerated}/.gitignore +0 -0
@@ -37,10 +37,11 @@ const EDGE_COLORS = {
37
37
  READS_CONFIG: '#65a30d',
38
38
  CALLS_EXTERNAL: '#e11d48',
39
39
  HANDLES: '#0ea5e9',
40
+ CALLS_RUNTIME: '#be123c',
40
41
  };
41
42
 
42
43
  /* One-line descriptions per node/edge kind, generated from src/schema into
43
- data/kind_descriptions.js. Absent (empty) when that file has not been built. */
44
+ js_autogenerated/kind_descriptions.js. Absent (empty) when that file has not been built. */
44
45
  const KIND_DESCRIPTIONS = window.KIND_DESCRIPTIONS ?? { nodes: {}, edges: {} };
45
46
 
46
47
  /* Heat ramp for runtime self-time: cool slate → yellow → red ("red = hot"). */
@@ -50,11 +51,11 @@ const HEAT_STOPS = [
50
51
  { at: 1, color: [220, 38, 38] },
51
52
  ];
52
53
 
53
- /* Un-measured nodes render at a neutral baseline, distinct from a cheap-but-measured node. */
54
- const RUNTIME_UNMEASURED_COLOR = '#243044';
55
- const RUNTIME_UNMEASURED_BORDER = '#475569';
56
54
  const HOTSPOTS_LIMIT = 12;
57
55
 
56
+ /* Persisted theme override ('light' | 'dark'); absent means follow the OS. */
57
+ const THEME_STORAGE_KEY = 'ktg.theme';
58
+
58
59
  /** @type {AppState} */
59
60
  const state = {
60
61
  nodes: [],
@@ -62,13 +63,22 @@ const state = {
62
63
  cy: undefined,
63
64
  hiddenNodeKinds: new Set(),
64
65
  hiddenEdgeKinds: new Set(),
66
+ hiddenCommunities: new Set(),
65
67
  hideIsolated: false,
66
68
  onlyMeasured: false,
67
69
  droppedFiles: { nodes: undefined, edges: undefined },
68
70
  encoding: 'structural',
69
71
  runtime: { maxSelfMs: 0, measuredCount: 0, totalSelfMs: 0 },
72
+ communities: [],
73
+ communityLabels: new Map(),
70
74
  };
71
75
 
76
+ /* Register the fcose layout extension (loaded as a CDN global, see index.html) so the
77
+ label-aware force layout is selectable. Guarded so a missing script never breaks the viewer. */
78
+ if (window.cytoscapeFcose !== undefined) {
79
+ cytoscape.use(window.cytoscapeFcose);
80
+ }
81
+
72
82
  /**
73
83
  * Looks up a required element by id, throwing when it is absent so a missing
74
84
  * template node fails loudly here instead of as a later `null` dereference.
@@ -122,18 +132,32 @@ const asInput = (target) => {
122
132
  return target;
123
133
  };
124
134
 
135
+ /**
136
+ * Narrows a change-event target to a `<select>` so `.value` can be read inside
137
+ * the encoding-selector handler.
138
+ * @param {EventTarget | null} target
139
+ * @returns {HTMLSelectElement}
140
+ */
141
+ const asSelect = (target) => {
142
+ if ((target instanceof HTMLSelectElement) === false) {
143
+ throw new Error('event target is not a select');
144
+ }
145
+ return target;
146
+ };
147
+
125
148
  /* ---------- data loading ---------- */
126
149
 
127
150
  function boot() {
128
151
  setupDropzone();
129
152
  setupFolds();
153
+ setupTheme();
130
154
  el('hide-isolated').addEventListener('change', (event) => {
131
155
  state.hideIsolated = asInput(event.target).checked;
132
156
  applyFilters();
133
157
  });
134
158
  el('relayout').addEventListener('click', () => runLayout());
135
- el('runtime-heat').addEventListener('change', (event) => {
136
- state.encoding = asInput(event.target).checked === true ? 'runtime' : 'structural';
159
+ el('encoding-select').addEventListener('change', (event) => {
160
+ state.encoding = encodingFromValue(asSelect(event.target).value);
137
161
  if (state.cy !== undefined) {
138
162
  state.cy.style(cyStyle());
139
163
  }
@@ -166,12 +190,12 @@ function boot() {
166
190
  async function tryFetch() {
167
191
  try {
168
192
  const [nodesText, edgesText] = await Promise.all([
169
- fetch('../../../outputs/graph/nodes.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
170
- fetch('../../../outputs/graph/edges.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
193
+ fetch('../../../.ts_knowledge_graph/graph/nodes.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
194
+ fetch('../../../.ts_knowledge_graph/graph/edges.jsonl').then((r) => r.ok === true ? r.text() : Promise.reject(new Error(String(r.status)))),
171
195
  ]);
172
- setData(parseJsonl(nodesText), parseJsonl(edgesText), 'fetched ../../../outputs/graph/*.jsonl');
196
+ setData(parseJsonl(nodesText), parseJsonl(edgesText), 'fetched ../../../.ts_knowledge_graph/graph/*.jsonl');
173
197
  } catch {
174
- el('status').textContent = 'no data — generate data/graph_data.js or drop the JSONL files here';
198
+ el('status').textContent = 'no data — generate js_autogenerated/graph_data.js or drop the JSONL files here';
175
199
  }
176
200
  }
177
201
 
@@ -272,6 +296,83 @@ function setupFolds() {
272
296
  }
273
297
  }
274
298
 
299
+ /* ---------- theme ---------- */
300
+
301
+ /**
302
+ * Reads a CSS custom property off the document root, trimmed. The Cytoscape
303
+ * style pulls its theme-dependent colours from the same variables the stylesheet
304
+ * uses, so switching theme is a single attribute flip plus a graph re-style.
305
+ * @param {string} name
306
+ * @returns {string}
307
+ */
308
+ function cssVar(name) {
309
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
310
+ }
311
+
312
+ /**
313
+ * Reads the persisted theme override, or `null` when none is set or storage is unavailable.
314
+ * @returns {'light' | 'dark' | null}
315
+ */
316
+ function storedTheme() {
317
+ try {
318
+ const value = localStorage.getItem(THEME_STORAGE_KEY);
319
+ return value === 'light' || value === 'dark' ? value : null;
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Resolves the active theme: an explicit stored choice wins, otherwise the OS
327
+ * `prefers-color-scheme`, otherwise dark.
328
+ * @returns {'light' | 'dark'}
329
+ */
330
+ function resolveTheme() {
331
+ const stored = storedTheme();
332
+ if (stored !== null) {
333
+ return stored;
334
+ }
335
+ return window.matchMedia('(prefers-color-scheme: light)').matches === true ? 'light' : 'dark';
336
+ }
337
+
338
+ /**
339
+ * Applies a theme: flips the `data-theme` attribute the stylesheet keys off,
340
+ * updates the toggle glyph, and re-styles the graph so its canvas-drawn colours
341
+ * (labels, selection ring, node borders) track the theme.
342
+ * @param {'light' | 'dark'} theme
343
+ */
344
+ function applyTheme(theme) {
345
+ document.documentElement.setAttribute('data-theme', theme);
346
+ const toggle = el('theme-toggle');
347
+ toggle.textContent = theme === 'dark' ? '☀' : '☾';
348
+ toggle.title = theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme';
349
+ if (state.cy !== undefined) {
350
+ state.cy.style(cyStyle());
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Wires the theme toggle: clicking persists and applies the opposite theme, and
356
+ * — while no explicit choice is stored — the viewer follows later OS changes.
357
+ */
358
+ function setupTheme() {
359
+ applyTheme(resolveTheme());
360
+ el('theme-toggle').addEventListener('click', () => {
361
+ const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
362
+ try {
363
+ localStorage.setItem(THEME_STORAGE_KEY, next);
364
+ } catch {
365
+ /* storage unavailable (private mode, file://) — apply for this session only */
366
+ }
367
+ applyTheme(next);
368
+ });
369
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
370
+ if (storedTheme() === null) {
371
+ applyTheme(event.matches === true ? 'light' : 'dark');
372
+ }
373
+ });
374
+ }
375
+
275
376
  /* ---------- graph construction ---------- */
276
377
 
277
378
  /**
@@ -305,11 +406,13 @@ function setData(nodes, edges, sourceLabel) {
305
406
  maxSelfMs = Math.max(maxSelfMs, selfMs);
306
407
  }
307
408
  state.runtime = { maxSelfMs, measuredCount, totalSelfMs };
409
+ state.communities = communityCounts(nodes);
410
+ state.communityLabels = communityLabels(nodes);
308
411
 
309
412
  const elements = [
310
413
  ...nodes.map((node) => ({
311
414
  group: 'nodes',
312
- data: { id: node.id, name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.range === undefined ? 0 : node.range.startLine, exported: node.exported === true, degree: degree.get(node.id) ?? 0, runtime: nodeRuntime(node) },
415
+ data: { id: node.id, name: node.name, kind: node.kind, filePath: node.filePath, startLine: node.range === undefined ? 0 : node.range.startLine, exported: node.exported === true, degree: degree.get(node.id) ?? 0, runtime: nodeRuntime(node), community: nodeCommunity(node) },
313
416
  })),
314
417
  ...edges
315
418
  .filter((edge) => nodeIds.has(edge.from) === true && nodeIds.has(edge.to) === true)
@@ -326,7 +429,7 @@ function setData(nodes, edges, sourceLabel) {
326
429
  container: el('cy'),
327
430
  elements,
328
431
  style: cyStyle(),
329
- layout: { name: 'cose', animate: false, padding: 30 },
432
+ layout: layoutOptions('fcose'),
330
433
  });
331
434
  state.cy.on('tap', 'node', (event) => select(event.target));
332
435
  state.cy.on('tap', (event) => {
@@ -337,21 +440,31 @@ function setData(nodes, edges, sourceLabel) {
337
440
 
338
441
  buildLegends();
339
442
  renderRuntime();
443
+ renderCommunities();
444
+ syncEncodingOptions();
340
445
  applyFilters();
341
446
  el('status').textContent = `${sourceLabel} — ${nodes.length} nodes, ${edges.length} edges`;
342
447
  }
343
448
 
344
449
  function cyStyle() {
450
+ const unmeasuredFill = cssVar('--unmeasured-fill');
451
+ const unmeasuredBorder = cssVar('--unmeasured-border');
452
+ const nodeBorder = cssVar('--graph-node-border');
453
+ const nodeBorderWidth = parseFloat(cssVar('--graph-node-border-width')) || 0;
454
+ const labelColor = cssVar('--graph-label');
455
+ const labelBg = cssVar('--graph-label-bg');
456
+ const selBorder = cssVar('--graph-sel-border');
345
457
  /** @param {CyCollection} node */
346
458
  const nodeColor = (node) => {
347
- if (state.encoding !== 'runtime') {
348
- return NODE_COLORS[node.data('kind')] ?? '#9ca3af';
459
+ if (state.encoding === 'runtime') {
460
+ const runtime = node.data('runtime');
461
+ return runtime === undefined || runtime === null ? unmeasuredFill : heatColor(runtimeFraction(runtime.selfMs));
349
462
  }
350
- const runtime = node.data('runtime');
351
- if (runtime === undefined || runtime === null) {
352
- return RUNTIME_UNMEASURED_COLOR;
463
+ if (state.encoding === 'community') {
464
+ const community = node.data('community');
465
+ return community === undefined || community === null ? unmeasuredFill : communityColor(community);
353
466
  }
354
- return heatColor(runtimeFraction(runtime.selfMs));
467
+ return NODE_COLORS[node.data('kind')] ?? '#9ca3af';
355
468
  };
356
469
  /** @param {CyCollection} node */
357
470
  const nodeSize = (node) => {
@@ -364,8 +477,15 @@ function cyStyle() {
364
477
  }
365
478
  return 12 + runtimeFraction(runtime.selfMs) * 40;
366
479
  };
367
- /** @param {CyCollection} node */
368
- const isUnmeasured = (node) => node.data('runtime') === undefined || node.data('runtime') === null;
480
+ /**
481
+ * Whether the active encoding has no value for this node un-measured in
482
+ * runtime mode, or unassigned to a community in community mode. Such nodes get
483
+ * the muted fill and a dashed border so the gap reads as "no data", not a colour.
484
+ * @param {CyCollection} node
485
+ */
486
+ const isUnencoded = (node) =>
487
+ (state.encoding === 'runtime' && (node.data('runtime') === undefined || node.data('runtime') === null))
488
+ || (state.encoding === 'community' && (node.data('community') === undefined || node.data('community') === null));
369
489
  return [
370
490
  {
371
491
  selector: 'node',
@@ -373,15 +493,19 @@ function cyStyle() {
373
493
  'background-color': nodeColor,
374
494
  'width': nodeSize,
375
495
  'height': nodeSize,
376
- 'border-width': (/** @type {CyCollection} */ node) => state.encoding === 'runtime' && isUnmeasured(node) === true ? 1 : 0,
377
- 'border-color': RUNTIME_UNMEASURED_BORDER,
378
- 'border-style': 'dashed',
496
+ 'border-width': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? 1 : nodeBorderWidth,
497
+ 'border-color': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? unmeasuredBorder : nodeBorder,
498
+ 'border-style': (/** @type {CyCollection} */ node) => isUnencoded(node) === true ? 'dashed' : 'solid',
379
499
  'label': 'data(name)',
380
- 'color': '#cbd5e1',
500
+ 'color': labelColor,
381
501
  'font-size': 8,
382
502
  'min-zoomed-font-size': 7,
383
503
  'text-valign': 'bottom',
384
504
  'text-margin-y': 3,
505
+ 'text-background-color': labelBg,
506
+ 'text-background-opacity': 0.5,
507
+ 'text-background-shape': 'roundrectangle',
508
+ 'text-background-padding': 2,
385
509
  },
386
510
  },
387
511
  {
@@ -397,21 +521,36 @@ function cyStyle() {
397
521
  },
398
522
  },
399
523
  { selector: '.hidden', style: { display: 'none' } },
400
- { selector: '.faded', style: { opacity: 0.08, 'text-opacity': 0 } },
401
- { selector: 'node.sel', style: { 'border-width': 3, 'border-color': '#ffffff', 'border-style': 'solid' } },
524
+ { selector: '.faded', style: { opacity: 0.08, 'text-opacity': 0, 'text-background-opacity': 0 } },
525
+ { selector: 'node.sel', style: { 'border-width': 3, 'border-color': selBorder, 'border-style': 'solid' } },
402
526
  ];
403
527
  }
404
528
 
529
+ /**
530
+ * Builds Cytoscape layout options for the given layout name. The force layouts
531
+ * (`fcose`, `cose`) are made label-aware via `nodeDimensionsIncludeLabels`, so each
532
+ * node's label box is factored into spacing and labels overlap their neighbours less.
533
+ * @param {string} name
534
+ * @returns {Record<string, unknown>}
535
+ */
536
+ function layoutOptions(name) {
537
+ const base = { name, animate: false, padding: 30 };
538
+ if (name === 'fcose' || name === 'cose') {
539
+ return { ...base, nodeDimensionsIncludeLabels: true };
540
+ }
541
+ if (name === 'concentric') {
542
+ return { ...base, concentric: (/** @type {CyCollection} */ node) => node.degree(), levelWidth: () => 2 };
543
+ }
544
+ return base;
545
+ }
546
+
405
547
  function runLayout() {
406
548
  const cy = state.cy;
407
549
  if (cy === undefined) {
408
550
  return;
409
551
  }
410
552
  const name = selectEl('layout-select').value;
411
- const options = name === 'concentric'
412
- ? { name, concentric: (/** @type {CyCollection} */ node) => node.degree(), levelWidth: () => 2, animate: false, padding: 30 }
413
- : { name, animate: false, padding: 30 };
414
- cy.elements(':visible').layout(options).run();
553
+ cy.elements(':visible').layout(layoutOptions(name)).run();
415
554
  }
416
555
 
417
556
  /* ---------- edge weighting ---------- */
@@ -612,7 +751,9 @@ function applyFilters() {
612
751
  const hiddenByKind = state.hiddenNodeKinds.has(node.data('kind')) === true;
613
752
  const unmeasured = node.data('runtime') === undefined || node.data('runtime') === null;
614
753
  const hiddenByMeasure = state.onlyMeasured === true && unmeasured === true;
615
- node.toggleClass('hidden', hiddenByKind === true || hiddenByMeasure === true);
754
+ const community = node.data('community');
755
+ const hiddenByCommunity = community !== undefined && community !== null && state.hiddenCommunities.has(community) === true;
756
+ node.toggleClass('hidden', hiddenByKind === true || hiddenByMeasure === true || hiddenByCommunity === true);
616
757
  });
617
758
  cy.edges().forEach((edge) => {
618
759
  edge.toggleClass('hidden', state.hiddenEdgeKinds.has(edge.data('kind')) === true);
@@ -729,7 +870,6 @@ function focusNode(id) {
729
870
  /** Renders the coverage line and the ranked hotspots list from the loaded runtime metrics. */
730
871
  function renderRuntime() {
731
872
  const section = el('runtime');
732
- const toggle = inputEl('runtime-heat');
733
873
  const measured = state.nodes
734
874
  .map((node) => ({ node, runtime: nodeRuntime(node) }))
735
875
  .filter((entry) => entry.runtime !== undefined)
@@ -738,20 +878,13 @@ function renderRuntime() {
738
878
  if (measured.length === 0) {
739
879
  section.classList.add('empty');
740
880
  el('coverage').textContent = 'no runtime data — run `enrich` to measure self-time';
741
- toggle.checked = false;
742
- toggle.disabled = true;
743
- state.encoding = 'structural';
744
881
  state.onlyMeasured = false;
745
882
  inputEl('only-measured').checked = false;
746
883
  el('hotspots').innerHTML = '';
747
- if (state.cy !== undefined) {
748
- state.cy.style(cyStyle());
749
- }
750
884
  return;
751
885
  }
752
886
 
753
887
  section.classList.remove('empty');
754
- toggle.disabled = false;
755
888
  inputEl('only-measured').disabled = false;
756
889
  el('coverage').textContent = `${state.runtime.measuredCount} / ${state.nodes.length} nodes measured · ${formatMs(state.runtime.totalSelfMs)} total self-time`;
757
890
 
@@ -766,6 +899,212 @@ function renderRuntime() {
766
899
  }
767
900
  }
768
901
 
902
+ /* ---------- community ---------- */
903
+
904
+ /**
905
+ * Reads the integer community index `cluster` attaches as `metadata.community`,
906
+ * or `undefined` when the graph has not been clustered.
907
+ * @param {RawNode} node
908
+ * @returns {number | undefined}
909
+ */
910
+ function nodeCommunity(node) {
911
+ if (node.metadata === undefined || node.metadata === null) {
912
+ return undefined;
913
+ }
914
+ const community = node.metadata.community;
915
+ return typeof community === 'number' ? community : undefined;
916
+ }
917
+
918
+ /**
919
+ * Reads the human-readable community label `cluster` attaches as
920
+ * `metadata.communityLabel`. Every clustered node carries one, written alongside
921
+ * its community index, so this is defined whenever {@link nodeCommunity} is.
922
+ * @param {RawNode} node
923
+ * @returns {string | undefined}
924
+ */
925
+ function nodeCommunityLabel(node) {
926
+ if (node.metadata === undefined || node.metadata === null) {
927
+ return undefined;
928
+ }
929
+ const label = node.metadata.communityLabel;
930
+ return typeof label === 'string' ? label : undefined;
931
+ }
932
+
933
+ /**
934
+ * A stable, theme-independent colour per community index, spread around the hue
935
+ * circle by the golden angle so adjacent indices stay distinct. Fixed
936
+ * saturation/lightness keep it legible on both the light and dark canvas, like
937
+ * the kind palette.
938
+ * @param {number} index
939
+ * @returns {string}
940
+ */
941
+ function communityColor(index) {
942
+ const hue = Math.round((index * 137.508) % 360);
943
+ return `hsl(${hue}, 65%, 55%)`;
944
+ }
945
+
946
+ /**
947
+ * Counts members per community across the loaded nodes, as `[index, count]`
948
+ * pairs sorted by size descending (the order `cluster` reports them in).
949
+ * @param {RawNode[]} nodes
950
+ * @returns {[number, number][]}
951
+ */
952
+ function communityCounts(nodes) {
953
+ /** @type {Map<number, number>} */
954
+ const counts = new Map();
955
+ for (const node of nodes) {
956
+ const community = nodeCommunity(node);
957
+ if (community !== undefined) {
958
+ counts.set(community, (counts.get(community) ?? 0) + 1);
959
+ }
960
+ }
961
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
962
+ }
963
+
964
+ /**
965
+ * Maps each community index to its label, read from the first member node seen —
966
+ * `cluster` writes the same label onto every member, so one read per community
967
+ * suffices.
968
+ * @param {RawNode[]} nodes
969
+ * @returns {Map<number, string>}
970
+ */
971
+ function communityLabels(nodes) {
972
+ /** @type {Map<number, string>} */
973
+ const labels = new Map();
974
+ for (const node of nodes) {
975
+ const community = nodeCommunity(node);
976
+ if (community === undefined || labels.has(community) === true) {
977
+ continue;
978
+ }
979
+ const label = nodeCommunityLabel(node);
980
+ if (label !== undefined) {
981
+ labels.set(community, label);
982
+ }
983
+ }
984
+ return labels;
985
+ }
986
+
987
+ /**
988
+ * Renders the Communities legend as visibility filters — a checkbox + swatch +
989
+ * member count per community, plus a master "all" toggle — so a community can be
990
+ * shown or hidden on the graph, mirroring the node/edge kind legends. The section
991
+ * is hidden when the graph is un-clustered.
992
+ */
993
+ function renderCommunities() {
994
+ const section = el('communities');
995
+ const container = el('community-legend');
996
+ container.innerHTML = '';
997
+ if (state.communities.length === 0) {
998
+ section.classList.add('empty');
999
+ return;
1000
+ }
1001
+ section.classList.remove('empty');
1002
+
1003
+ const indices = state.communities.map(([index]) => index);
1004
+ /** @type {HTMLInputElement[]} */
1005
+ const childCheckboxes = [];
1006
+
1007
+ const master = document.createElement('input');
1008
+ master.type = 'checkbox';
1009
+ const syncMaster = () => {
1010
+ const hiddenCount = indices.filter((index) => state.hiddenCommunities.has(index) === true).length;
1011
+ master.checked = hiddenCount === 0;
1012
+ master.indeterminate = hiddenCount > 0 && hiddenCount < indices.length;
1013
+ };
1014
+ master.addEventListener('change', () => {
1015
+ const allVisible = indices.every((index) => state.hiddenCommunities.has(index) === false);
1016
+ for (const index of indices) {
1017
+ if (allVisible === true) {
1018
+ state.hiddenCommunities.add(index);
1019
+ } else {
1020
+ state.hiddenCommunities.delete(index);
1021
+ }
1022
+ }
1023
+ for (const child of childCheckboxes) {
1024
+ child.checked = state.hiddenCommunities.has(Number(child.dataset.community)) === false;
1025
+ }
1026
+ syncMaster();
1027
+ applyFilters();
1028
+ });
1029
+ const masterLabel = document.createElement('label');
1030
+ masterLabel.className = 'master';
1031
+ masterLabel.title = 'show or hide every community';
1032
+ const spacer = document.createElement('span');
1033
+ spacer.className = 'swatch spacer';
1034
+ const masterText = document.createElement('span');
1035
+ masterText.textContent = 'all';
1036
+ masterLabel.append(master, spacer, masterText);
1037
+ container.appendChild(masterLabel);
1038
+
1039
+ for (const [index, count] of state.communities) {
1040
+ const row = document.createElement('label');
1041
+ const checkbox = document.createElement('input');
1042
+ checkbox.type = 'checkbox';
1043
+ checkbox.dataset.community = String(index);
1044
+ checkbox.checked = state.hiddenCommunities.has(index) === false;
1045
+ checkbox.addEventListener('change', () => {
1046
+ if (checkbox.checked === true) {
1047
+ state.hiddenCommunities.delete(index);
1048
+ } else {
1049
+ state.hiddenCommunities.add(index);
1050
+ }
1051
+ syncMaster();
1052
+ applyFilters();
1053
+ });
1054
+ childCheckboxes.push(checkbox);
1055
+ const swatch = document.createElement('span');
1056
+ swatch.className = 'swatch';
1057
+ swatch.style.background = communityColor(index);
1058
+ const text = document.createElement('span');
1059
+ text.textContent = /** @type {string} */ (state.communityLabels.get(index));
1060
+ const countSpan = document.createElement('span');
1061
+ countSpan.className = 'count';
1062
+ countSpan.textContent = String(count);
1063
+ row.append(checkbox, swatch, text, countSpan);
1064
+ container.appendChild(row);
1065
+ }
1066
+
1067
+ syncMaster();
1068
+ }
1069
+
1070
+ /**
1071
+ * Enables the `self-time` and `community` colour modes only when the loaded
1072
+ * graph carries that data, falls back to `structural` if the active mode lost
1073
+ * its data, mirrors the choice into the `<select>`, and re-applies the style.
1074
+ */
1075
+ function syncEncodingOptions() {
1076
+ const select = selectEl('encoding-select');
1077
+ /**
1078
+ * @param {string} value
1079
+ * @param {boolean} enabled
1080
+ */
1081
+ const setEnabled = (value, enabled) => {
1082
+ const option = select.querySelector(`option[value="${value}"]`);
1083
+ if (option instanceof HTMLOptionElement) {
1084
+ option.disabled = enabled === false;
1085
+ }
1086
+ };
1087
+ setEnabled('runtime', state.runtime.measuredCount > 0);
1088
+ setEnabled('community', state.communities.length > 0);
1089
+ if ((state.encoding === 'runtime' && state.runtime.measuredCount === 0)
1090
+ || (state.encoding === 'community' && state.communities.length === 0)) {
1091
+ state.encoding = 'structural';
1092
+ }
1093
+ select.value = state.encoding;
1094
+ if (state.cy !== undefined) {
1095
+ state.cy.style(cyStyle());
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Narrows an arbitrary `<select>` value to a known encoding mode, defaulting to `structural`.
1101
+ * @param {string} value
1102
+ * @returns {'structural' | 'runtime' | 'community'}
1103
+ */
1104
+ function encodingFromValue(value) {
1105
+ return value === 'runtime' || value === 'community' ? value : 'structural';
1106
+ }
1107
+
769
1108
  /* ---------- search ---------- */
770
1109
 
771
1110
  function renderSearchResults() {
@@ -33,6 +33,7 @@ window.KIND_DESCRIPTIONS = {
33
33
  "WRITES": "The source assigns to the target variable or property.",
34
34
  "READS_CONFIG": "The source reads the target configuration flag (an environment variable).",
35
35
  "CALLS_EXTERNAL": "The source makes an outbound HTTP call to the target external API.",
36
- "HANDLES": "Links an HTTP endpoint to the function that handles it (route to handler)."
36
+ "HANDLES": "Links an HTTP endpoint to the function that handles it (route to handler).",
37
+ "CALLS_RUNTIME": "A call observed at runtime in a CPU profile: the source function or method was on the stack directly above the target. Captures dynamic dispatch that static CALLS cannot see."
37
38
  }
38
39
  };
@@ -33,7 +33,7 @@ interface RawNode {
33
33
  filePath: string;
34
34
  range?: RawRange;
35
35
  exported?: boolean;
36
- metadata?: { runtime?: NodeRuntime | null; [key: string]: unknown } | null;
36
+ metadata?: { runtime?: NodeRuntime | null; community?: number | null; communityLabel?: string | null; [key: string]: unknown } | null;
37
37
  }
38
38
 
39
39
  /** A graph edge as serialised in `edges.jsonl`; mirrors `GraphEdgeSchema`. */
@@ -57,7 +57,7 @@ interface KindDescriptions {
57
57
  edges: Record<string, string>;
58
58
  }
59
59
 
60
- /** GitHub permalink descriptor; mirrors `GitHubSource` in `src/commands/web_command.ts`. */
60
+ /** GitHub permalink descriptor; mirrors `GitHubSource` in `src/commands/webview_command.ts`. */
61
61
  interface GitHubSource {
62
62
  baseUrl: string;
63
63
  commit: string;
@@ -76,11 +76,14 @@ interface AppState {
76
76
  cy: CyCore | undefined;
77
77
  hiddenNodeKinds: Set<string>;
78
78
  hiddenEdgeKinds: Set<string>;
79
+ hiddenCommunities: Set<number>;
79
80
  hideIsolated: boolean;
80
81
  onlyMeasured: boolean;
81
82
  droppedFiles: { nodes: RawNode[] | undefined; edges: RawEdge[] | undefined };
82
- encoding: 'structural' | 'runtime';
83
+ encoding: 'structural' | 'runtime' | 'community';
83
84
  runtime: { maxSelfMs: number; measuredCount: number; totalSelfMs: number };
85
+ communities: [number, number][];
86
+ communityLabels: Map<number, string>;
84
87
  }
85
88
 
86
89
  /* ---------- Cytoscape.js (loaded as a CDN global, untyped) ---------- */
@@ -136,6 +139,10 @@ interface CytoscapeOptions {
136
139
  }
137
140
 
138
141
  declare function cytoscape(options?: CytoscapeOptions): CyCore;
142
+ declare namespace cytoscape {
143
+ /** Registers a Cytoscape extension (such as the fcose layout) loaded as a CDN global. */
144
+ function use(extension: unknown): void;
145
+ }
139
146
 
140
147
  /* ---------- globals injected into the page ---------- */
141
148
 
@@ -143,4 +150,5 @@ interface Window {
143
150
  GRAPH_DATA?: GraphData;
144
151
  KIND_DESCRIPTIONS?: KindDescriptions;
145
152
  GRAPH_SOURCE?: GraphSource | null;
153
+ cytoscapeFcose?: unknown;
146
154
  }
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAmBA,qBAAa,GAAG;IACf,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;CAyBhC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAqBA,qBAAa,GAAG;IACf,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;CA2BhC"}
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import { BenchmarkCommand } from './commands/benchmark_command.js';
4
4
  import { BlastRadiusCommand } from './commands/blast_radius_command.js';
5
5
  import { CallsCommand } from './commands/calls_command.js';
6
+ import { ClusterCommand } from './commands/cluster_command.js';
6
7
  import { CostCommand } from './commands/cost_command.js';
7
8
  import { DeadExportsCommand } from './commands/dead_exports_command.js';
8
9
  import { EnrichCommand } from './commands/enrich_command.js';
@@ -13,8 +14,9 @@ import { InstallCommand } from './commands/install_command.js';
13
14
  import { LoadCommand } from './commands/load_command.js';
14
15
  import { NeighborsCommand } from './commands/neighbors_command.js';
15
16
  import { ReferencesCommand } from './commands/references_command.js';
17
+ import { ReportCommand } from './commands/report_command.js';
16
18
  import { VerifyCommand } from './commands/verify_command.js';
17
- import { WebCommand } from './commands/web_command.js';
19
+ import { WebviewCommand } from './commands/webview_command.js';
18
20
  import { WhoCallsCommand } from './commands/who_calls_command.js';
19
21
  export class Cli {
20
22
  static run(argv) {
@@ -25,6 +27,7 @@ export class Cli {
25
27
  ExtractCommand.register(program);
26
28
  LoadCommand.register(program);
27
29
  EnrichCommand.register(program);
30
+ ClusterCommand.register(program);
28
31
  FindCommand.register(program);
29
32
  WhoCallsCommand.register(program);
30
33
  CallsCommand.register(program);
@@ -36,7 +39,8 @@ export class Cli {
36
39
  BlastRadiusCommand.register(program);
37
40
  NeighborsCommand.register(program);
38
41
  ReferencesCommand.register(program);
39
- WebCommand.register(program);
42
+ ReportCommand.register(program);
43
+ WebviewCommand.register(program);
40
44
  InstallCommand.register(program);
41
45
  void program.parseAsync(argv);
42
46
  }