HuggingFace's chat-ui is a TypeScript/SvelteKit app with 92 modules. We picked it to run the Svelte extractor against a real production codebase. Before a staff engineer signs off on a refactor, they check where the coupling is concentrated, whether there are cycles, which functions are too complex to touch safely, and whether the risk lives at the module level or hides inside a handful of files. We pointed enola. at chat-ui and walked through that exact review.
One generate_snapshot call, 136 milliseconds, 3,783 facts across 92 modules in TypeScript and Svelte. Everything below is what came out of querying that graph.
The snapshot
enola generate_snapshot /path/to/chat-ui
| Metric | Value |
|---|---|
| Generation time | 136ms |
| Facts extracted | 3,783 |
| Modules | 92 |
| Symbols | 1,775 (732 functions, 656 variables, 157 interfaces, 122 types, 89 methods, 11 classes, 8 enums) |
| Routes | 25 (17 GET, 8 POST) |
| Dependencies | 1,565 (944 internal, 621 external) |
| Call edges | 4,990 |
| Cyclic dependency chains | 2, plus 1 low-confidence coupling cluster |
| Languages detected | TypeScript, Svelte |
| Commit | main @ f98711a7 |
Full repo scanned, tests included; only build output, node_modules, and non-code config excluded.
Complexity is concentrated in three functions
| Symbol | Complexity |
|---|---|
server/textGeneration/mcp.runMcpFlow | 185 |
routes/conversation/[id].POST | 147 |
routes/conversation/[id].writeMessage | 92 |
server/textGeneration.generate | 59 |
server/hooks.handleRequest | 54 |
server.buildModels | 45 |
runMcpFlow orchestrates MCP tool-call flow for chat completions, with a complexity score of 185, roughly 3x higher than anything else in the repo. The two next-highest scores both live in the same file, the conversation route handler. The highest-complexity code in chat-ui is concentrated in three functions; the rest of the top-12 outliers list scores under 60.
The three highest-fan-in modules
| Module | Fan-in | Fan-out | Blast radius |
|---|---|---|---|
src/lib/server | 245 | 62 | 82 |
src/lib/types | 180 | 30 | 82 |
src/lib/utils | 128 | 38 | 82 |
src/lib/stores | 53 | 7 | 77 |
src/lib/migrations/routines | 11 | 37 | 64 |
src/lib/components | 50 | 68 | 10 |
src/lib/components/chat | 34 | 130 | 9 |
src/routes/conversation/[id] | 1 | 51 | 0 |
src/lib/server has the highest fan-in in the repo. types and utils aren't far behind, and all three top out at the identical blast radius: 82. That's expected: server holds shared backend logic, types holds shared type definitions, utils holds shared helpers, and all three are supposed to have high fan-in. The route handler at the bottom of the table is the opposite case: fan-in of 1, fan-out of 51, blast radius 0. Nothing depends on a leaf route, so a change there can't cascade outward, only inward into what it calls.
The matching 82 was worth checking rather than guessing at, so we walked the import graph in facts.jsonl directly instead of reasoning from the module-level table. The reverse-dependency sets for server, types, and utils aren't just the same size, they're the same 82 modules, exactly.
63 modules import server directly, 34 import types, 24 import utils, and only 11 modules import all three directly. Starting from any one of them and walking outward still lands in the same 82-module set. The other 9, src/lib/actions, src/lib/constants, src/lib/components/players, src/lib/components/voice, scripts/setups, and three standalone routes (healthcheck, privacy, user), are the only modules in the repo that depend on none of the three. 90% of chat-ui sits in one connected core; these 9 are the periphery.
Two cycles, and one signal that isn't a cycle
| Type | Modules |
|---|---|
| Cycle | src/lib/server/textGeneration ↔ src/lib/server/textGeneration/mcp |
| Cycle | src/routes ↔ src/lib/components ↔ components/chat ↔ components/mcp |
| Coupling cluster (0.4 confidence) | src/lib, migrations, migrations/routines, server, server/endpoints(/openai), server/files, server/mcp, server/router, server/textGeneration/utils, stores, types (13 modules) |
Fix this one first: a two-module loop between textGeneration and its mcp submodule, the smallest cycle in the graph, sitting directly on the app's highest-complexity function. textGeneration/index.ts imports runMcpFlow (complexity 185, the highest score in the repo) from mcp/, and runMcpFlow.ts imports shared types back from ../types, closing the loop. A change to those shared types can affect the MCP flow's assumptions without anyone touching index.ts, which is exactly the failure mode a tight cycle causes. The fix is straightforward: move the types runMcpFlow needs somewhere neither module has to import from the other to reach.
This is the actual insight query_insights returns for it, straight from insights.json:
{
"title": "Cyclic dependency detected (2 modules)",
"source": "cycles",
"description": "The following modules form a dependency cycle: src/lib/server/textGeneration -> src/lib/server/textGeneration/mcp -> src/lib/server/textGeneration. This can cause initialization issues, make refactoring harder, and indicates tight coupling.",
"confidence": 1,
"evidence": [
{ "fact": "src/lib/server/textGeneration", "detail": "module \"src/lib/server/textGeneration\" is part of the cycle" },
{ "fact": "src/lib/server/textGeneration/mcp", "detail": "module \"src/lib/server/textGeneration/mcp\" is part of the cycle" }
],
"suggested_actions": [
"Introduce an interface to break the cycle",
"Extract shared types to a separate package",
"Consider merging tightly coupled modules"
]
}
Confidence 1: two directed edges Tarjan's algorithm found forming a loop. The runMcpFlow complexity outlier reads the same way, down to the line number:
{
"title": "High cyclomatic complexity: src/lib/server/textGeneration/mcp.runMcpFlow (185)",
"source": "complexity-outliers",
"description": "\"src/lib/server/textGeneration/mcp.runMcpFlow\" has a cyclomatic complexity of 185 — well above the repo average. Highly branched functions are hard to test fully and are frequent defect sites.",
"confidence": 0.7,
"evidence": [
{ "file": "src/lib/server/textGeneration/mcp/runMcpFlow.ts", "symbol": "src/lib/server/textGeneration/mcp.runMcpFlow", "detail": "cyclomatic complexity 185 at line 45" }
]
}
The second cycle is bigger and matters less. Four modules, routes, components, components/chat, components/mcp, form a loop that's a common SvelteKit pattern: a page imports a component, the component imports something re-exported from routes. Usually this is an import-hygiene issue, not a design flaw. Worth an audit for whichever component reaches into src/routes (routes should only be consumed, never depended on) and relocating whatever it's grabbing into src/lib.
The 13-module cluster in the table isn't a cycle. Here's the actual insight for it:
{
"title": "Highly coupled module cluster (13 modules)",
"source": "cycles",
"description": "13 modules reference each other mutually. In an autoloaded codebase (e.g. Rails) this is expected — constant references between directories resolve lazily, so this is not a load-order cycle. Treat it as an overall coupling-density signal, not a defect to break.",
"confidence": 0.4,
"suggested_actions": [
"Look for a few high-traffic modules whose extraction would thin the cluster",
"Prefer narrowing individual module responsibilities over a single big refactor"
]
}
0.4 confidence is enola's own signal not to treat this as a finding on the level of the two real cycles. Leave it alone unless it keeps growing.
The auth guard is the one god class that matters
| Symbol | Dependents |
|---|---|
server.authCondition | 28 |
server/api/utils.superjsonResponse | 20 |
server/api/utils.requireAuth | 11 |
utils.requireAuthUser | 10 |
utils.escapeHTML | 8 |
Three of the top five are auth-related. authCondition, the code that decides who's allowed to do what, has more dependents than anything else in the repo.
None of these five are cycle participants. They're a different kind of risk: each one is a single point of change for a large share of the API surface. A shared auth guard is supposed to have high fan-in; that's the job. The number gives a precise answer instead of a guess: a change to authCondition's signature affects 28 call sites, not "a lot of auth stuff." Run impact_analysis on it by name before changing it, rather than assuming the guard is safe just because it's auth.
The raw insight for it names the file and every call site, not just the count:
{
"title": "High fan-in symbol: src/lib/server.authCondition (28 dependents)",
"source": "god-class",
"description": "\"src/lib/server.authCondition\" is depended upon by 28 other symbols — well above the repo average. High fan-in concentrates change risk: edits here ripple across many call sites, and it often indicates a missing or overly broad abstraction.",
"confidence": 1,
"evidence": [
{ "file": "src/lib/server/auth.ts", "symbol": "src/lib/server.authCondition", "detail": "28 symbols depend on this" },
{ "symbol": "src/lib/server.createConversationFromShare", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/lib/server/api/utils.resolveConversation", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/conversation/[id].GET", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/conversation/[id]/message/[messageId].DELETE", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/conversations.DELETE", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/conversations.GET", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/v2/conversations.DELETE", "detail": "depends on src/lib/server.authCondition" },
{ "symbol": "src/routes/api/v2/conversations.GET", "detail": "depends on src/lib/server.authCondition" }
]
}
The evidence array names 8 of the 28 dependents; six are route handlers. All 28 are resolved the same way: real call edges, not estimates.
Where to start reading
Three places, in order.
1. src/lib/types. Read this first: it's the shared type definitions (Message, Conversation, settings shapes) every other module uses. 98% of its 60 symbols are exported, which is expected here; a type barrel is supposed to export almost everything it declares.
2. src/lib/server. The module with the highest fan-in in the repo. It holds auth, model configuration, and the endpoint abstraction every LLM backend implements against, and it's worth reading once you already know the vocabulary, not before.
3. src/routes/conversation/[id]. Where a chat message gets handled. It contains two of the three most complex functions in the app. This is where types and server get tied together into the actual request/response cycle: a 9-level-deep dependency chain running through LLM endpoint calls and MCP tool execution.
Worth flagging before diving in: most of the app's logic doesn't live in .svelte files. The UI layer is comparatively thin, nothing dramatic in its fan-in or fan-out. The real complexity is server-side: MCP tool orchestration and multi-provider LLM endpoint translation. A newcomer expecting "a Svelte app" will find more of the real complexity in src/lib/server than in the component tree.
Two smaller flags
| Route | Depth |
|---|---|
routes/settings/(nav) | 10 |
routes/conversation/[id] | 9 |
routes/models/[...model] | 9 |
routes/r/[id] | 9 |
routes/settings | 9 |
Five of the eight flagged deep-dependency chains sit at 9–10 levels, all inside routes/, because route handlers pull in the same server/types/utils modules. Nothing here needs action; it follows directly from the coupling numbers already shown. The other flag, types' large public surface, is also expected. Worth revisiting only if the module keeps growing before anyone splits it by domain.
Refactor priorities
src/lib/server has the highest fan-in, is tied for the highest blast radius, and contains the auth guard with 28 dependents. None of that makes it dangerous: high fan-in on a shared module is normal, not a warning sign. The actual risk is much smaller: two files, one 185-complexity function, one cycle. That's where review time should go before a refactor, not server, types, or utils. The routes/components cycle needs an import audit, not a dedicated refactor. The 13-module cluster doesn't need attention at all right now.
Why an agent can't reconstruct this picture
impact_analysis is a graph traversal, not a summary: it walks the same parsed graph, 92 modules and 4,990 call edges, and returns identical results for the same target every time it's run against this commit. An agent searching and reading files isn't running that traversal; it's sampling files, inferring relationships from what it reads, and filling in gaps by pattern-matching.
The textGeneration/mcp cycle is a clear example. A cycle isn't visible from any single file. It only exists as a property of the whole graph: something you find by walking every import edge and checking whether any path leads back to where it started. Two files that each look reasonable on their own form a loop once both edges exist on the same graph, and that's easy to lose track of when you're reading files one at a time, across separate tool calls, over a long session.
A parsed graph either contains that cycle or it doesn't. An agent's mental model of it might, depending on whether it happened to notice the back-import this time.
Follow-up queries
Enola. turns findings like these into direct questions instead of another read-through of the same files.
"What would break if I changed runMcpFlow?" Not just a caller list. Because runMcpFlow sits inside that two-module cycle, the answer also has to surface that index.ts imports it and runMcpFlow.ts imports back from the parent module, so the two modules can't be evaluated as independent blast radii. A plain "find usages" search misses this, since it requires knowing an edge exists in both directions at once, not just that one exists.
"Is the routes/components cycle a real design flaw, or just import hygiene?" The graph returns the same fact either way: four modules, four edges, closing a loop. It gives you the specific edges to inspect, including which component reaches into src/routes, instead of leaving that as a guess. Whether it's "a real design flaw" is a judgment call for whoever owns that code; the graph doesn't make that call.
Try it on your own repo
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh
enola on GitHub, Apache 2.0, free, local. Full tool reference in ARCHITECTURE.md.
FAQ
Does HuggingFace's chat-ui have circular dependencies?
Yes, two genuine ones: a two-module loop wrapping the app's most complex function (textGeneration and its mcp submodule), and a four-module loop across the routing and component tree. A third finding, a 13-module coupling cluster, looks similar but isn't a cycle; enola. flags it at only 0.4 confidence because it's a density signal, not a load-order loop.
What is the riskiest module to change in chat-ui?
By the numbers, src/lib/server: highest fan-in in the repo, tied for the highest blast radius. But the finding that should change a refactor plan is different: the small two-file cycle between textGeneration and its mcp submodule, because it wraps the single most complex function in the app.
Why do src/lib/server, src/lib/types, and src/lib/utils all report the same blast radius?
Because their reverse-dependency sets are the same 82 modules, not just the same count. Direct fan-in differs a lot (63 modules import server directly, 34 import types, 24 import utils), but walking the import graph outward from any of the three lands in the same connected core, which covers 90% of the repo. Only 9 modules in chat-ui depend on none of the three.
How long does it take enola. to analyze a SvelteKit codebase?
136 milliseconds for chat-ui: 3,783 facts, 1,775 symbols, 25 routes, 1,565 dependencies, and 4,990 call edges across 92 modules.
enola. is open source under Apache 2.0: free, local, no data leaves your machine.
curl -fsSL https://raw.githubusercontent.com/enola-labs/enola/main/install.sh | sh