Merge PR #5295: fix(utils): bound Mermaid ASCII pathfinder (@roboomp)

This commit is contained in:
can1357
2026-07-14 19:16:08 +02:00
3 changed files with 122 additions and 6 deletions
+3
View File
@@ -5,6 +5,9 @@
### Added
- Added a terminal stderr guard (`suppressTerminalStderr`/`restoreTerminalStderr`): dup2-redirects fd 2 to the omp log file while a TUI owns the terminal so macOS runtime diagnostics cannot paint into the viewport. The postmortem fatal handlers restore fd 2 before printing so crash reports stay visible.
### 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
+67 -5
View File
@@ -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<string, AsciiNode>, 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<string, AsciiNode>, 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<string, AsciiNode>, 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<string, AsciiNode>,
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<string, GridCoord | null>()
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
}
+52 -1
View File
@@ -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<string, AsciiNode>();
for (const blocker of blockers) {
grid.set(gridKey(blocker), node);
}
expect(getPath(grid, { x: 0, y: 2 }, enclosed)).toBeNull();
});
});