verify-mermaid.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. /**
  2. * Parse every repo-authored Mermaid fence with Mermaid itself, catching syntax that link and fence
  3. * checks cannot. Scope intentionally matches the Markdown link gate, including standing docs,
  4. * package/example docs, and agent skills. Run with `tsx scripts/verify-mermaid.ts`.
  5. */
  6. import { globSync, readFileSync, realpathSync } from 'node:fs'
  7. import { resolve } from 'node:path'
  8. import { fromMarkdown } from 'mdast-util-from-markdown'
  9. import { gfmFromMarkdown } from 'mdast-util-gfm'
  10. import { gfm } from 'micromark-extension-gfm'
  11. import { JSDOM } from 'jsdom'
  12. import type { Nodes } from 'mdast'
  13. import { isArchivedAgentNotePath } from './repo-files.ts'
  14. const root = resolve(import.meta.dirname, '..')
  15. const PATTERNS = [
  16. 'README.md',
  17. 'README.zh.md',
  18. '.agents/notes/**/*.md',
  19. 'docs/**/*.md',
  20. 'packages/*/*.md',
  21. 'packages/*/*/*.md',
  22. 'AGENTS.md',
  23. 'packages/AGENTS.md',
  24. '.agents/skills/**/*.md',
  25. ]
  26. interface Block {
  27. file: string
  28. line: number
  29. source: string
  30. }
  31. interface Violation {
  32. file: string
  33. line: number
  34. message: string
  35. }
  36. function extractMermaidBlocks(file: string): Block[] {
  37. const source = readFileSync(resolve(root, file), 'utf8')
  38. const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  39. const out: Block[] = []
  40. const visit = (node: Nodes): void => {
  41. if (node.type === 'code' && node.lang === 'mermaid') {
  42. out.push({ file, line: node.position?.start.line ?? 0, source: node.value })
  43. }
  44. if ('children' in node) {
  45. for (const child of node.children) visit(child)
  46. }
  47. }
  48. visit(tree)
  49. return out
  50. }
  51. function formatError(error: unknown): string {
  52. if (error instanceof Error) return error.message.replace(/\s+/g, ' ').trim()
  53. return String(error).replace(/\s+/g, ' ').trim()
  54. }
  55. const blocks: Block[] = []
  56. const seen = new Set<string>()
  57. let checkedFiles = 0
  58. for (const pattern of PATTERNS) {
  59. for (const match of globSync(pattern, { cwd: root })) {
  60. if (isArchivedAgentNotePath(match)) continue
  61. const real = realpathSync(resolve(root, match))
  62. if (seen.has(real)) continue
  63. seen.add(real)
  64. checkedFiles++
  65. blocks.push(...extractMermaidBlocks(match))
  66. }
  67. }
  68. const violations: Violation[] = []
  69. const { window } = new JSDOM('')
  70. Object.defineProperty(globalThis, 'window', { value: window })
  71. Object.defineProperty(globalThis, 'document', { value: window.document })
  72. Object.defineProperty(globalThis, 'navigator', { value: window.navigator })
  73. const mermaid = (await import('mermaid')).default
  74. // maxEdges: mermaid's default 500-edge render guard; the module graph grows
  75. // with every package edge and crossed it legitimately. Raise the guard here
  76. // (a secure config settable only via initialize) rather than trimming edges.
  77. // The graph passed 1000 the same way it passed 500, so the headroom doubles
  78. // again rather than being set to whatever the current count happens to be.
  79. mermaid.initialize({ startOnLoad: false, maxEdges: 2000 })
  80. for (const block of blocks) {
  81. try {
  82. await mermaid.parse(block.source, { suppressErrors: false })
  83. } catch (error: unknown) {
  84. violations.push({ file: block.file, line: block.line, message: formatError(error) })
  85. }
  86. }
  87. if (violations.length === 0) {
  88. console.log(`verify-mermaid: ${blocks.length} mermaid block(s) parsed across ${checkedFiles} file(s).`)
  89. process.exit(0)
  90. }
  91. console.error('verify-mermaid: Mermaid syntax errors found:')
  92. for (const violation of violations) {
  93. console.error(` ${violation.file}:${violation.line} ${violation.message}`)
  94. }
  95. process.exit(1)