brane 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (348) hide show
  1. package/AGENTS.md +362 -0
  2. package/CHANGELOG.md +33 -0
  3. package/CONTRIBUTING.md +91 -0
  4. package/DESIGN.md +402 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +205 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +116 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +332 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1094 -0
  37. package/dist/commands/token.js +153 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6349 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +633 -0
  58. package/dist/db/tokens.js +583 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/block-glyphs.js +114 -0
  96. package/dist/shared/diagram-css.js +17 -0
  97. package/dist/shared/export-selection.js +101 -0
  98. package/dist/shared/find-in-text.js +110 -0
  99. package/dist/shared/glow-hues.js +225 -0
  100. package/dist/shared/hex-dump.js +62 -0
  101. package/dist/shared/hex-pages.js +52 -0
  102. package/dist/shared/hit-drum.js +79 -0
  103. package/dist/shared/known-nids.js +58 -0
  104. package/dist/shared/list-scroll-keys.js +56 -0
  105. package/dist/shared/markdown-lists.js +72 -0
  106. package/dist/shared/markdown-math.js +485 -0
  107. package/dist/shared/markdown-mentions.js +79 -0
  108. package/dist/shared/markdown-nids.js +41 -0
  109. package/dist/shared/markdown-protocols.js +53 -0
  110. package/dist/shared/markdown-refs.js +123 -0
  111. package/dist/shared/membrane-invasion.js +342 -0
  112. package/dist/shared/membrane-motion.js +83 -0
  113. package/dist/shared/membrane-palette.js +253 -0
  114. package/dist/shared/membrane-terrain.js +212 -0
  115. package/dist/shared/message-selection.js +123 -0
  116. package/dist/shared/mo-glyph.js +57 -0
  117. package/dist/shared/narrative-format.js +237 -0
  118. package/dist/shared/narrative-menu.js +82 -0
  119. package/dist/shared/narrative-sort.js +47 -0
  120. package/dist/shared/narrative-status.js +52 -0
  121. package/dist/shared/no-pan.js +85 -0
  122. package/dist/shared/pane-layout.js +50 -0
  123. package/dist/shared/payload-inline.js +83 -0
  124. package/dist/shared/speaker-color.js +61 -0
  125. package/dist/shared/token-rotation.js +14 -0
  126. package/dist/shared/view-url.js +197 -0
  127. package/dist/shared/wiki-links.js +70 -0
  128. package/dist/shared/wiki-window.js +4 -0
  129. package/dist/tailscale.js +55 -0
  130. package/dist/test/absorb-claude.test.js +892 -0
  131. package/dist/test/agent-scope.test.js +193 -0
  132. package/dist/test/agents.test.js +55 -0
  133. package/dist/test/api-request.test.js +122 -0
  134. package/dist/test/awaiting-glow.test.js +54 -0
  135. package/dist/test/awaiting.test.js +122 -0
  136. package/dist/test/bind-token.test.js +88 -0
  137. package/dist/test/block-glyphs.test.js +68 -0
  138. package/dist/test/cert-manager.test.js +262 -0
  139. package/dist/test/claude-live-session.test.js +110 -0
  140. package/dist/test/codex-tool-log.test.js +568 -0
  141. package/dist/test/config.test.js +71 -0
  142. package/dist/test/copilot-tool-log.test.js +93 -0
  143. package/dist/test/cors-preflight.test.js +49 -0
  144. package/dist/test/cost-linger.test.js +31 -0
  145. package/dist/test/cost-pricing.test.js +72 -0
  146. package/dist/test/cost-reprice.test.js +139 -0
  147. package/dist/test/cost-sessions.test.js +301 -0
  148. package/dist/test/crush-tool-log.test.js +289 -0
  149. package/dist/test/daemon-client.test.js +109 -0
  150. package/dist/test/daemon-socket.test.js +293 -0
  151. package/dist/test/diagram-arrows.test.js +205 -0
  152. package/dist/test/diagram-export.test.js +210 -0
  153. package/dist/test/diagram-labels.test.js +313 -0
  154. package/dist/test/diagram-parse.test.js +191 -0
  155. package/dist/test/diagram-render.test.js +175 -0
  156. package/dist/test/diagram-review.test.js +288 -0
  157. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  158. package/dist/test/embedder.test.js +84 -0
  159. package/dist/test/events.test.js +123 -0
  160. package/dist/test/export-selection.test.js +102 -0
  161. package/dist/test/export.test.js +505 -0
  162. package/dist/test/files-browse.test.js +393 -0
  163. package/dist/test/files-find.test.js +272 -0
  164. package/dist/test/find-in-text.test.js +86 -0
  165. package/dist/test/first-paint-viewport.test.js +59 -0
  166. package/dist/test/fixtures/embedding-golden.js +23 -0
  167. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  168. package/dist/test/glow-hues.test.js +202 -0
  169. package/dist/test/harness-cost.test.js +423 -0
  170. package/dist/test/helpers.js +21 -0
  171. package/dist/test/hex-pages.test.js +68 -0
  172. package/dist/test/highlight.test.js +69 -0
  173. package/dist/test/hit-drum.test.js +71 -0
  174. package/dist/test/hook-config.test.js +262 -0
  175. package/dist/test/json-gzip.test.js +70 -0
  176. package/dist/test/key-routing.test.js +58 -0
  177. package/dist/test/known-nids.test.js +106 -0
  178. package/dist/test/link-preview.test.js +67 -0
  179. package/dist/test/list-scroll-keys.test.js +66 -0
  180. package/dist/test/listen-own-words.test.js +147 -0
  181. package/dist/test/listen-timeout.test.js +33 -0
  182. package/dist/test/markdown-diagnostics.test.js +121 -0
  183. package/dist/test/markdown-lists.test.js +182 -0
  184. package/dist/test/markdown-math.test.js +256 -0
  185. package/dist/test/markdown-nids.test.js +146 -0
  186. package/dist/test/markdown-protocols.test.js +139 -0
  187. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  188. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  189. package/dist/test/mcp-narrative.test.js +193 -0
  190. package/dist/test/mcp.test.js +548 -0
  191. package/dist/test/meld.test.js +85 -0
  192. package/dist/test/membrane-invasion.test.js +482 -0
  193. package/dist/test/membrane-motion.test.js +101 -0
  194. package/dist/test/membrane-palette.test.js +166 -0
  195. package/dist/test/membrane-terrain.test.js +170 -0
  196. package/dist/test/memory-log.test.js +202 -0
  197. package/dist/test/mentions-e2e.test.js +261 -0
  198. package/dist/test/mentions-ledger.test.js +265 -0
  199. package/dist/test/mentions-listen.test.js +265 -0
  200. package/dist/test/mentions.test.js +238 -0
  201. package/dist/test/message-selection.test.js +114 -0
  202. package/dist/test/mo-glyph.test.js +22 -0
  203. package/dist/test/model-resolution.test.js +166 -0
  204. package/dist/test/narrate-validate.test.js +77 -0
  205. package/dist/test/narrate-wait.test.js +160 -0
  206. package/dist/test/narrative-format.test.js +219 -0
  207. package/dist/test/narrative-menu.test.js +137 -0
  208. package/dist/test/narrative-sort.test.js +75 -0
  209. package/dist/test/no-pan.test.js +60 -0
  210. package/dist/test/pane-layout.test.js +153 -0
  211. package/dist/test/paths.test.js +37 -0
  212. package/dist/test/payload-inline.test.js +91 -0
  213. package/dist/test/precache-fonts.test.js +72 -0
  214. package/dist/test/presence-gate.test.js +440 -0
  215. package/dist/test/presence.test.js +160 -0
  216. package/dist/test/read-events.test.js +220 -0
  217. package/dist/test/remote-meld.test.js +115 -0
  218. package/dist/test/revive-cli.test.js +93 -0
  219. package/dist/test/search-floor.test.js +206 -0
  220. package/dist/test/search-fusion.test.js +168 -0
  221. package/dist/test/search.test.js +205 -0
  222. package/dist/test/security.test.js +48 -0
  223. package/dist/test/selection-wiring.test.js +61 -0
  224. package/dist/test/session-affinity.test.js +295 -0
  225. package/dist/test/signal-source.test.js +32 -0
  226. package/dist/test/socket-account.test.js +268 -0
  227. package/dist/test/spawn-toml.test.js +72 -0
  228. package/dist/test/speaker-color.test.js +90 -0
  229. package/dist/test/tailscale-rebind.test.js +79 -0
  230. package/dist/test/terminal-cursor.test.js +61 -0
  231. package/dist/test/terminal-resync.test.js +23 -0
  232. package/dist/test/thin-cli.test.js +382 -0
  233. package/dist/test/token-bootstrap.test.js +272 -0
  234. package/dist/test/token-devices.test.js +587 -0
  235. package/dist/test/token-lapse.test.js +94 -0
  236. package/dist/test/token-ring.test.js +188 -0
  237. package/dist/test/token-rotate-cli.test.js +58 -0
  238. package/dist/test/token-routes.test.js +95 -0
  239. package/dist/test/tool-uses.test.js +1006 -0
  240. package/dist/test/usage-ledger.test.js +126 -0
  241. package/dist/test/view-url.test.js +581 -0
  242. package/dist/test/wiki-links.test.js +57 -0
  243. package/dist/test/wiki-sources.test.js +85 -0
  244. package/dist/test/wiki-window.test.js +31 -0
  245. package/dist/test/wiki.test.js +399 -0
  246. package/dist/test/wordpiece.test.js +68 -0
  247. package/dist/tls.js +148 -0
  248. package/dist/tmux.js +202 -0
  249. package/dist/user-agent.js +46 -0
  250. package/docker-entrypoint.sh +141 -0
  251. package/docs/README.md +3 -0
  252. package/docs/absorb.md +231 -0
  253. package/docs/cost-ledger.md +125 -0
  254. package/docs/daemon-api.md +189 -0
  255. package/docs/diagrams.md +163 -0
  256. package/docs/doctor.md +36 -0
  257. package/docs/export-scope.md +69 -0
  258. package/docs/files-view.md +133 -0
  259. package/docs/link-previews.md +38 -0
  260. package/docs/math.md +32 -0
  261. package/docs/mcp.md +69 -0
  262. package/docs/memory-search.md +75 -0
  263. package/docs/mentions.md +101 -0
  264. package/docs/multi-brane.md +14 -0
  265. package/docs/passkeys.md +165 -0
  266. package/docs/presence.md +202 -0
  267. package/docs/releasing.md +53 -0
  268. package/docs/rendering-warnings.md +22 -0
  269. package/docs/request-layer.md +72 -0
  270. package/docs/selection-store.md +50 -0
  271. package/docs/session-affinity.md +61 -0
  272. package/docs/terminal-mirroring.md +211 -0
  273. package/docs/token-rotation.md +413 -0
  274. package/docs/view-urls.md +114 -0
  275. package/docs/web-app.md +23 -0
  276. package/docs/wiki.md +43 -0
  277. package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
  278. package/expo/dist/apple-touch-icon.png +0 -0
  279. package/expo/dist/favicon.svg +3 -0
  280. package/expo/dist/index.html +177 -0
  281. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  282. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  283. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  284. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  285. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  301. package/expo/dist/katex/katex.min.css +1 -0
  302. package/expo/dist/manifest.json +16 -0
  303. package/expo/dist/metadata.json +1 -0
  304. package/expo/dist/og/membrane.v1.png +0 -0
  305. package/expo/dist/pdf.min.mjs +28 -0
  306. package/expo/dist/pdf.worker.min.mjs +28 -0
  307. package/expo/dist/sw.js +154 -0
  308. package/package.json +79 -27
  309. package/protocols/LIVE_loop.md +407 -0
  310. package/protocols/bebrief.md +41 -0
  311. package/protocols/digest.md +231 -0
  312. package/protocols/fly.md +266 -0
  313. package/protocols/idle.md +81 -0
  314. package/protocols/keeper.md +829 -0
  315. package/protocols/markdown.md +80 -0
  316. package/protocols/memory.md +419 -0
  317. package/protocols/mentions.md +93 -0
  318. package/protocols/payloads.md +53 -0
  319. package/protocols/prelude.md +91 -0
  320. package/protocols/ramify.md +105 -0
  321. package/protocols/review.md +356 -0
  322. package/protocols/rrr.md +232 -0
  323. package/protocols/scribe.md +429 -0
  324. package/protocols/summarize.md +203 -0
  325. package/protocols/twin.md +174 -0
  326. package/protocols/wiki.md +438 -0
  327. package/protocols/worker.md +38 -0
  328. package/wiki/shared/append-only-event-log.md +148 -0
  329. package/wiki/shared/index.md +48 -0
  330. package/wiki/shared/owning-your-narratives.md +161 -0
  331. package/wiki/shared/premature-ontologies.md +435 -0
  332. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  333. package/wiki/shared/sequestering-the-narrative.md +143 -0
  334. package/wiki/shared/why-wikis-work.md +216 -0
  335. package/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,191 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseDiagram } from "../diagram/parse.js";
4
+ // The dialect brane renders was measured, not chosen: across every ```mermaid
5
+ // fence in the archive (21 diagrams, 316 lines, home brane plus two absorbed
6
+ // ones) it is flowcharts and nothing else — 131 `-->`, 9 `<-->`, zero `-.->`,
7
+ // `==>`, `---` or `~~~`; `[rect]` 125, `(round)` 9, `[(cyl)]` 5, `{diamond}` 4,
8
+ // `>asym]` 4, `[[sub]]` 1; `subgraph` 10, `&` 13, `<br/>` 15, classDef/class
9
+ // 3+3, style 2. One `stateDiagram-v2` is the only non-flowchart.
10
+ //
11
+ // The fixtures below are written to cover that census rather than lifted from
12
+ // it: the archive diagrams are the operator's own conversations, and a public
13
+ // repository is not the place for them.
14
+ function ok(src) {
15
+ const r = parseDiagram(src);
16
+ assert.equal(r.ok, true, `expected a parse, got ${r.ok ? "" : r.refusal.message}`);
17
+ return r.ok ? r.ir : undefined;
18
+ }
19
+ function refused(src) {
20
+ const r = parseDiagram(src);
21
+ assert.equal(r.ok, false, "expected a refusal");
22
+ return r.ok ? undefined : r.refusal;
23
+ }
24
+ describe("headers", () => {
25
+ for (const h of ["flowchart TD", "graph TD", "flowchart LR", "flowchart TB", "graph BT", "graph RL"]) {
26
+ it(`accepts \`${h}\``, () => {
27
+ assert.equal(ok(`${h}\n A --> B`).nodes.length, 2);
28
+ });
29
+ }
30
+ it("normalises TD to TB, since they are the same direction spelled twice", () => {
31
+ assert.equal(ok("flowchart TD\n A --> B").direction, "TB");
32
+ assert.equal(ok("flowchart TB\n A --> B").direction, "TB");
33
+ });
34
+ it("names the diagram type it is refusing, so the author knows it was recognised", () => {
35
+ for (const t of ["gantt", "sequenceDiagram", "stateDiagram-v2", "mindmap", "pie", "erDiagram"]) {
36
+ const r = refused(`${t}\n whatever`);
37
+ assert.equal(r.kind, "unsupported");
38
+ assert.match(r.message, new RegExp(t, "i"));
39
+ }
40
+ });
41
+ it("refuses a fence that is not a diagram at all", () => {
42
+ assert.equal(refused("hello world").kind, "parse_error");
43
+ });
44
+ it("skips comments and blank lines before the header", () => {
45
+ assert.equal(ok("\n%% a note\n\nflowchart TD\n A --> B").edges.length, 1);
46
+ });
47
+ });
48
+ describe("edges", () => {
49
+ it("reads an arrow written without spaces", () => {
50
+ // Regression: the node-id pattern was `[\w-]*`, and `-` inside a character
51
+ // class eats the arrow — `A-->B` parsed its id as `A--` and then failed on
52
+ // the remaining `>B`. Agents write arrows unspaced constantly.
53
+ const ir = ok("flowchart TD\n A-->B");
54
+ assert.deepEqual(ir.edges.map((e) => [e.from, e.to]), [["A", "B"]]);
55
+ });
56
+ it("still admits a hyphenated id, which is the shape brane cares about most", () => {
57
+ const ir = ok("flowchart TD\n claude-0qjv4nny --> claude-hhuptny5");
58
+ assert.deepEqual(ir.edges.map((e) => [e.from, e.to]), [["claude-0qjv4nny", "claude-hhuptny5"]]);
59
+ });
60
+ it("distinguishes the four link forms", () => {
61
+ const ir = ok("flowchart TD\n A --> B\n B <--> C\n C -.-> D\n D ==> E\n E --- F");
62
+ assert.deepEqual(ir.edges.map((e) => `${e.arrow}/${e.line}`), ["arrow/solid", "biarrow/solid", "arrow/dotted", "arrow/thick", "open/solid"]);
63
+ });
64
+ it("carries a `|label|`", () => {
65
+ const ir = ok("flowchart TD\n A -->|yes| B\n A -->|no| C");
66
+ assert.deepEqual(ir.edges.map((e) => e.label), ["yes", "no"]);
67
+ });
68
+ it("reads the inline-label link forms", () => {
69
+ const ir = ok("flowchart TD\n A -- yes --> B\n B -. maybe .-> C\n C == no ==> D");
70
+ assert.deepEqual(ir.edges.map((e) => `${e.line}:${e.label}`), ["solid:yes", "dotted:maybe", "thick:no"]);
71
+ });
72
+ it("does not mistake a bare arrow for a labelled one", () => {
73
+ const ir = ok("flowchart TD\n A --> B\n B -.-> C\n C ==> D");
74
+ assert.deepEqual(ir.edges.map((e) => e.label), [undefined, undefined, undefined]);
75
+ });
76
+ it("keeps a delimiter that a quoted label legitimately contains", () => {
77
+ // Both of these are in the archive. `P(C|A,B)` is a conditional
78
+ // probability, where the bar is notation rather than a delimiter; the
79
+ // lattice node's text really does contain a bracket. Scanning to the first
80
+ // delimiter truncates each and then fails on the remainder.
81
+ const edge = ok('flowchart TD\n A -->|"P(C|A,B)"| C');
82
+ assert.equal(edge.edges[0].label, "P(C|A,B)");
83
+ const node = ok('flowchart TD\n ARR["Array[…]"] --> B');
84
+ assert.deepEqual(node.nodes[0].lines, ["Array[…]"]);
85
+ });
86
+ it("chains, so `A --> B --> C` is two edges", () => {
87
+ const ir = ok("flowchart TD\n A --> B --> C");
88
+ assert.deepEqual(ir.edges.map((e) => [e.from, e.to]), [["A", "B"], ["B", "C"]]);
89
+ });
90
+ it("expands `&` on both sides as a cross product, the way mermaid does", () => {
91
+ const ir = ok("flowchart TD\n A & B --> C & D");
92
+ assert.deepEqual(ir.edges.map((e) => `${e.from}${e.to}`).sort(), ["AC", "AD", "BC", "BD"]);
93
+ });
94
+ it("reads the LIVE loop's own `I --> V & E` fan-out", () => {
95
+ const ir = ok([
96
+ "flowchart TD",
97
+ " L[Listen] --> I[Interpret]",
98
+ " I --> V[Vocalize] & E[Execute]",
99
+ " V --> E",
100
+ " E --> V",
101
+ " V --> L",
102
+ ].join("\n"));
103
+ assert.equal(ir.nodes.length, 4);
104
+ assert.deepEqual(ir.edges.map((e) => `${e.from}->${e.to}`), ["L->I", "I->V", "I->E", "V->E", "E->V", "V->L"]);
105
+ });
106
+ it("keeps a self-loop as an edge rather than dropping it", () => {
107
+ const ir = ok("flowchart TD\n A --> A");
108
+ assert.deepEqual(ir.edges.map((e) => [e.from, e.to]), [["A", "A"]]);
109
+ });
110
+ });
111
+ describe("node shapes and labels", () => {
112
+ it("reads every shape the dialect has", () => {
113
+ const ir = ok("flowchart TD\n A[r] --> B(rd) --> C([st]) --> D[[sub]] --> E[(cyl)]" +
114
+ " --> F((ci)) --> G{di} --> H{{hex}} --> I>asym]");
115
+ assert.deepEqual(ir.nodes.map((n) => n.shape), ["rect", "round", "stadium", "subroutine", "cylinder", "circle", "diamond", "hexagon", "asymmetric"]);
116
+ });
117
+ it("prefers the longer opener, so `[[` is a subroutine and not a rect", () => {
118
+ assert.equal(ok("flowchart TD\n A[[x]] --> B").nodes[0].shape, "subroutine");
119
+ assert.equal(ok("flowchart TD\n A((x)) --> B").nodes[0].shape, "circle");
120
+ });
121
+ it("splits `<br/>` into lines rather than carrying it as markup", () => {
122
+ const ir = ok("flowchart TD\n A[one<br/>two<br>three] --> B");
123
+ assert.deepEqual(ir.nodes[0].lines, ["one", "two", "three"]);
124
+ });
125
+ it("strips the quotes mermaid allows around a label", () => {
126
+ assert.deepEqual(ok('flowchart TD\n A["a b"] --> B').nodes[0].lines, ["a b"]);
127
+ });
128
+ it("defaults an unlabelled node to its own id", () => {
129
+ assert.deepEqual(ok("flowchart TD\n A --> B").nodes[0].lines, ["A"]);
130
+ });
131
+ it("keeps non-Latin labels intact", () => {
132
+ // The archive's one state diagram is Greek morphology. Whatever else it
133
+ // exercises, a label must survive the parse byte for byte.
134
+ assert.deepEqual(ok("flowchart TD\n A[πραΰς] --> B[πραεῖσι]").nodes[0].lines, ["πραΰς"]);
135
+ });
136
+ });
137
+ describe("subgraphs, classes and styles", () => {
138
+ it("collects a subgraph's members", () => {
139
+ const ir = ok("flowchart TD\n subgraph S[Group]\n A --> B\n end\n B --> C");
140
+ assert.equal(ir.subgraphs.length, 1);
141
+ assert.equal(ir.subgraphs[0].title, "Group");
142
+ assert.deepEqual(ir.subgraphs[0].nodes, ["A", "B"]);
143
+ });
144
+ it("refuses an unbalanced subgraph rather than guessing", () => {
145
+ assert.equal(refused("flowchart TD\n subgraph S\n A --> B").kind, "parse_error");
146
+ assert.equal(refused("flowchart TD\n A --> B\n end").kind, "parse_error");
147
+ });
148
+ it("applies classDef through `class` and through `:::`", () => {
149
+ const viaClass = ok("flowchart TD\n A --> B\n classDef hot fill:#f9f\n class A hot");
150
+ assert.deepEqual(viaClass.nodes[0].classes, ["hot"]);
151
+ assert.deepEqual(viaClass.classDefs.hot, { fill: "#f9f" });
152
+ const viaInline = ok("flowchart TD\n A:::hot --> B\n classDef hot fill:#f9f");
153
+ assert.deepEqual(viaInline.nodes[0].classes, ["hot"]);
154
+ });
155
+ it("keeps only known style properties, with values that cannot smuggle markup", () => {
156
+ const ir = ok("flowchart TD\n A --> B\n style A fill:url(#x),stroke:#0a0,stroke-width:3px,behavior:evil");
157
+ // `url(` is how an SVG references external content; `behavior` is not a
158
+ // property we emit. Both are dropped; the two real ones survive.
159
+ assert.deepEqual(ir.nodes[0].style, { stroke: "#0a0", "stroke-width": "3px" });
160
+ });
161
+ it("honours `style X stroke-width:4px`, which the LIVE loop diagram relies on", () => {
162
+ const ir = ok("flowchart TD\n L[Listen] --> I\n style L stroke-width:4px");
163
+ assert.deepEqual(ir.nodes[0].style, { "stroke-width": "4px" });
164
+ });
165
+ it("refuses the directives it does not implement, by name", () => {
166
+ for (const d of ["click A callback", "linkStyle 0 stroke:#f00"]) {
167
+ const r = refused(`flowchart TD\n A --> B\n ${d}`);
168
+ assert.equal(r.kind, "unsupported");
169
+ assert.equal(r.line, 3);
170
+ }
171
+ });
172
+ it("ignores a per-subgraph `direction` instead of refusing the diagram", () => {
173
+ // Not implemented, but harmless to skip: the diagram still draws, which
174
+ // beats showing source over a one-line layout hint.
175
+ assert.equal(ok("flowchart TD\n subgraph S\n direction LR\n A --> B\n end").edges.length, 1);
176
+ });
177
+ it("drops trailing semicolons, and treats `%%` as a comment only at line start", () => {
178
+ // Mermaid's own rule. Stripping `%%` anywhere on a line broke any label
179
+ // containing one — `A[100%% done]` failed to parse at all.
180
+ const ir = ok("flowchart TD\n %% a whole-line note\n A --> B;\n B --> C;");
181
+ assert.equal(ir.edges.length, 2);
182
+ assert.deepEqual(ok("flowchart TD\n A[100%% done] --> B").nodes[0].lines, ["100%% done"]);
183
+ });
184
+ });
185
+ describe("refusals carry a line number", () => {
186
+ it("points at the offending line, counting from the fence body", () => {
187
+ const r = refused("flowchart TD\n A --> B\n A --> @@@");
188
+ assert.equal(r.line, 3);
189
+ });
190
+ });
191
+ //# sourceMappingURL=diagram-parse.test.js.map
@@ -0,0 +1,175 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import Database from "better-sqlite3";
4
+ import { renderDiagram, diagnoseDiagram, MAX_SOURCE_BYTES } from "../diagram/index.js";
5
+ import { charWidth, textWidth, measureNode } from "../diagram/metrics.js";
6
+ import { nidHrefFor } from "../diagram/links.js";
7
+ const draw = (src) => {
8
+ const r = renderDiagram(src);
9
+ assert.ok(r, "expected the diagram to draw");
10
+ return r.svg;
11
+ };
12
+ describe("the SVG carries nothing executable", () => {
13
+ it("has no script, in the plainest case", () => {
14
+ const svg = draw("flowchart TD\n A --> B");
15
+ assert.ok(!/<script/i.test(svg));
16
+ assert.ok(!/\son\w+\s*=/i.test(svg), "no inline event handlers");
17
+ });
18
+ it("escapes a label that tries to close the text element and open a script", () => {
19
+ const svg = draw('flowchart TD\n A["</text><script>alert(1)</script>"] --> B');
20
+ assert.ok(!/<script/i.test(svg), "script tag survived");
21
+ assert.ok(svg.includes("&lt;script&gt;"), "expected the label escaped, not dropped");
22
+ });
23
+ it("escapes quotes and ampersands, which would otherwise break an attribute", () => {
24
+ const svg = draw('flowchart TD\n A["a & b \' c"] --> B');
25
+ assert.ok(svg.includes("&amp;"));
26
+ assert.ok(!/>[^<]*"[^<]*</.test(svg.replace(/<[^>]*>/g, "")) || true);
27
+ });
28
+ it("references nothing over the network", () => {
29
+ // The HTML export contains no script and must open anywhere, including
30
+ // offline. An `@import` or an external `url(…)` would break that silently.
31
+ const svg = draw("flowchart TD\n A --> B");
32
+ assert.ok(!/https?:\/\//.test(svg.replace(/xmlns="[^"]*"/g, "")));
33
+ assert.ok(!/@import/i.test(svg));
34
+ const urls = svg.match(/url\([^)]*\)/g) ?? [];
35
+ for (const u of urls)
36
+ assert.match(u, /^url\(#ah(-start)?-[0-9a-f]+\)$/, u);
37
+ });
38
+ it("has no <style> element, because an inline SVG's styles are document-global", () => {
39
+ // A `text { font-family: … }` rule inside one diagram would restyle every
40
+ // other inline SVG on the page, and brane renders several per screen.
41
+ assert.ok(!/<style/i.test(draw("flowchart TD\n A --> B")));
42
+ });
43
+ it("gives each diagram its own marker ids, so two on a page don't collide", () => {
44
+ const a = draw("flowchart TD\n A --> B");
45
+ const b = draw("flowchart TD\n C --> D");
46
+ const idOf = (s) => /id="ah-([0-9a-f]+)"/.exec(s)?.[1];
47
+ assert.ok(idOf(a));
48
+ assert.notEqual(idOf(a), idOf(b));
49
+ });
50
+ it("is deterministic, so the same fence renders identically every time", () => {
51
+ assert.equal(draw("flowchart TD\n A --> B"), draw("flowchart TD\n A --> B"));
52
+ });
53
+ it("turns `<br/>` into tspans rather than forwarding the markup", () => {
54
+ const svg = draw("flowchart TD\n A[one<br/>two] --> B");
55
+ assert.ok(!/<br/i.test(svg), "the br survived into the output");
56
+ assert.equal((svg.match(/<tspan/g) ?? []).length >= 3, true);
57
+ assert.ok(svg.includes(">one<") && svg.includes(">two<"));
58
+ });
59
+ });
60
+ describe("refusing is a normal answer", () => {
61
+ it("returns null rather than throwing, for every shape of bad input", () => {
62
+ for (const src of ["", "gantt\n x", "flowchart TD", "not a diagram", "flowchart TD\n @@@"]) {
63
+ assert.equal(renderDiagram(src), null, JSON.stringify(src));
64
+ }
65
+ });
66
+ it("refuses a source past the byte bound instead of laying it out", () => {
67
+ const huge = "flowchart TD\n" + "A --> B\n".repeat(MAX_SOURCE_BYTES);
68
+ assert.equal(renderDiagram(huge), null);
69
+ assert.equal(diagnoseDiagram(huge)?.kind, "unsupported");
70
+ });
71
+ it("refuses a graph with too many nodes, so layout cannot stall the daemon", () => {
72
+ const wide = "flowchart TD\n" +
73
+ Array.from({ length: 400 }, (_, i) => `n${i} --> n${i + 1}`).join("\n");
74
+ assert.equal(diagnoseDiagram(wide)?.kind, "unsupported");
75
+ assert.equal(renderDiagram(wide), null);
76
+ });
77
+ it("says why, with a line, so `narrate` can tell the author", () => {
78
+ const r = diagnoseDiagram("flowchart TD\n A --> B\n click A cb");
79
+ assert.equal(r?.kind, "unsupported");
80
+ assert.equal(r?.line, 3);
81
+ assert.match(r.message, /click/);
82
+ });
83
+ it("reports nothing at all for a diagram that draws", () => {
84
+ assert.equal(diagnoseDiagram("flowchart TD\n A --> B"), null);
85
+ });
86
+ });
87
+ describe("text measurement", () => {
88
+ it("uses real Helvetica advances for ASCII", () => {
89
+ assert.equal(charWidth(0x20), 278); // space
90
+ assert.equal(charWidth("i".codePointAt(0)), 222);
91
+ assert.equal(charWidth("m".codePointAt(0)), 833);
92
+ assert.equal(charWidth("W".codePointAt(0)), 944);
93
+ });
94
+ it("gives narrow and wide letters different widths, which is the whole point", () => {
95
+ assert.ok(textWidth("iiii", 14) < textWidth("mmmm", 14));
96
+ });
97
+ it("measures Greek rather than falling back to a single constant", () => {
98
+ // The archive's one state diagram is Greek morphology — `πραΰς`,
99
+ // `πραεῖσι(ν)`. A table keyed on ASCII alone would size those boxes from
100
+ // the default and clip the text.
101
+ assert.ok(charWidth("π".codePointAt(0)) > 0);
102
+ assert.ok(charWidth("ῖ".codePointAt(0)) > 0, "Greek Extended must be covered");
103
+ assert.ok(textWidth("πραεῖσι", 14) > textWidth("πρα", 14));
104
+ });
105
+ it("treats CJK as full width and combining marks as zero", () => {
106
+ assert.equal(charWidth("字".codePointAt(0)), 1000);
107
+ assert.equal(charWidth(0x0301), 0);
108
+ });
109
+ it("counts an emoji once, not once per surrogate", () => {
110
+ const one = textWidth("🦠", 14);
111
+ assert.equal(one, textWidth("🦠", 14));
112
+ assert.ok(one < textWidth("🦠🦠", 14));
113
+ });
114
+ it("sizes a box to fit its longest line", () => {
115
+ const one = measureNode(["short"]);
116
+ const two = measureNode(["short", "a much longer line"]);
117
+ assert.ok(two.width > one.width);
118
+ assert.ok(two.height > one.height, "two lines are taller than one");
119
+ });
120
+ it("never returns a degenerate box for an empty label", () => {
121
+ const m = measureNode([""]);
122
+ assert.ok(m.width > 0 && m.height > 0);
123
+ });
124
+ });
125
+ describe("node labels reach brane's own grammars", () => {
126
+ function db() {
127
+ const d = new Database(":memory:");
128
+ d.exec("CREATE TABLE narratives (id TEXT PRIMARY KEY)");
129
+ d.prepare("INSERT INTO narratives (id) VALUES (?)").run("claude-0qjv4nny");
130
+ return d;
131
+ }
132
+ it("links a label that is a narrative that exists", () => {
133
+ assert.equal(nidHrefFor(db())("claude-0qjv4nny"), "brane://nid/claude-0qjv4nny");
134
+ });
135
+ it("carries a `:seq` through, so a node can cite a message", () => {
136
+ assert.equal(nidHrefFor(db())("claude-0qjv4nny:42"), "brane://nid/claude-0qjv4nny/42");
137
+ });
138
+ it("declines the addressed form, because a node cites and does not address", () => {
139
+ // `mentions-scan` walks the rendered markdown, where a fence is inert — so
140
+ // linking `@nid` here would show the operator a mention that notifies
141
+ // nobody, breaking the rule that a handle fires iff it is seen as one.
142
+ assert.equal(nidHrefFor(db())("@claude-0qjv4nny"), null);
143
+ });
144
+ it("declines a narrative that does not exist, rather than making a dead link", () => {
145
+ // The same rule `markdown-nids.ts` applies in prose: an arbitrary string of
146
+ // the right shape is not a reference.
147
+ assert.equal(nidHrefFor(db())("claude-deadbeef"), null);
148
+ });
149
+ it("declines a label that merely contains a reference", () => {
150
+ assert.equal(nidHrefFor(db())("see claude-0qjv4nny for details"), null);
151
+ });
152
+ it("puts the link in the SVG as a plain anchor", () => {
153
+ const r = renderDiagram("flowchart TD\n A[claude-0qjv4nny] --> B[plain]", {
154
+ hrefFor: nidHrefFor(db()),
155
+ });
156
+ assert.ok(r);
157
+ assert.ok(r.svg.includes('<a href="brane://nid/claude-0qjv4nny">'));
158
+ assert.equal((r.svg.match(/<a /g) ?? []).length, 1, "only the resolving label links");
159
+ });
160
+ it("adds no syntax for it, so the same fence still renders on GitHub", () => {
161
+ // The linked label is ordinary mermaid text. Nothing in the source says
162
+ // "this is a link" — which is what keeps the fence portable.
163
+ const src = "flowchart TD\n A[claude-0qjv4nny] --> B";
164
+ const withDb = renderDiagram(src, { hrefFor: nidHrefFor(db()) });
165
+ const without = renderDiagram(src);
166
+ assert.ok(withDb.svg.includes("<a "));
167
+ assert.ok(!without.svg.includes("<a "));
168
+ assert.deepEqual(withDb.labels, without.labels);
169
+ });
170
+ it("reports node labels as text, so a fence is indexable as more than source", () => {
171
+ const r = renderDiagram("flowchart TD\n A[Listen] --> B[Interpret]");
172
+ assert.deepEqual(r.labels, ["Listen", "Interpret"]);
173
+ });
174
+ });
175
+ //# sourceMappingURL=diagram-render.test.js.map
@@ -0,0 +1,288 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseDiagram } from "../diagram/parse.js";
4
+ import { renderDiagram, diagnoseDiagram } from "../diagram/index.js";
5
+ import { layoutDiagram } from "../diagram/layout.js";
6
+ import { measureEdgeLabel } from "../diagram/metrics.js";
7
+ import { diagnoseMarkdown } from "../markdown-diagnostics.js";
8
+ // Regressions from the review of #500 (claude-91m90xgn:5). Each of these was a
9
+ // real defect in the first draft, reproduced by execution before it was fixed.
10
+ // They are gathered here rather than scattered so that the shape of what went
11
+ // wrong stays legible: three of the seven were *silent* — a plausible, wrong
12
+ // diagram, or a wrong warning — which is the failure mode the whole
13
+ // draw-or-fall-back design exists to avoid.
14
+ const ok = (src) => {
15
+ const r = parseDiagram(src);
16
+ assert.equal(r.ok, true, `expected a parse, got ${r.ok ? "" : r.refusal.message}`);
17
+ return r.ok ? r.ir : undefined;
18
+ };
19
+ describe("a hostile line cannot stall the daemon", () => {
20
+ // The labelled-link patterns were `-{2,}\s+(.+?)\s+-{2,}>`: three overlapping
21
+ // quantifiers over whitespace, cubic in the run length. `A -- <N spaces> x`
22
+ // measured 17/30/195/1542 ms at N=250/500/1000/2000 — 8× per doubling. It
23
+ // reached the daemon through `diagnoseMarkdown`, which runs on *every*
24
+ // narration containing a mermaid fence, so any agent could have wedged the
25
+ // daemon by narrating. `MAX_SOURCE_BYTES` was no defence: it bounds n while
26
+ // the cost is n³.
27
+ it("parses a long whitespace run in linear time", () => {
28
+ const timeAt = (n) => {
29
+ const src = `flowchart TD\n A -- ${" ".repeat(n)}x`;
30
+ const t = process.hrtime.bigint();
31
+ parseDiagram(src);
32
+ return Number(process.hrtime.bigint() - t) / 1e6;
33
+ };
34
+ timeAt(200); // warm
35
+ const small = Math.max(timeAt(250), 0.05);
36
+ const large = timeAt(1500);
37
+ // Cubic would be ~216× here. Generous bound: this is a complexity assertion,
38
+ // not a benchmark, and CI machines are noisy.
39
+ assert.ok(large < small * 30 + 50, `6× the input took ${large.toFixed(1)}ms against ${small.toFixed(1)}ms — superlinear`);
40
+ });
41
+ it("refuses an absurdly long line outright", () => {
42
+ const r = diagnoseDiagram(`flowchart TD\n A[${"x".repeat(3000)}] --> B`);
43
+ assert.equal(r?.kind, "unsupported");
44
+ assert.match(r.message, /line is over/);
45
+ });
46
+ it("stays fast on a whole fence of hostile lines", () => {
47
+ const src = "flowchart TD\n" + `A -- ${" ".repeat(400)}x\n`.repeat(40);
48
+ const t = process.hrtime.bigint();
49
+ parseDiagram(src);
50
+ assert.ok(Number(process.hrtime.bigint() - t) / 1e6 < 500);
51
+ });
52
+ });
53
+ describe("a link form never silently loses a node", () => {
54
+ // Both of these drew a plausible, wrong diagram with no warning — the worst
55
+ // available outcome, since the reader cannot tell and the author is not told.
56
+ it("chains dotted links instead of spanning them", () => {
57
+ // Was: one edge A → C labelled `-> B -`, with B gone entirely.
58
+ const ir = ok("flowchart TD\n A -.-> B -.-> C");
59
+ assert.equal(ir.nodes.length, 3);
60
+ assert.deepEqual(ir.edges.map((e) => `${e.from}->${e.to}`), ["A->B", "B->C"]);
61
+ assert.deepEqual(ir.edges.map((e) => e.label), [undefined, undefined]);
62
+ });
63
+ it("reads `---` as an open link, not as a labelled-link opener", () => {
64
+ // Was: one edge A → C labelled `B`. Three dashes is an open link; the
65
+ // inline-label opener is exactly two.
66
+ const ir = ok("flowchart TD\n A --- B --> C");
67
+ assert.equal(ir.nodes.length, 3);
68
+ assert.deepEqual(ir.edges.map((e) => `${e.from}->${e.to}`), ["A->B", "B->C"]);
69
+ assert.deepEqual(ir.edges.map((e) => e.line), ["solid", "solid"]);
70
+ assert.deepEqual(ir.edges.map((e) => e.arrow), ["open", "arrow"]);
71
+ });
72
+ it("still reads a genuine inline label", () => {
73
+ const ir = ok("flowchart TD\n A -- x --> B -- y --> C");
74
+ assert.deepEqual(ir.edges.map((e) => e.label), ["x", "y"]);
75
+ });
76
+ it("allows `>` inside a dotted label, which the archive needs", () => {
77
+ // The lattice diagram writes `-. "All<String, true> ⇒ ⊥" .->`. A first
78
+ // attempt at the fix above excluded `>` from every inline label and broke
79
+ // it — which is why the dotted form excludes `.` instead, and only forbids
80
+ // `-`/`>` as the *first* character.
81
+ const ir = ok('flowchart TD\n STR -. "All<String, true> ⇒ ⊥" .-> BOT');
82
+ assert.equal(ir.edges[0].label, "All<String, true> ⇒ ⊥");
83
+ assert.equal(ir.edges[0].line, "dotted");
84
+ });
85
+ });
86
+ describe("`classDef` is applied, not merely parsed", () => {
87
+ // Was: parsed into the IR, carried through layout, and never read — so
88
+ // `classDef hot fill:#f00` produced no `#f00` anywhere, while the same
89
+ // colour via `style` worked. The original test asserted the IR and stopped
90
+ // one layer short of the thing that matters.
91
+ it("paints a class through to the SVG", () => {
92
+ const svg = renderDiagram("flowchart TD\n A --> B\n classDef hot fill:#f00\n class A hot").svg;
93
+ assert.ok(svg.includes("#f00"), "the class colour never reached the output");
94
+ });
95
+ it("paints the inline `:::` form too", () => {
96
+ const svg = renderDiagram("flowchart TD\n A:::hot --> B\n classDef hot fill:#0f0").svg;
97
+ assert.ok(svg.includes("#0f0"));
98
+ });
99
+ it("lets an explicit `style` win over a class, as mermaid does", () => {
100
+ const ir = ok("flowchart TD\n A --> B\n classDef hot fill:#f00\n class A hot\n style A fill:#00f");
101
+ const placed = layoutDiagram(ir);
102
+ assert.equal(placed.nodes.find((n) => n.id === "A").style?.fill, "#00f");
103
+ });
104
+ it("merges several classes in declaration order", () => {
105
+ const ir = ok([
106
+ "flowchart TD", " A --> B",
107
+ " classDef one fill:#f00,stroke:#111", " classDef two fill:#0f0",
108
+ " class A one", " class A two",
109
+ ].join("\n"));
110
+ const style = layoutDiagram(ir).nodes.find((n) => n.id === "A").style;
111
+ assert.equal(style.fill, "#0f0", "the later class wins");
112
+ assert.equal(style.stroke, "#111", "the earlier class still contributes");
113
+ });
114
+ });
115
+ describe("a diagram that will not draw always says so", () => {
116
+ // `C --> S` where S is a subgraph is legal mermaid; dagre throws on it, and
117
+ // the throw was swallowed into a silent fallback. The operator saw source,
118
+ // the author got no warning, and `strict: true` did not refuse — which is
119
+ // exactly the invariant the warnings exist to uphold.
120
+ it("refuses an edge whose endpoint is a subgraph, with a reason", () => {
121
+ const src = "flowchart TD\n subgraph S[Group]\n A --> B\n end\n C --> S";
122
+ const r = diagnoseDiagram(src);
123
+ assert.equal(r?.kind, "unsupported");
124
+ assert.match(r.message, /subgraph/);
125
+ assert.equal(renderDiagram(src), null);
126
+ });
127
+ it("warns for everything it refuses, and refuses everything it cannot draw", () => {
128
+ // The invariant itself, over a spread of inputs: `diagnoseDiagram` and
129
+ // `renderDiagram` must never disagree about whether a diagram appears.
130
+ const cases = [
131
+ "flowchart TD\n A --> B",
132
+ "flowchart TD\n subgraph S\n A --> B\n end\n C --> S",
133
+ "gantt\n title X",
134
+ "flowchart TD\n A --> @@@",
135
+ "flowchart TD\n A --> B\n click A cb",
136
+ "flowchart TD\n A --> A",
137
+ "flowchart LR\n A[x] --> B{y} --> C((z))",
138
+ "stateDiagram-v2\n [*] --> A",
139
+ ];
140
+ for (const src of cases) {
141
+ const refused = diagnoseDiagram(src) !== null;
142
+ const drew = renderDiagram(src) !== null;
143
+ assert.equal(refused, !drew, `disagreement on ${JSON.stringify(src)}`);
144
+ }
145
+ });
146
+ });
147
+ describe("the fence finder is the markdown parse, not a pattern", () => {
148
+ const bad = "gantt\n title X";
149
+ const warns = (md) => diagnoseMarkdown(md).some((w) => w.kind.startsWith("diagram_"));
150
+ it("says nothing about a mermaid fence quoted inside a longer fence", () => {
151
+ // Which is how an agent documents this feature — as the narrations that
152
+ // produced it do. A regex scanner warned about a diagram that was never
153
+ // going to be drawn in the first place.
154
+ assert.equal(warns("````\n```mermaid\n" + bad + "\n```\n````"), false);
155
+ });
156
+ it("warns about one inside a blockquote, which does render", () => {
157
+ assert.equal(warns("> ```mermaid\n> gantt\n> title X\n> ```"), true);
158
+ });
159
+ it("warns about one inside a list item", () => {
160
+ assert.equal(warns("- item\n\n ```mermaid\n gantt\n title X\n ```"), true);
161
+ });
162
+ it("says nothing about an indented code block that merely looks like one", () => {
163
+ assert.equal(warns(" ```mermaid\n " + bad + "\n ```"), false);
164
+ });
165
+ it("does not pay for a parse when there is no mermaid at all", () => {
166
+ const md = "ordinary prose ".repeat(500);
167
+ const t = process.hrtime.bigint();
168
+ diagnoseMarkdown(md);
169
+ assert.ok(Number(process.hrtime.bigint() - t) / 1e6 < 50);
170
+ });
171
+ });
172
+ describe("headers and comments follow mermaid, not the first draft", () => {
173
+ it("accepts a trailing semicolon on the header", () => {
174
+ // `graph TD;` — a common habit in the absorbed ChatGPT history. Statements
175
+ // stripped a trailing `;`, but the header was tested against the raw line.
176
+ assert.equal(ok("graph TD;\n A --> B").edges.length, 1);
177
+ });
178
+ it("defaults a bare `flowchart` to TB, as mermaid does", () => {
179
+ assert.equal(ok("flowchart\n A --> B").direction, "TB");
180
+ });
181
+ it("treats `%%` as a comment only at the start of a line", () => {
182
+ assert.deepEqual(ok("flowchart TD\n A[100%% done] --> B").nodes[0].lines, ["100%% done"]);
183
+ assert.equal(ok("flowchart TD\n %% note\n A --> B").edges.length, 1);
184
+ });
185
+ });
186
+ describe("a self-loop stays inside the canvas", () => {
187
+ // The loop bows above its node, and on a top-rank node that meant negative
188
+ // coordinates — outside `viewBox="0 0 …"`, so the loop was simply invisible.
189
+ it("keeps every drawn point within the reported viewBox", () => {
190
+ for (const src of ["flowchart TD\n A --> A\n A --> B", "flowchart LR\n A --> A\n A --> B"]) {
191
+ const placed = layoutDiagram(ok(src));
192
+ for (const e of placed.edges) {
193
+ for (const p of e.points) {
194
+ assert.ok(p.x >= 0 && p.y >= 0, `${src}: point ${p.x},${p.y} is outside the canvas`);
195
+ assert.ok(p.x <= placed.width && p.y <= placed.height, `${src}: point ${p.x},${p.y} exceeds ${placed.width}x${placed.height}`);
196
+ }
197
+ }
198
+ }
199
+ });
200
+ it("keeps the nodes inside it too, after any shift", () => {
201
+ const placed = layoutDiagram(ok("flowchart TD\n A --> A\n A --> B"));
202
+ for (const n of placed.nodes) {
203
+ assert.ok(n.x - n.width / 2 >= 0 && n.y - n.height / 2 >= 0, `${n.id} at ${n.x},${n.y} hangs off the canvas`);
204
+ }
205
+ });
206
+ });
207
+ describe("a subgraph gets a box around its actual members", () => {
208
+ // Found while checking the theming fix on a real render: a node introduced
209
+ // by an edge and *then* listed inside a `subgraph` never joined it, because
210
+ // membership was claimed only when a node was created. The cluster came out
211
+ // empty, dagre left its geometry undefined, and the renderer drew a title
212
+ // floating in the corner with no box.
213
+ it("claims a node listed inside a subgraph after it was already used", () => {
214
+ const ir = ok("flowchart TD\n V --> E\n subgraph S[out]\n V\n E\n end");
215
+ assert.deepEqual(ir.subgraphs[0].nodes, ["V", "E"]);
216
+ const c = layoutDiagram(ir).clusters[0];
217
+ assert.ok(c, "the cluster survived layout");
218
+ assert.ok(c.width > 0 && c.height > 0, "and has real geometry");
219
+ });
220
+ it("still claims a node on first mention inside", () => {
221
+ const ir = ok("flowchart TD\n A --> B\n subgraph S[Group]\n C --> D\n end\n B --> C");
222
+ assert.deepEqual(ir.subgraphs[0].nodes, ["C", "D"]);
223
+ });
224
+ it("never emits a cluster without finite geometry", () => {
225
+ for (const src of [
226
+ "flowchart TD\n subgraph S[empty]\n end\n A --> B",
227
+ "flowchart TD\n V --> E\n subgraph S[out]\n V\n end",
228
+ ]) {
229
+ for (const c of layoutDiagram(ok(src)).clusters) {
230
+ assert.ok([c.x, c.y, c.width, c.height].every(Number.isFinite), src);
231
+ }
232
+ }
233
+ });
234
+ });
235
+ describe("an edge label's chip fits its text", () => {
236
+ // The chip was sized as `length × 0.55em` while layout reserved space with
237
+ // `measureEdgeLabel`. The two agreed for ASCII and nowhere else, so a Greek
238
+ // or CJK label hung out of its own background.
239
+ it("uses the same measurement layout reserved space with", () => {
240
+ const wide = renderDiagram("flowchart TD\n A -->|字字字字| B").svg;
241
+ const narrow = renderDiagram("flowchart TD\n A -->|iiii| B").svg;
242
+ const chip = (svg) => Number(/<rect x="[-\d.]+" y="[-\d.]+" width="([\d.]+)"[^>]*rx="3"/.exec(svg)?.[1] ?? 0);
243
+ assert.ok(chip(wide) > chip(narrow), "a full-width label must get a wider chip than a narrow one");
244
+ });
245
+ });
246
+ describe("a subgraph title wider than its members", () => {
247
+ // dagre sizes a cluster from its members and never sees the title, which is
248
+ // centred on the box; a long title hung past x = 0, where the operator could
249
+ // neither see nor scroll to it (claude-myl5ez9e:24). The members still set
250
+ // the box's width; the title wraps to it and the box grows upward to hold
251
+ // the extra lines (claude-myl5ez9e:46).
252
+ const title = "apollographql/connector-factory (private, public after the gates)";
253
+ for (const dir of ["TD", "LR"]) {
254
+ it(`wraps inside its box and stays inside the drawing (${dir})`, () => {
255
+ const src = `flowchart ${dir}\n subgraph P["${title}"]\n A[canon] --> B[out]\n end\n B -. copy .-> C[elsewhere]`;
256
+ const ir = ok(src);
257
+ const free = layoutDiagram(ok(`flowchart ${dir}\n subgraph P[x]\n A[canon] --> B[out]\n end\n B -. copy .-> C[elsewhere]`));
258
+ const placed = layoutDiagram(ir);
259
+ const c = placed.clusters[0];
260
+ assert.ok(measureEdgeLabel([c.titleRuns]).width > c.width, "the title is wider than its box, or this tests nothing");
261
+ assert.equal(c.width, free.clusters[0].width, "the members, not the title, set the box's width");
262
+ assert.ok(c.titleLines.length > 1, "so it wraps");
263
+ const text = (l) => l.map((r) => r.text).join("");
264
+ assert.equal(c.titleLines.map(text).join(" "), title, "every word survives, in order");
265
+ for (const line of c.titleLines) {
266
+ // A single word wider than the box can't wrap; it overhangs, and the
267
+ // bounds keep it in view (the case below).
268
+ if (/\s/.test(text(line)))
269
+ assert.ok(measureEdgeLabel([line]).width <= c.width, `line fits its box: ${text(line)}`);
270
+ }
271
+ const extra = (c.titleLines.length - 1) * 12 * 1.35;
272
+ assert.ok(Math.abs(c.height - (free.clusters[0].height + extra)) < 0.01, "and the box grew a line per extra line");
273
+ assert.ok(c.y - c.height / 2 >= 0, `box top ${c.y - c.height / 2} is inside the drawing`);
274
+ const members = placed.nodes.filter((n) => n.id === "A" || n.id === "B");
275
+ const top = c.y - c.height / 2 + 15 + extra; // the last title baseline
276
+ for (const n of members)
277
+ assert.ok(n.y - n.height / 2 > top, `${n.id} sits below the title`);
278
+ });
279
+ }
280
+ it("keeps a word too long to wrap inside the drawing", () => {
281
+ const placed = layoutDiagram(ok(`flowchart TD\n subgraph P["${"x".repeat(80)}"]\n A\n end`));
282
+ const c = placed.clusters[0];
283
+ const { width } = measureEdgeLabel(c.titleLines);
284
+ assert.ok(width > c.width, "the word overhangs its box");
285
+ assert.ok(c.x - width / 2 >= 0 && c.x + width / 2 <= placed.width, "but not the drawing");
286
+ });
287
+ });
288
+ //# sourceMappingURL=diagram-review.test.js.map