Explorar el Código

fix(tui): harden Mermaid rendering limits (#42123)

Kit Langton hace 4 días
padre
commit
c6a86acb0b

+ 7 - 1
packages/merman/src/core/canvas.test.ts

@@ -4,7 +4,7 @@ import { DiagramCanvas, DiagramCanvasSizeError, type DiagramCanvasCell } from ".
 
 describe("DiagramCanvas", () => {
   test("rejects canvases that exceed the rendering budget", () => {
-    expect(() => new DiagramCanvas(2_000, 1_000)).toThrow(DiagramCanvasSizeError)
+    expect(() => new DiagramCanvas(1_000, 251)).toThrow(DiagramCanvasSizeError)
   })
 
   test("rejects invalid canvas dimensions", () => {
@@ -38,6 +38,12 @@ describe("DiagramCanvas", () => {
     expect(stringWidth(canvas.toString())).toBe(4)
   })
 
+  test("does not emit partial wide graphemes", () => {
+    const clipped = new DiagramCanvas<"label">(1, 1)
+    clipped.setText(0, 0, "界", "label")
+    expect(clipped.toString()).toBe("")
+  })
+
   test("keeps custom measurement for ASCII text", () => {
     let measurements = 0
     const canvas = new DiagramCanvas<"label">(5, 1, {

+ 5 - 2
packages/merman/src/core/canvas.ts

@@ -40,7 +40,7 @@ export interface DiagramCanvasRunOptions<Style extends string, Metadata extends
   trimBottom?: boolean
 }
 
-const MAX_DIAGRAM_CELLS = 1_000_000
+const MAX_DIAGRAM_CELLS = 250_000
 
 export class DiagramCanvasSizeError extends Error {
   constructor(
@@ -122,7 +122,6 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
     merge: boolean,
   ): void {
     if (y < 0 || y >= this.cells.length || x < 0 || x >= this.cells[y]!.length) return
-
     const incoming = { char, style, ...metadata } as DiagramCanvasCell<Style, Metadata>
     const cell = merge ? (this.mergeCell?.(this.cells[y]![x]!, incoming) ?? incoming) : incoming
     this.cells[y]![x] = cell
@@ -151,6 +150,10 @@ export class DiagramCanvas<Style extends string, Metadata extends object = objec
     let offset = 0
     for (const grapheme of diagramTextGraphemes(text)) {
       const width = Math.max(1, this.measure(grapheme))
+      if (x + offset < 0 || x + offset + width > this.width) {
+        offset += width
+        continue
+      }
       this.setCell(x + offset, y, grapheme, style, metadataAt(x + offset))
       for (let continuation = 1; continuation < width; continuation++) {
         this.setCell(x + offset + continuation, y, "", style, metadataAt(x + offset + continuation))

+ 4 - 0
packages/merman/src/flowchart/parser.ts

@@ -30,6 +30,7 @@ const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`)
 const ID_ONLY_RE = new RegExp(`^${ID_RE}$`)
 const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`)
 const CIRCLE_NODE_RE = new RegExp(`^${ID_RE}\\(\\(.+\\)\\)$`)
+const MAX_FLOWCHART_LINE_LENGTH = 10_000
 const EDGE_OPERATOR_RE =
   /(-\.(?!->)(.+?)\.(?:->|-))|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->|\.-)|(<-->|-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/dg
 
@@ -252,6 +253,9 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
 
   for (const source of meaningfulNumberedMermaidLines(content)) {
     const line = source.text
+    if (line.length > MAX_FLOWCHART_LINE_LENGTH) {
+      throw new MermaidSyntaxError("flowchart", source.lineNumber, line, "Flowchart statement is too long")
+    }
     if (hasInternalStatementSeparator(line)) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
     const header = line.match(FLOWCHART_HEADER_RE)
     if (header) {

+ 15 - 3
packages/merman/src/markdown.ts

@@ -37,6 +37,8 @@ interface PreparedDiagram {
 
 export interface MermaidMarkdownRendererOptions {
   compact?: boolean
+  /** Fold horizontal flowcharts that exceed this width. Defaults to 120 columns. */
+  layoutMaxWidth?: number
   colors?: {
     text?: ColorInput
     primary?: ColorInput
@@ -101,11 +103,19 @@ class StaticDiagramRenderable extends TextRenderable {
   }
 }
 
-function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkdownRendererOptions): PreparedDiagram {
+function prepareDiagram(
+  kind: DiagramKind,
+  source: string,
+  options: MermaidMarkdownRendererOptions,
+  layoutMaxWidth: number,
+): PreparedDiagram {
   const colors = options.colors ?? {}
   switch (kind) {
     case "flowchart": {
-      const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), { compact: options.compact })
+      const grid = drawFlowchartDiagramGrid(parseMermaidFlowchartDiagram(source), {
+        compact: options.compact,
+        layoutMaxWidth,
+      })
       const size = grid.getTextSize({ trimTop: true, trimBottom: true })
       return {
         kind,
@@ -192,9 +202,11 @@ export function createMermaidCodeBlockRenderer(
     // OpenTUI's default block ID is the stable identity available for this fence across streaming updates.
     const key = context.defaultRender()?.id
     const options = typeof input === "function" ? input() : input
+    const configuredMaxWidth = options.layoutMaxWidth === undefined ? 120 : Math.max(1, Math.trunc(options.layoutMaxWidth))
+    const layoutMaxWidth = Math.min(configuredMaxWidth, Math.max(1, Math.trunc(ctx.width)))
 
     try {
-      const prepared = prepareDiagram(kind, token.text, options)
+      const prepared = prepareDiagram(kind, token.text, options, layoutMaxWidth)
       const diagram = new StaticDiagramRenderable(ctx, prepared)
       if (key) claimLastGood(key, prepared, diagram, lastGood)
       return diagram

+ 4 - 0
packages/merman/src/sequence/parser.ts

@@ -23,6 +23,7 @@ const ELSE_RE = /^else(?:\s+(.+))?$/i
 const LOOP_RE = /^loop\s+(.+)$/i
 const AUTONUMBER_RE = /^autonumber(?:\s+(\d+)(?:\s+(\d+))?)?$/i
 const UNSUPPORTED_BIDIRECTIONAL_MESSAGE_RE = /<<-{1,2}>>/
+const MESSAGE_OPERATOR_RE = /-->>|->>|--x|-x|--\)|-\)|-->|->/
 const CSS_COLOR_NAMES = new Set([
   "black",
   "white",
@@ -237,6 +238,9 @@ export function parseMermaidSequenceDiagram(content: string): SequenceDiagram {
       const arrow = messageMatch[2]!
       const activationMarker = messageMatch[3]!
       const to = stripQuotes(messageMatch[4]!)
+      if (MESSAGE_OPERATOR_RE.test(from) || MESSAGE_OPERATOR_RE.test(to)) {
+        throw new MermaidSyntaxError("sequence", source.lineNumber, line)
+      }
       const message: SequenceMessage = {
         from,
         to,

+ 3 - 0
packages/merman/src/state/parser.ts

@@ -176,6 +176,9 @@ export function parseMermaidStateDiagram(content: string): StateDiagram {
     if (transitionMatch) {
       const rawFrom = transitionMatch[1]!
       const rawTo = transitionMatch[2]!
+      if (rawFrom.includes("-->") || rawTo.includes("-->")) {
+        throw new MermaidSyntaxError("state", source.lineNumber, line)
+      }
       const from = normalizeStateDiagramEndpoint(rawFrom, "from", parentId)
       const to = normalizeStateDiagramEndpoint(rawTo, "to", parentId)
       ensureState(states, from, rawFrom === "[*]" ? "●" : from, rawFrom === "[*]" ? "start" : "state", parentId)

+ 10 - 0
packages/merman/src/test/diagnostics.test.ts

@@ -41,6 +41,11 @@ describe("parser diagnostics", () => {
     expect(diagram.edges).toHaveLength(1)
   })
 
+  test("rejects pathological flowchart statements before parsing edge operators", () => {
+    const statement = "-.a".repeat(4_000)
+    expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n${statement}`)).toThrow("Flowchart statement is too long")
+  })
+
   test("exposes structured syntax errors through top-level rendering", () => {
     try {
       renderSequenceDiagram(`sequenceDiagram
@@ -62,6 +67,11 @@ describe("parser diagnostics", () => {
     }
   })
 
+  test("rejects chained sequence and state transitions instead of creating phantom endpoints", () => {
+    expect(() => parseMermaidSequenceDiagram("sequenceDiagram\n  A->>B->>C: hello")).toThrow(MermaidSyntaxError)
+    expect(() => parseMermaidStateDiagram("stateDiagram-v2\n  A-->B-->C")).toThrow(MermaidSyntaxError)
+  })
+
   test("reports unclosed state constructs at their opening line", () => {
     expect(() =>
       parseMermaidStateDiagram(`stateDiagram-v2

+ 22 - 0
packages/merman/src/test/markdown.test.ts

@@ -289,6 +289,28 @@ flowchart TB
   expect(frame).not.toMatch(/<\/?i>|<br|events persist|mcp stdio/)
 })
 
+test("folds a horizontal flowchart to fit the Markdown viewport", async () => {
+  const testRenderer = await createTestRenderer({ width: 160, height: 30 })
+  renderer = testRenderer.renderer
+  const markdown = new MarkdownRenderable(renderer, {
+    id: "markdown-horizontal-flowchart",
+    content: `\`\`\`mermaid
+flowchart LR
+  A["per TURN<br/>fresh sandbox each turn"] --- B["per SESSION/thread<br/>one sandbox per Slack thread"] --- C["per REPO<br/>threads share a sandbox"] --- D["GLOBAL registry<br/>(upstream today: hardwired at boot)"]
+\`\`\``,
+    syntaxStyle,
+    renderNode: createMermaidMarkdownRenderer(renderer),
+  })
+
+  renderer.root.add(markdown)
+  await renderMarkdown(markdown, testRenderer.renderOnce)
+
+  const diagram = markdown.getChildren()[0] as CodeRenderable
+  expect(diagram.scrollWidth).toBeLessThanOrEqual(diagram.width)
+  expect(diagram.scrollWidth).toBeLessThanOrEqual(120)
+  expect(testRenderer.captureCharFrame()).toContain("GLOBAL registry")
+})
+
 test("renders a Mermaid state fence inside MarkdownRenderable", async () => {
   const testRenderer = await createTestRenderer({ width: 80, height: 14 })
   renderer = testRenderer.renderer