socraticode
Version:
SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.
242 lines • 11.8 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
/**
* Interactive HTML graph visualiser.
*
* Builds a single self-contained HTML file that renders the project's
* file-import graph (and, when available, the symbol-level call graph)
* using a vendored Cytoscape.js + Dagre layout, so the file works offline
* without any CDN access.
*
* The generated page contains:
* - The file-import graph as the primary view (always available).
* - A "Symbols" toggle that switches to the symbol-level call graph,
* available when the symbol graph fits within the embed caps.
* - Per-file symbol lists in the sidebar (names, kinds, line numbers).
* - Blast-radius and call-flow overlays (click or right-click a node).
* - Live search across files and symbols, six layouts, PNG export.
*
* Scale caps (to keep the HTML payload bounded for gigantic repos):
* - Symbol graph embedded only if `symbolCount <= MAX_SYMBOLS` and
* `edgeCount <= MAX_EDGES`. Above either cap the symbol toggle is
* disabled and a banner explains why; the file view still works.
* - Per-file symbol list always embedded (capped at `MAX_SYMS_PER_FILE`
* per file — the sidebar shows the first N and a "use codebase_symbols
* for full list" note).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { findCircularDependencies, nodeLanguage } from "./graph-analysis.js";
import { logger } from "./logger.js";
import { loadFilePayload, loadSymbolGraphMeta } from "./symbol-graph-store.js";
// ── Assets locator ────────────────────────────────────────────────────
// Works in dev (tsx, src/) and prod (node, dist/): assets sit alongside
// the services directory in both layouts thanks to `scripts/copy-assets.mjs`.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ASSETS_DIR = path.join(__dirname, "..", "assets");
// ── Caps ──────────────────────────────────────────────────────────────
const MAX_SYMBOLS = 20_000; // total symbols embedded in the Symbols view
const MAX_EDGES = 60_000; // total call edges embedded in the Symbols view
const MAX_SYMS_PER_FILE = 200; // per-file list shown in the sidebar
const PARALLEL_LOAD_BATCH = 20; // Qdrant payload fetches in parallel
let _assetCache = null;
async function loadAssets() {
if (_assetCache)
return _assetCache;
const [cytoscape, dagre, cytoscapeDagre, styles, app, template] = await Promise.all([
fs.readFile(path.join(ASSETS_DIR, "cytoscape.min.js"), "utf-8"),
fs.readFile(path.join(ASSETS_DIR, "dagre.min.js"), "utf-8"),
fs.readFile(path.join(ASSETS_DIR, "cytoscape-dagre.js"), "utf-8"),
fs.readFile(path.join(ASSETS_DIR, "viewer-styles.css"), "utf-8"),
fs.readFile(path.join(ASSETS_DIR, "viewer-app.js"), "utf-8"),
fs.readFile(path.join(ASSETS_DIR, "viewer-template.html"), "utf-8"),
]);
_assetCache = { cytoscape, dagre, cytoscapeDagre, styles, app, template };
return _assetCache;
}
/** Reset the asset cache (for tests / hot-reload). */
export function resetVisualizeAssetCache() {
_assetCache = null;
}
async function loadSymbolDataForViz(projectId, fileGraph) {
const meta = await loadSymbolGraphMeta(projectId).catch(() => null);
if (!meta) {
return { mode: "omitted", reason: "no symbol graph meta", symbols: [], symbolEdges: [], symbolsByFile: {}, perFileCount: {}, symbolCount: null, edgeCount: null, builtAt: null };
}
const exceedsSymbols = meta.symbolCount > MAX_SYMBOLS;
const exceedsEdges = meta.edgeCount > MAX_EDGES;
const symbols = [];
const symbolEdges = [];
const symbolsByFile = {};
const perFileCount = {};
// Load per-file payloads in bounded parallel batches.
for (let i = 0; i < fileGraph.nodes.length; i += PARALLEL_LOAD_BATCH) {
const batch = fileGraph.nodes.slice(i, i + PARALLEL_LOAD_BATCH);
const payloads = await Promise.all(batch.map((n) => loadFilePayload(projectId, n.relativePath).catch(() => null)));
for (const payload of payloads) {
if (!payload)
continue;
perFileCount[payload.file] = payload.symbols.length;
const vizSymbols = payload.symbols.slice(0, MAX_SYMS_PER_FILE).map((s) => ({
id: s.id, file: payload.file, name: s.name, qualifiedName: s.qualifiedName, kind: s.kind, line: s.line,
}));
symbolsByFile[payload.file] = vizSymbols;
if (!exceedsSymbols) {
// Embed all symbols from the file into the top-level list (no cap there)
for (const s of payload.symbols) {
symbols.push({ id: s.id, file: payload.file, name: s.name, qualifiedName: s.qualifiedName, kind: s.kind, line: s.line });
}
}
if (!exceedsEdges) {
for (const e of payload.outgoingCalls) {
const target = e.calleeCandidates[0];
if (!target)
continue;
symbolEdges.push({ source: e.callerId, target, confidence: e.confidence });
}
}
}
}
if (exceedsSymbols || exceedsEdges) {
const parts = [];
if (exceedsSymbols)
parts.push(`${meta.symbolCount.toLocaleString()} symbols > ${MAX_SYMBOLS.toLocaleString()} cap`);
if (exceedsEdges)
parts.push(`${meta.edgeCount.toLocaleString()} call edges > ${MAX_EDGES.toLocaleString()} cap`);
return {
mode: "capped",
reason: parts.join(", "),
symbols: [], symbolEdges: [], symbolsByFile, perFileCount,
symbolCount: meta.symbolCount, edgeCount: meta.edgeCount, builtAt: meta.builtAt,
};
}
return {
mode: "full",
symbols, symbolEdges, symbolsByFile, perFileCount,
symbolCount: meta.symbolCount, edgeCount: meta.edgeCount, builtAt: meta.builtAt,
};
}
// ── File-graph → viz data ─────────────────────────────────────────────
function buildFileVizData(graph) {
// Circular-dependency edge set for highlighting
const cyclicEdgeKeys = new Set();
const cycles = findCircularDependencies(graph);
for (const cycle of cycles) {
for (let i = 0; i < cycle.length - 1; i++)
cyclicEdgeKeys.add(`${cycle[i]}->${cycle[i + 1]}`);
}
const files = graph.nodes.map((n) => ({
id: n.relativePath,
label: path.basename(n.relativePath),
language: nodeLanguage(n),
deps: n.dependencies.length,
dependents: n.dependents.length,
symbolCount: 0,
}));
// Dedupe edges (graph edges may repeat on multi-import files)
const seen = new Set();
const fileEdges = [];
for (const e of graph.edges) {
const key = `${e.source}->${e.target}`;
if (seen.has(key))
continue;
seen.add(key);
fileEdges.push({ source: e.source, target: e.target, type: e.type, cyclic: cyclicEdgeKeys.has(key) });
}
return { files, fileEdges };
}
// ── HTML escapers ─────────────────────────────────────────────────────
/** Escape text for safe interpolation into HTML text content / attrs. */
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
}
/**
* Serialize data for embedding inside a <script type="application/json">
* block. Escapes every `<` as the JSON unicode escape `\u003c` so the HTML
* parser cannot see a stray `</script>` anywhere inside the payload.
*/
function toEmbeddedJson(data) {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
/**
* Build the interactive HTML viewer for a project as a single
* self-contained string. Includes the file-import graph unconditionally
* and the symbol-level call graph when it fits within the embed caps.
*/
export async function buildInteractiveGraphHtml(opts) {
const assets = await loadAssets();
const { files, fileEdges } = buildFileVizData(opts.graph);
const sym = await loadSymbolDataForViz(opts.projectId, opts.graph);
// Attach full symbol counts to file viz rows
for (const f of files)
f.symbolCount = sym.perFileCount[f.id] ?? 0;
const data = {
project: {
name: opts.projectName,
files: files.length,
edges: fileEdges.length,
symbols: sym.symbolCount,
callEdges: sym.edgeCount,
builtAt: sym.builtAt ? new Date(sym.builtAt).toISOString() : null,
},
files,
fileEdges,
symbols: sym.symbols,
symbolEdges: sym.symbolEdges,
symbolsByFile: sym.symbolsByFile,
symbolMode: sym.mode,
symbolOmitReason: sym.reason,
};
// Use embedded counts (what's actually in this viewer) rather than the
// authoritative meta counts — otherwise the top bar and the sidebar's
// "All symbols" count can disagree. meta counts include synthetic
// <module> placeholders and unresolved call edges that aren't rendered.
let statsLine;
if (sym.mode === "full") {
statsLine = `${files.length} files · ${fileEdges.length} edges · ${sym.symbols.length} symbols · ${sym.symbolEdges.length} calls`;
}
else if (sym.mode === "capped") {
// Can't embed — fall back to the authoritative meta counts and flag
// the limitation so the number isn't mistaken for "this viewer's
// explorable scope".
statsLine = `${files.length} files · ${fileEdges.length} edges · ${sym.symbolCount ?? 0} symbols (capped) · ${sym.edgeCount ?? 0} calls (capped)`;
}
else {
statsLine = `${files.length} files · ${fileEdges.length} edges`;
}
// Use function replacers for asset/data injection: string replacements
// would interpret `$&`, `$'`, `` $` ``, and `$$` in the replacement, which
// corrupts vendored bundles (cytoscape.min.js and dagre.min.js both contain
// a regex-escape idiom of the form `\$&`).
const html = assets.template
.replace(/\{\{TITLE\}\}/g, escapeHtml(`${opts.projectName} — SocratiCode graph`))
.replace(/\{\{PROJECT_NAME\}\}/g, escapeHtml(opts.projectName))
.replace(/\{\{STATS\}\}/g, escapeHtml(statsLine))
.replace("{{STYLES}}", () => assets.styles)
.replace("{{CYTOSCAPE}}", () => assets.cytoscape)
.replace("{{DAGRE}}", () => assets.dagre)
.replace("{{CYTOSCAPE_DAGRE}}", () => assets.cytoscapeDagre)
.replace("{{DATA_JSON}}", () => toEmbeddedJson(data))
.replace("{{APP}}", () => assets.app);
logger.info("Built interactive graph HTML", {
project: opts.projectName,
files: files.length,
fileEdges: fileEdges.length,
symbolMode: sym.mode,
symbols: sym.symbols.length,
symbolEdges: sym.symbolEdges.length,
htmlBytes: html.length,
});
return {
html,
stats: {
files: files.length,
fileEdges: fileEdges.length,
symbolMode: sym.mode,
symbols: sym.symbols.length,
symbolEdges: sym.symbolEdges.length,
},
};
}
//# sourceMappingURL=graph-visualize-html.js.map