From cc2041ab7fab90ecaf74377ba45b0159d6bed13f Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 12 Jul 2026 18:54:28 +0000 Subject: [PATCH] fix(utils): bounded mermaid ascii pathfinder - Added bounded A* routing extents plus an expansion backstop so unreachable attachment points return null instead of searching the unbounded quadrant. - Covered the reported declaration-order graph and an enclosed destination attachment point. - Documented the Mermaid ASCII routing fix in the utils changelog. Fixes #5293 --- packages/utils/CHANGELOG.md | 4 ++ .../vendor/mermaid-ascii/ascii/pathfinder.ts | 72 +++++++++++++++++-- packages/utils/test/mermaid-ascii.test.ts | 53 +++++++++++++- 3 files changed, 123 insertions(+), 6 deletions(-) diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 992cfb967..6f79aa40b 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Mermaid ASCII routing to stop unbounded pathfinder searches when an edge attachment point is unreachable. ([#5293](https://github.com/can1357/oh-my-pi/issues/5293)) + ## [16.4.6] - 2026-07-12 ### Added diff --git a/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts index d7e40ddfc..a84465afd 100644 --- a/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts +++ b/packages/utils/src/vendor/mermaid-ascii/ascii/pathfinder.ts @@ -108,21 +108,75 @@ const MOVE_DIRS: GridCoord[] = [ { x: 0, y: -1 }, ] -/** Check if a grid cell is unoccupied and has non-negative coordinates. */ -function isFreeInGrid(grid: Map, c: GridCoord): boolean { - if (c.x < 0 || c.y < 0) return false +interface SearchBounds { + minX: number + maxX: number + minY: number + maxY: number + expansionLimit: number +} + +const MIN_ROUTING_MARGIN = 8 +const MIN_EXPANSION_BUDGET = 256 +const MAX_EXPANSION_BUDGET = 50_000 + +function searchBoundsFor(grid: Map, from: GridCoord, to: GridCoord): SearchBounds { + let minX = Math.min(from.x, to.x) + let maxX = Math.max(from.x, to.x) + let minY = Math.min(from.y, to.y) + let maxY = Math.max(from.y, to.y) + + for (const key of grid.keys()) { + const comma = key.indexOf(',') + if (comma === -1) continue + + const x = Number(key.slice(0, comma)) + const y = Number(key.slice(comma + 1)) + if (!Number.isFinite(x) || !Number.isFinite(y)) continue + + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minY = Math.min(minY, y) + maxY = Math.max(maxY, y) + } + + const width = maxX - minX + 1 + const height = maxY - minY + 1 + const margin = Math.max(MIN_ROUTING_MARGIN, Math.ceil(Math.max(width, height) / 2)) + const boundedMinX = Math.max(0, minX - margin) + const boundedMaxX = maxX + margin + const boundedMinY = Math.max(0, minY - margin) + const boundedMaxY = maxY + margin + const area = (boundedMaxX - boundedMinX + 1) * (boundedMaxY - boundedMinY + 1) + + return { + minX: boundedMinX, + maxX: boundedMaxX, + minY: boundedMinY, + maxY: boundedMaxY, + expansionLimit: Math.min(MAX_EXPANSION_BUDGET, Math.max(MIN_EXPANSION_BUDGET, area * 4)), + } +} + +/** Check if a grid cell is unoccupied and inside the bounded routing area. */ +function isFreeInGrid(grid: Map, c: GridCoord, bounds: SearchBounds): boolean { + if (c.x < bounds.minX || c.x > bounds.maxX || c.y < bounds.minY || c.y > bounds.maxY) { + return false + } + return !grid.has(gridKey(c)) } /** * Find a path from `from` to `to` on the grid using A*. - * Returns the path as an array of GridCoords, or null if no path exists. + * Returns the path as an array of GridCoords, or null if no bounded path exists. */ export function getPath( grid: Map, from: GridCoord, to: GridCoord, ): GridCoord[] | null { + const bounds = searchBoundsFor(grid, from, to) const pq = new MinHeap() pq.push({ coord: from, priority: 0 }) @@ -132,7 +186,13 @@ export function getPath( const cameFrom = new Map() cameFrom.set(gridKey(from), null) + let expansions = 0 + while (pq.length > 0) { + if (expansions++ >= bounds.expansionLimit) { + return null + } + const current = pq.pop()!.coord if (gridCoordEquals(current, to)) { @@ -150,9 +210,11 @@ export function getPath( for (const dir of MOVE_DIRS) { const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y } + const insideBounds = next.x >= bounds.minX && next.x <= bounds.maxX + && next.y >= bounds.minY && next.y <= bounds.maxY // Allow moving to the destination even if it's occupied (it's a node boundary) - if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) { + if (!insideBounds || (!isFreeInGrid(grid, next, bounds) && !gridCoordEquals(next, to))) { continue } diff --git a/packages/utils/test/mermaid-ascii.test.ts b/packages/utils/test/mermaid-ascii.test.ts index 811f98cda..ef9d44f7c 100644 --- a/packages/utils/test/mermaid-ascii.test.ts +++ b/packages/utils/test/mermaid-ascii.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "bun:test"; -import { renderMermaidAscii } from "../src/mermaid-ascii"; +import { renderMermaidAscii, renderMermaidAsciiSafe } from "../src/mermaid-ascii"; +import { getPath } from "../src/vendor/mermaid-ascii/ascii/pathfinder"; +import { type AsciiNode, type GridCoord, gridKey } from "../src/vendor/mermaid-ascii/ascii/types"; describe("renderMermaidAscii", () => { it("preserves an existing emoji edge label when a later narrow label collides with it", () => { @@ -10,4 +12,53 @@ describe("renderMermaidAscii", () => { expect(rendered).toContain("─🚀─"); expect(rendered).not.toContain("──A─"); }); + + it("returns a bounded fallback for declaration orders that make a clean route unreachable", () => { + const rendered = renderMermaidAsciiSafe( + [ + "flowchart TD", + " Worker[Worker]", + " Archive[Archive]", + " Gateway[Gateway]", + " Audit[Audit]", + "", + " Worker --> Archive", + " Gateway --> Worker", + " Gateway --> Audit", + ].join("\n"), + { colorMode: "none" }, + ); + + if (rendered === null) { + throw new Error("expected Mermaid ASCII renderer to return fallback output"); + } + + expect(rendered).toContain("Archive"); + expect(rendered).toContain("Gateway"); + expect(rendered).toContain("Audit"); + }); + + it("returns null when the destination attachment point is enclosed", () => { + const node: AsciiNode = { + name: "blocker", + displayLabel: "blocker", + shape: "rectangle", + index: 0, + gridCoord: null, + drawingCoord: null, + drawing: null, + drawn: false, + styleClassName: "", + styleClass: { name: "", styles: {} }, + }; + const enclosed: GridCoord = { x: 2, y: 2 }; + const blockers: GridCoord[] = [enclosed, { x: 1, y: 2 }, { x: 3, y: 2 }, { x: 2, y: 1 }, { x: 2, y: 3 }]; + const grid = new Map(); + + for (const blocker of blockers) { + grid.set(gridKey(blocker), node); + } + + expect(getPath(grid, { x: 0, y: 2 }, enclosed)).toBeNull(); + }); });