Across 2,733 functions in ueberdosis/tiptap at commit c4c5e16, Hotspots flagged 84 as critical — and the five with the highest activity-weighted risk scores all land in the fire quadrant, meaning they are both structurally complex and actively changing right now. The top-ranked function, tokenize in createBlockMarkdownSpec.ts, carries an activity-weighted risk score of 16.12 with a cyclomatic complexity of 16, a max nesting depth of 7, and was touched 17 days ago. I would start there because it sits at the entry point of tiptap’s custom block markdown tokenization, where a subtle branching error cascades into everything downstream. The pattern is consistent across all five hotspots: the markdown and drag-handle subsystems are where structural complexity and recent commit activity converge into live regression risk.
The table below ranks functions by activity-weighted risk — a score that multiplies structural complexity by recent commit frequency. A function that is both hard to understand (high cyclomatic complexity) and actively changing is a higher priority than one that is complex but untouched. CC = cyclomatic complexity (independent execution paths); ND = max nesting depth; FO = fan-out (distinct callees).
Top 5 Hotspots
| Function | File | Risk | CC | ND | FO |
|---|---|---|---|---|---|
tokenize | packages/core/src/utilities/markdown/createBlockMarkdownSpec.ts | 16.1 | 16 | 7 | 11 |
parseInlineTokens | packages/markdown/src/MarkdownManager.ts | 15.7 | 37 | 9 | 10 |
parseListToken | packages/markdown/src/MarkdownManager.ts | 15.3 | 22 | 6 | 20 |
parseIndentedBlocks | packages/core/src/utilities/markdown/parseIndentedBlocks.ts | 15.1 | 19 | 4 | 20 |
findBestDragTarget | packages/extension-drag-handle/src/helpers/findBestDragTarget.ts | 14.7 | 17 | 4 | 15 |
Large Repo Analysis
tiptap is a large repository. To stay within memory constraints, this analysis used hybrid touch mode: structural complexity — CC, ND, FO — is measured precisely for every function. Git activity is tracked at the function level (via git log -L) only for files with 5 or more commits in the last 30 days; other files use a file-level approximation. Rankings therefore surface functions that are both structurally complex and in the most actively-changing parts of the codebase. Dormant code with high structural complexity will rank lower than it would under a full per-function analysis — to surface it, run hotspots analyze . --per-function-touches on a machine with sufficient memory.
Quadrant and Pattern Overview
2,733 functions analyzed
Every function in this codebase falls into either the fire or watch quadrant — there is no dormant structural debt to speak of. The 308 fire-quadrant functions are actively changing and structurally complex. The risk is concentrated: 84 functions are critical, 224 are high. The markdown layer accounts for three of the top five hotspots, and every one of them is in motion.
High cyclomatic complexity — many independent execution paths, each a potential bug surface and required test case.God Function×5God Function
Calls an unusually large number of distinct functions (high fan-out), making it the structural centre of gravity for a subsystem.Long Function×5Long Function
Function body is too long to review in a single pass; likely contains multiple distinct responsibilities.Deeply Nested×3Deeply Nested
Control structures nested 4+ levels deep, making it hard to reason about the full execution state at inner branches.Exit Heavy×3Exit Heavy
Multiple return or throw paths dispersed through the body — each exit needs separate test coverage.Middle Man×1Middle Man
Mostly delegates to one other function without adding meaningful logic — a refactoring candidate for removal or consolidation.
Every function in the top five exhibits both god_function and long_function characteristics — they each handle more cases than a single function should, and all five also show complex_branching. Three additionally carry deeply_nested or exit_heavy patterns. This is not a coincidence: markdown parsing is inherently stateful and case-driven, but that pressure has been absorbed into monolithic functions rather than distributed across smaller, testable units.
tokenize — createBlockMarkdownSpec.ts
This function is responsible for recognizing and consuming custom fenced block syntax (the :::blockName delimiter pattern) from a raw markdown source string and producing a structured token for the rest of the pipeline. From the source excerpt, it does this by running a regex to detect an opening tag, then entering an unbounded loop that scans for matching closing delimiters while tracking nesting level manually. When it finds the closing tag, it dispatches to either lexer.blockTokens or lexer.inlineTokens depending on the content mode, and then post-processes the result to strip trailing empty paragraphs.
The cyclomatic complexity of 16 understates the actual decision surface here. In TypeScript, the optional chaining on match[2]?.endsWith(':::') and the multiple continue/break paths through the for (;;) loop add implicit control flow that CC doesn’t fully capture. The max nesting depth of 7 is the more telling signal: the deepest logic — deciding whether to trim and which tokenizer branch to call — is buried inside nested conditionals inside the loop, making it hard to reason about invariants at each level. The exit-heavy pattern (multiple early return undefined paths plus break and continue inside the loop) means test coverage requires exercising at least a dozen distinct paths to reach full branch coverage.
The single commit touching this file in the last 30 days, 17 days ago, combined with every recorded commit to this file being a fix rather than a new feature, tells me the file has been corrected repeatedly. That is a meaningful historical signal: the file has been corrected, not extended.
Recommendation: Extract the nesting-level scanner into a standalone findMatchingClose(src, position, blockPattern) function that returns a {matchedContent, fullMatch} result or null. That single extraction removes the deepest nesting levels and isolates the most error-prone logic — the level-tracking loop — into a unit that can be tested independently with a table of nested and malformed inputs.
parseInlineTokens — MarkdownManager.ts
With a cyclomatic complexity of 37 and a max nesting depth of 9, parseInlineTokens is the most structurally complex function in the top five. It was touched twice in the last 30 days, most recently 17 days ago, putting it squarely in the fire quadrant. The function walks an array of MarkdownToken objects and converts them into JSONContent nodes for the ProseMirror document model. What makes it genuinely hard is the HTML fragment reassembly logic: when the tokenizer splits an inline HTML element like <em>text</em> across multiple tokens, this function has to detect the opening tag, scan forward through subsequent tokens to find the matching closing tag, merge the raw fragments back into a single string, parse the merged result, and then advance the loop index past all the consumed tokens.
That lookahead-and-merge path creates a secondary loop nested inside the primary token-walking loop, which is where the nesting depth of 9 comes from. Each conditional on token.type — 'text', 'escape', 'html', and presumably others in the full function — is a branch. At CC 37, there are 37 independent execution paths, each of which is a required test case. In TypeScript, the type narrowing happening inside each branch (e.g., casting parsedSingle through normalizeParseResult) adds further implicit paths that the CC metric doesn’t count.
This file has accumulated more PR review comments per commit than any other file in the top five — a concrete signal that reviewers have found this code worth discussing at length. Every recorded commit to the file so far has been a correction, not a new feature.
Recommendation: The HTML fragment reassembly logic is the clearest extraction candidate — pull it into a mergeInlineHTMLFragment(tokens, startIndex) function that returns the merged JSONContent nodes and the new index. This reduces the nesting depth of the main loop immediately and makes the lookahead algorithm independently testable. After that extraction, the remaining token.type dispatch can be converted into a registry of per-type handlers, which brings the CC of the main function down below 10.
parseListToken — MarkdownManager.ts
This function sits in the same file as parseInlineTokens and shares its commit history — two touches in 30 days, 17 days since last changed, every recorded commit a correction, and the same high review comment density as parseInlineTokens. Its fan-out of 20 is the second highest in the top five and is the metric I would emphasize here: 20 distinct function calls means a change to parseListToken can trigger unexpected ripple effects across a wide surface of the codebase.
From the source excerpt, the function’s complexity comes from a specific and non-trivial problem: a markdown list can contain a mix of plain list items and task-list items (- [ ] text), and tiptap needs to split that mixed list into separate sub-lists of each type before mapping them to ProseMirror nodes. The function detects this mixed case, then manually groups items by type, transforms task items into a custom taskItem token (reconstructing their raw content from line-splitting and indentation stripping), and finally uses the lexer to re-parse nested content. Each of those steps is implemented inline, which is why the CC reaches 22 and the nesting depth reaches 6.
The god-function pattern is particularly visible here: the function is doing type detection, item grouping, token transformation, indentation normalization, and recursive lexing all in one body. A change to how indentation is stripped — a plausible follow-up to any of the recent fix commits — has to navigate all of that simultaneously.
Recommendation: Extract the task-item transformation — the block that splits raw content by lines, strips common indentation, and reconstructs a taskItem token — into a dedicated transformToTaskItemToken(item, lexer) function. That alone removes the deepest nesting in the grouping loop and reduces the fan-out of parseListToken by consolidating several of the 20 callees into the extracted function. The mixed-list splitting logic can then be read as a straightforward group-and-map pipeline.
parseIndentedBlocks — parseIndentedBlocks.ts
parseIndentedBlocks implements a line-by-line parser for indented block structures — think task lists or custom indented nodes where each item can have arbitrarily indented nested content. From the source excerpt, it walks the source string line by line using a while loop, matches each line against a configurable itemPattern, then enters a secondary lookahead loop to collect indented continuation lines and empty lines that might be part of the same nested block. Once it has collected an item’s full content, it calls lexer.inlineTokens or lexer.blockTokens to parse it.
The fan-out of 20 matches parseListToken’s and points to the same architectural pressure: a general-purpose parser that has accumulated calls to many downstream utilities. The exit-heavy pattern — multiple return undefined paths for cases where the input doesn’t match — combined with break and continue inside two nested while loops means the control flow is branchy even though the max nesting depth of 4 looks modest on its own. CC 19 confirms there are 19 independent paths, each of which requires a distinct test input to exercise.
This file has a single author in the last 90 days and zero PR review comments, which contrasts with MarkdownManager.ts’s dense review history. That makes it a slightly more isolated risk: the logic hasn’t been stress-tested by multiple reviewers the way the inline and list parsing has.
Recommendation: The empty-line lookahead block — the inner while loop that peeks ahead to determine whether an empty line is a separator or a continuation — is a good first extraction target. A findContinuationEnd(lines, fromIndex, currentIndentLevel) helper would make the intent explicit and remove the most confusing branching from the main loop body. Given the fan-out of 20, I would also audit which of those 20 callees are truly distinct responsibilities versus helper utilities that could be consolidated.
findBestDragTarget — findBestDragTarget.ts
This function is the outlier in the top five — it lives in the drag-handle extension rather than the markdown layer, and its job is to determine which ProseMirror document node a drag handle should attach to given a set of screen coordinates. From the source excerpt, it works by resolving the cursor position from coordinates, walking up the document tree from the deepest ancestor to the root, evaluating each candidate node against a configurable set of DragHandleRule objects, scoring each candidate, and then also checking for atom nodes (like images) that the ancestor walk would miss because they sit at $pos.nodeAfter rather than in the ancestor chain.
The CC of 17 comes from the rule evaluation logic, the allowedContainers check, the atom-node special case, and several guard conditions at the top of the function. The fan-out of 15 reflects the breadth of what this function touches: coordinate validation, ProseMirror position resolution, document traversal, rule application, DOM node lookup, and the atom-node fallback path. The exit-heavy pattern (early returns for invalid coordinates, no position info, negative scores, null candidates) is correct defensive programming here but still represents paths that need test coverage.
The context-only data shows that edgeBlockRect and keydown in the same extension-drag-handle package have been touched 2 and 3 times respectively in the last 30 days, both changed just 2 days ago. That tells me the drag-handle extension is under active development right now, which raises the practical urgency of findBestDragTarget’s complexity — it is the core decision function for a subsystem that is currently being iterated on.
Recommendation: Extract the candidate-building logic — the depthLevels.map(...) block that resolves each ancestor node, checks container constraints, builds the rule context, calculates the score, and fetches the DOM element — into a buildCandidates($pos, options, rules, coords, view) function. The atom-node check for $pos.nodeAfter can then be a clearly separated second step. This reduces findBestDragTarget to coordinate validation, candidate collection, and winner selection, which is the level of abstraction the function name implies.
Summary
The markdown parsing layer — MarkdownManager.ts and the utilities under packages/core/src/utilities/markdown/ — is where I would concentrate the first refactoring sprint. Three of the top four functions live there, all are fire-quadrant, all have a commit history that is entirely corrective, and two of them share a file with unusually high PR review comment density — reviewers have already been flagging complexity in code review. The drag-handle extension deserves attention second, not because its score is lower, but because its surrounding files are the most actively changing right now.
Patterns Found
Antipatterns detected across the top functions in this snapshot:
| Pattern | Occurrences |
|---|---|
complex_branching | 5 |
god_function | 5 |
long_function | 5 |
deeply_nested | 3 |
exit_heavy | 3 |
middle_man | 1 |
These labels belong to two tiers — Tier 1 (structural): complex_branching, deeply_nested, exit_heavy, long_function, god_function. Tier 2 (relational/temporal): hub_function, cyclic_hub, middle_man, neighbor_risk, stale_complex, churn_magnet, shotgun_target, volatile_god.
Reproduce This Analysis
git clone https://github.com/ueberdosis/tiptap
cd tiptap
git checkout c4c5e16b65b27688a9cd3217ab40f86f023f7a22
hotspots analyze . --mode snapshot --explain-patterns --force --hybrid-touches 5
To run the same analysis on your own codebase, run hotspots analyze . --mode snapshot in any local git repo — no configuration required.
I use Hotspots to highlight structural and activity risk — not “bad code.” I treat these findings as a prioritization aid, not a bug predictor. Editorial policy →