mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(wiki): sanitize generated mermaid diagrams (#1539)
* fix(wiki): sanitize generated mermaid diagrams * fix(wiki): address mermaid sanitizer review --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
ec4624af87
commit
e8c8ddec8a
5 changed files with 248 additions and 36 deletions
|
|
@ -28,6 +28,7 @@ import {
|
|||
type FileWithExports,
|
||||
} from './graph-queries.js';
|
||||
import { generateHTMLViewer } from './html-viewer.js';
|
||||
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
||||
|
||||
import {
|
||||
callLLM,
|
||||
|
|
@ -591,7 +592,7 @@ export class WikiGenerator {
|
|||
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
|
||||
|
||||
// Write page with front matter
|
||||
const pageContent = `# ${node.name}\n\n${response.content}`;
|
||||
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
||||
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
||||
}
|
||||
|
||||
|
|
@ -631,7 +632,7 @@ export class WikiGenerator {
|
|||
|
||||
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
|
||||
|
||||
const pageContent = `# ${node.name}\n\n${response.content}`;
|
||||
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
|
||||
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +682,9 @@ export class WikiGenerator {
|
|||
this.streamOpts('Generating overview', 88),
|
||||
);
|
||||
|
||||
const pageContent = `# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`;
|
||||
const pageContent = sanitizeMermaidMarkdown(
|
||||
`# ${path.basename(this.repoPath)} — Wiki\n\n${response.content}`,
|
||||
);
|
||||
await fs.writeFile(path.join(this.wikiDir, 'overview.md'), pageContent, 'utf-8');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { sanitizeMermaidMarkdown } from './mermaid-sanitizer.js';
|
||||
|
||||
interface ModuleTreeNode {
|
||||
name: string;
|
||||
|
|
@ -42,7 +43,7 @@ export async function generateHTMLViewer(wikiDir: string, projectName: string):
|
|||
const dirEntries = await fs.readdir(wikiDir);
|
||||
for (const f of dirEntries.filter((f) => f.endsWith('.md'))) {
|
||||
const content = await fs.readFile(path.join(wikiDir, f), 'utf-8');
|
||||
pages[f.replace(/\.md$/, '')] = content;
|
||||
pages[f.replace(/\.md$/, '')] = sanitizeMermaidMarkdown(content);
|
||||
}
|
||||
|
||||
const html = buildHTML(projectName, moduleTree, pages, meta);
|
||||
|
|
|
|||
119
gitnexus/src/core/wiki/mermaid-sanitizer.ts
Normal file
119
gitnexus/src/core/wiki/mermaid-sanitizer.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
const MERMAID_FENCE_RE = /```mermaid\s*\n([\s\S]*?)```/g;
|
||||
const NODE_LABEL_RE =
|
||||
/(\[[^\]\n]*(?:\\n)[^\]\n]*\]|\{[^}\n]*(?:\\n)[^}\n]*\}|\([^)\n]*(?:\\n)[^)\n]*\))/g;
|
||||
const EDGE_LABEL_RE = /\|([^|\n]+)\|/g;
|
||||
const UNSAFE_EDGE_LABEL_RE = /[()[\]{}<>]/;
|
||||
const UNSAFE_NODE_ID_RE = /[^A-Za-z0-9_-]/;
|
||||
const NODE_ID_RE = /^[A-Za-z0-9_.:/()-]+$/;
|
||||
|
||||
const LINE_PREFIX_RE = /^(\s*(?:(?:[-A-Za-z0-9_]+)\s*:\s*)?)(.*)$/;
|
||||
const EDGE_RE =
|
||||
/(\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?\|[^|\n]*\|(?:[>|ox])?|\s*(?:[ox])?(?:--+|==+|\.\.+)(?:[>|ox])?|\s*<--+>?\s*)/g;
|
||||
|
||||
export function sanitizeMermaidMarkdown(markdown: string): string {
|
||||
return markdown.replace(MERMAID_FENCE_RE, (_match, diagram: string) => {
|
||||
return '```mermaid\n' + sanitizeMermaidDiagram(diagram) + '```';
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeMermaidDiagram(diagram: string): string {
|
||||
const aliases = new Map<string, string>();
|
||||
let nextAlias = 1;
|
||||
|
||||
const aliasFor = (id: string): string => {
|
||||
const existing = aliases.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
const base = id.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'node';
|
||||
let alias = base;
|
||||
while ([...aliases.values()].includes(alias)) {
|
||||
nextAlias += 1;
|
||||
alias = `${base}_${nextAlias}`;
|
||||
}
|
||||
aliases.set(id, alias);
|
||||
return alias;
|
||||
};
|
||||
|
||||
return diagram
|
||||
.split('\n')
|
||||
.map((line) => sanitizeMermaidLine(line, aliasFor))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function sanitizeMermaidLine(line: string, aliasFor: (id: string) => string): string {
|
||||
let sanitized = replaceLiteralLineBreaksInLabels(line);
|
||||
sanitized = quoteUnsafeEdgeLabels(sanitized);
|
||||
|
||||
const prefixMatch = sanitized.match(LINE_PREFIX_RE);
|
||||
if (!prefixMatch) return sanitized;
|
||||
|
||||
const prefix = prefixMatch[1];
|
||||
const body = prefixMatch[2];
|
||||
if (isDirectiveLine(body)) return sanitized;
|
||||
|
||||
const parts = body.split(EDGE_RE);
|
||||
if (parts.length === 1) return sanitized;
|
||||
|
||||
for (let i = 0; i < parts.length; i += 2) {
|
||||
parts[i] = sanitizeNodeReference(parts[i], aliasFor);
|
||||
}
|
||||
|
||||
return prefix + parts.join('');
|
||||
}
|
||||
|
||||
function replaceLiteralLineBreaksInLabels(line: string): string {
|
||||
return line.replace(NODE_LABEL_RE, (label) => label.replace(/\\n/g, '<br/>'));
|
||||
}
|
||||
|
||||
function quoteUnsafeEdgeLabels(line: string): string {
|
||||
return line.replace(EDGE_LABEL_RE, (match, label: string) => {
|
||||
const trimmed = label.trim();
|
||||
if (!UNSAFE_EDGE_LABEL_RE.test(trimmed)) return match;
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return match;
|
||||
}
|
||||
return `|"${escapeMermaidLabel(trimmed)}"|`;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeNodeReference(segment: string, aliasFor: (id: string) => string): string {
|
||||
const match = segment.match(/^(\s*)([A-Za-z0-9_.:/()-]+)(.*?)(\s*)$/);
|
||||
if (!match) return segment;
|
||||
|
||||
const [, leading, id, suffix, trailing] = match;
|
||||
if (!NODE_ID_RE.test(id) || !UNSAFE_NODE_ID_RE.test(id)) return segment;
|
||||
const hasInlineLabel =
|
||||
suffix.trim().startsWith('[') || suffix.trim().startsWith('(') || suffix.trim().startsWith('{');
|
||||
|
||||
if (hasInlineLabel) return `${leading}${aliasFor(id)}${suffix}${trailing}`;
|
||||
|
||||
return `${leading}${aliasFor(id)}["${escapeMermaidLabel(id)}"]${suffix}${trailing}`;
|
||||
}
|
||||
|
||||
function escapeMermaidLabel(label: string): string {
|
||||
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function isDirectiveLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return (
|
||||
trimmed === '' ||
|
||||
trimmed.startsWith('%%') ||
|
||||
trimmed.startsWith('graph ') ||
|
||||
trimmed.startsWith('flowchart ') ||
|
||||
trimmed.startsWith('sequenceDiagram') ||
|
||||
trimmed.startsWith('classDiagram') ||
|
||||
trimmed.startsWith('stateDiagram') ||
|
||||
trimmed.startsWith('erDiagram') ||
|
||||
trimmed.startsWith('journey') ||
|
||||
trimmed.startsWith('gantt') ||
|
||||
trimmed.startsWith('pie ') ||
|
||||
trimmed.startsWith('mindmap') ||
|
||||
trimmed.startsWith('timeline') ||
|
||||
trimmed.startsWith('subgraph ') ||
|
||||
trimmed === 'end'
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo');
|
|||
// still works), `afterAll` rms the parent tmpdir.
|
||||
let MINI_REPO: string;
|
||||
let tmpParent: string;
|
||||
let suiteGitnexusHome: string;
|
||||
|
||||
// Absolute file:// URL to tsx loader — needed when spawning CLI with cwd
|
||||
// outside the project tree (bare 'tsx' specifier won't resolve there).
|
||||
|
|
@ -49,6 +50,7 @@ beforeAll(() => {
|
|||
// Copy the fixture into an isolated tmpdir named `mini-repo` so that the
|
||||
// `--repo mini-repo` CLI arg (which matches by basename) still works.
|
||||
tmpParent = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-'));
|
||||
suiteGitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cli-e2e-home-'));
|
||||
MINI_REPO = path.join(tmpParent, 'mini-repo');
|
||||
fs.cpSync(FIXTURE_SRC, MINI_REPO, { recursive: true });
|
||||
|
||||
|
|
@ -75,21 +77,30 @@ afterAll(() => {
|
|||
if (tmpParent) {
|
||||
fs.rmSync(tmpParent, { recursive: true, force: true });
|
||||
}
|
||||
if (suiteGitnexusHome) {
|
||||
fs.rmSync(suiteGitnexusHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function cliEnv(extraEnv: Record<string, string> = {}) {
|
||||
return {
|
||||
...process.env,
|
||||
GITNEXUS_HOME: suiteGitnexusHome,
|
||||
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
|
||||
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
|
||||
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function runCli(command: string, cwd: string, timeoutMs = 15000) {
|
||||
return spawnSync(process.execPath, ['--import', tsxImportUrl, cliEntry, command], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pre-set --max-old-space-size so analyzeCommand's ensureHeap() sees it
|
||||
// and skips the re-exec. The re-exec drops the tsx loader (--import tsx
|
||||
// is not in process.argv), causing ERR_UNKNOWN_FILE_EXTENSION on .ts files.
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -103,10 +114,7 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 15000) {
|
|||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -126,11 +134,7 @@ function runCliWithEnv(
|
|||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
...extraEnv,
|
||||
},
|
||||
env: cliEnv(extraEnv),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -919,10 +923,7 @@ describe('CLI end-to-end', () => {
|
|||
encoding: 'utf8',
|
||||
timeout: timeoutMs,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1042,10 +1043,7 @@ describe('CLI end-to-end', () => {
|
|||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
if (result.status === null) return;
|
||||
|
|
@ -1159,10 +1157,7 @@ describe('CLI end-to-end', () => {
|
|||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1212,10 +1207,7 @@ describe('CLI end-to-end', () => {
|
|||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(),
|
||||
},
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
97
gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts
Normal file
97
gitnexus/test/unit/wiki-mermaid-sanitizer.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
sanitizeMermaidDiagram,
|
||||
sanitizeMermaidMarkdown,
|
||||
} from '../../src/core/wiki/mermaid-sanitizer.js';
|
||||
|
||||
describe('sanitizeMermaidMarkdown', () => {
|
||||
it('replaces literal newline escapes inside rectangle and diamond labels', () => {
|
||||
const markdown = [
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
' A[HTTP request\\nwith ID param] --> B{Preceding tei:zone\\nwith @start=#pid?}',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidMarkdown(markdown);
|
||||
|
||||
expect(sanitized).toContain('A[HTTP request<br/>with ID param]');
|
||||
expect(sanitized).toContain('B{Preceding tei:zone<br/>with @start=#pid?}');
|
||||
expect(sanitized).not.toContain('\\n');
|
||||
});
|
||||
|
||||
it('quotes unsafe edge labels without changing safe labels', () => {
|
||||
const diagram = [
|
||||
'graph LR',
|
||||
' Script -->|doc()| eXist[(eXist-db XML)]',
|
||||
' Client -->|HTTP params| Script',
|
||||
].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidDiagram(diagram);
|
||||
|
||||
expect(sanitized).toContain('Script -->|"doc()"| eXist[(eXist-db XML)]');
|
||||
expect(sanitized).toContain('Client -->|HTTP params| Script');
|
||||
});
|
||||
|
||||
it('escapes backslashes and quotes in quoted edge labels', () => {
|
||||
const diagram = ['graph LR', ' Script -->|doc("C:\\\\tmp")| Target'].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidDiagram(diagram);
|
||||
|
||||
expect(sanitized).toContain('Script -->|"doc(\\"C:\\\\\\\\tmp\\")"| Target');
|
||||
});
|
||||
|
||||
it('aliases bare node IDs that contain dots and keeps display labels', () => {
|
||||
const diagram = [
|
||||
'graph LR',
|
||||
' Client -->|xmlurl + xslurl| xslt-conversion.xq',
|
||||
' xslt-conversion.xq -->|stream-transform| lbpwebjs-main.xsl',
|
||||
' lbpwebjs-main.xsl -->|fetches| TEI-XML[(TEI XML in eXist)]',
|
||||
].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidDiagram(diagram);
|
||||
|
||||
expect(sanitized).toContain(
|
||||
'Client -->|xmlurl + xslurl| xslt-conversion_xq["xslt-conversion.xq"]',
|
||||
);
|
||||
expect(sanitized).toContain(
|
||||
'xslt-conversion_xq["xslt-conversion.xq"] -->|stream-transform| lbpwebjs-main_xsl["lbpwebjs-main.xsl"]',
|
||||
);
|
||||
expect(sanitized).toContain(
|
||||
'lbpwebjs-main_xsl["lbpwebjs-main.xsl"] -->|fetches| TEI-XML[(TEI XML in eXist)]',
|
||||
);
|
||||
});
|
||||
|
||||
it('aliases unsafe node IDs while preserving existing inline labels', () => {
|
||||
const diagram = [
|
||||
'graph LR',
|
||||
' file.name.ts[(eXist-db XML)] --> target.node["Target node"]',
|
||||
].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidDiagram(diagram);
|
||||
|
||||
expect(sanitized).toContain('file_name_ts[(eXist-db XML)] --> target_node["Target node"]');
|
||||
});
|
||||
|
||||
it('only rewrites fenced Mermaid blocks in markdown', () => {
|
||||
const markdown = [
|
||||
'Regular text with doc() and file.name.ts.',
|
||||
'',
|
||||
'```ts',
|
||||
'const label = "A\\nB";',
|
||||
'```',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart LR',
|
||||
' A -->|doc()| file.name.ts',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const sanitized = sanitizeMermaidMarkdown(markdown);
|
||||
|
||||
expect(sanitized).toContain('Regular text with doc() and file.name.ts.');
|
||||
expect(sanitized).toContain('const label = "A\\nB";');
|
||||
expect(sanitized).toContain('A -->|"doc()"| file_name_ts["file.name.ts"]');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue