From 96399b6095553a512e5399e1c59b5e11ad744ee2 Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 5 Oct 2026 00:25:45 -0400 Subject: [PATCH 1/9] =?UTF-8?q?test(chat):=20pin=20how=20the=20chat=20rend?= =?UTF-8?q?ers=20code=20fences=20and=20inline=20code=20=E2=80=94=20before?= =?UTF-8?q?=20changing=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat's Markdown renderer lives inline in media/chat.html, and no suite ran it. The next commit changes how it finds a code fence, so this one writes down what it does today, against today's code: an ordinary fenced block a code block; the language line is not in it two blocks, prose between and a block still being typed is shown as code inline code escaped, never formatted; a lone backtick is text a path in inline code a file chip, when it names a real project file a fence inside a list item still a code block, indented as it was a fence stuck to a line "Run this: ```bash" opens a block, and a closing fence stuck to the last line of code closes one — sloppy, and already tolerated The functions are sliced out of chat.html itself, the way shHighlight.test.js does it, so the suite runs the shipped code and not a copy of it. --- .../test/chatMarkdownFences.test.js | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 extensions/levelcode-ai/test/chatMarkdownFences.test.js diff --git a/extensions/levelcode-ai/test/chatMarkdownFences.test.js b/extensions/levelcode-ai/test/chatMarkdownFences.test.js new file mode 100644 index 0000000..41169af --- /dev/null +++ b/extensions/levelcode-ai/test/chatMarkdownFences.test.js @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * The chat's Markdown renderer: code fences and inline code — run: node test/chatMarkdownFences.test.js + * + * What it does today, pinned before the way it finds a code fence is changed: an ordinary fenced + * block, two of them with prose between, a block still being typed, a fence indented inside a list + * item, inline code and file chips — and the two sloppy shapes models produce that it already + * tolerates (a fence stuck to the end of a line of prose; a closing fence stuck to the code). + * + * The renderer lives inline in media/chat.html, so its functions are sliced out of the file itself + * (the pattern of shHighlight.test.js): these tests run the shipped code, not a copy of it. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const html = fs.readFileSync(path.join(__dirname, '..', 'media', 'chat.html'), 'utf8'); + +/** Slice a 2-space-indented function out of chat.html (see shHighlight.test.js for why not brace counting). */ +function extract(name) { + const start = html.indexOf('\n function ' + name + '('); + assert.ok(start >= 0, 'chat.html no longer defines ' + name + '()'); + const from = start + 1; + const oneLiner = html.slice(from, html.indexOf('\n', from)); + if (/}\s*$/.test(oneLiner) && oneLiner.split('{').length === oneLiner.split('}').length) { return oneLiner; } + const end = html.indexOf('\n }', from); + assert.ok(end >= 0, 'no closing brace found for ' + name + '()'); + return html.slice(from, end + 4); +} +/** A top-level `const NAME = …;` line (or lines, up to the first line ending in `;`). */ +function decl(name) { + const m = new RegExp('\\n const ' + name + ' = [\\s\\S]*?;\\n').exec(html); + assert.ok(m, 'chat.html no longer declares ' + name); + return m[0]; +} + +// A document just big enough for streamFeed(): elements that hold innerHTML and keep their children in order. +function fakeDocument() { + const el = () => ({ innerHTML: '', children: [], appendChild(c) { this.children.push(c); return c; }, insertBefore(c, ref) { const i = this.children.indexOf(ref); this.children.splice(i < 0 ? this.children.length : i, 0, c); return c; } }); + return { createElement: el }; +} + +const FUNCS = ['esc', 'escAttr', 'highlight', 'resolveFile', 'fileIcon', 'fileLinkChip', 'linkifyFiles', 'mdFmt', 'mdInline', 'mdListItem', 'mdBlockKind', 'mdParseList', 'mdTableBlock', 'mdBlocks', 'render', 'lastStableIndex', 'makeStream', 'stripThink', 'streamFeed']; +// eslint-disable-next-line no-new-func +const boot = new Function('document', [ + 'let fileIndex = null; function scrollIfStuck(){}', + decl('FENCE'), decl('TICK'), decl('HL_KW'), decl('HTML5_SHIELD'), + ...FUNCS.map(extract), + 'return { render, mdInline, mdBlocks, lastStableIndex, makeStream, streamFeed, setFiles: function(rels){ const paths = new Set(rels); const byBase = new Map(); for (const r of rels){ const b = r.split("/").pop(); if (!byBase.has(b)) byBase.set(b, []); byBase.get(b).push(r); } fileIndex = { paths: paths, byBase: byBase }; } };' +].join('\n')); +const doc = fakeDocument(); +const C = boot(doc); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const T = '`', F = '```'; +const text = (h) => h.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); +const count = (h, tag) => (h.match(new RegExp('<' + tag + '[ >]', 'g')) || []).length; + +const DOCS = { + fenced: 'Here is the code:\n\n' + F + 'js\nconst a = 1;\nconsole.log(a);\n' + F + '\n\nAnd then some prose after it.\n\n- one\n- two\n', + twoBlocks: 'First:\n' + F + '\nplain block\n' + F + '\nbetween\n' + F + 'python\nprint("x")\n' + F + '\nlast words', + inList: '1. Install it:\n ' + F + 'bash\n npm install\n ' + F + '\n2. Run it.\n' +}; + +// --------------------------------------------------------------------------------------------------- + +test('PIN: an ordinary fenced block is a code block, and what surrounds it is prose', () => { + const h = C.render(DOCS.fenced); + assert.strictEqual(count(h, 'pre'), 1); + assert.match(h, /

Here is the code:<\/p>

/);
+	assert.ok(text(h).includes('const a = 1;\nconsole.log(a);\n'), 'the code, line for line');
+	assert.ok(!text(h).includes('js\nconst'), 'the language tag is not part of the code');
+	assert.match(h, /<\/code><\/pre>

And then some prose after it\.<\/p>

  • one<\/li>
  • two<\/li><\/ul>$/); +}); + +test('PIN: two blocks, prose between them, and an unfinished block at the end is still shown as code', () => { + const h = C.render(DOCS.twoBlocks); + assert.strictEqual(count(h, 'pre'), 2); + assert.ok(text(h).includes('plain block\n') && text(h).includes('print("x")\n')); + assert.match(h, /

    between<\/p>/); assert.match(h, /

    last words<\/p>$/); + const open = C.render('Look:\n' + F + 'js\nconst a = 1;\nstill typing'); + assert.strictEqual(count(open, 'pre'), 1); + assert.match(open, /

    [\s\S]*still typing<\/code><\/pre>$/, 'the tail of an open block is code, not prose');
    +});
    +
    +test('PIN: inline code is escaped and never formatted; a lone backtick is just a backtick', () => {
    +	assert.strictEqual(C.mdInline('use ' + T + 'a **x**' + T + ' here'), 'use a<b> **x** here');
    +	assert.strictEqual(C.mdInline('a ' + T + 'one' + T + ' and ' + T + 'two' + T + '.'), 'a one and two.');
    +	assert.strictEqual(C.mdInline('5' + T + ' is a backtick'), '5' + T + ' is a backtick');
    +	assert.strictEqual(C.mdInline('**bold** and *em*'), 'bold and em');
    +});
    +
    +test('PIN: a path in inline code that names a real project file becomes a file chip', () => {
    +	C.setFiles(['docs/MCP.md', 'extensions/levelcode-ai/agent.js']);
    +	const h = C.mdInline('see ' + T + 'docs/MCP.md' + T + ' and ' + T + 'nope.md' + T);
    +	assert.match(h, /nope\.md<\/code>/);
    +	C.setFiles([]);
    +});
    +
    +test('PIN: a fence inside a list item, indented, is still a code block', () => {
    +	const h = C.render(DOCS.inList);
    +	assert.strictEqual(count(h, 'pre'), 1);
    +	assert.ok(text(h).includes('npm install'));
    +	assert.ok(/Run it\./.test(text(h)));
    +});
    +
    +test('FENCES, SLOPPY: a fence stuck to the end of a line of prose, or a closing fence stuck to the code, still works', () => {
    +	const opener = C.render('Run this: ' + F + 'bash\nnpm test\n' + F + '\nDone.');
    +	assert.strictEqual(count(opener, 'pre'), 1);
    +	assert.match(opener, /^

    Run this:\s*<\/p>

    /);
    +	assert.ok(text(opener).includes('npm test\n'));
    +	assert.match(opener, /

    Done\.<\/p>$/); + const closer = C.render(F + 'js\nlet a = 1;' + F + '\nAfter.'); + assert.strictEqual(count(closer, 'pre'), 1); + assert.strictEqual(text(/

    ([\s\S]*?)<\/code><\/pre>/.exec(closer)[1]), 'let a = 1;');
    +	assert.match(closer, /

    After\.<\/p>$/); +}); + +console.log('chatMarkdownFences: ' + n + ' tests passed'); From 3b05fd3c3bb8cf218f18696c0172aa25793d743d Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 5 Oct 2026 00:26:13 -0400 Subject: [PATCH 2/9] =?UTF-8?q?fix(chat):=20three=20backticks=20in=20a=20s?= =?UTF-8?q?entence=20are=20not=20a=20code=20fence=20=E2=80=94=20the=20para?= =?UTF-8?q?graph=20came=20out=20a=20fragment=20per=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An answer that mentioned a code fence in passing — "each closed by a plain ```` ``` ````" — was rendered as two code blocks holding one backtick each, and the rest of the paragraph arrived one streamed fragment per line: "gr", "ounded ent", "irely in the actual code". Reported from a real session. The model had quoted the fence correctly, in an inline span of four backticks. Two things were wrong, and the second is what made it look so bad. render() split the text on every run of three backticks, wherever it stood. So ```` ``` ```` was three fences: a block containing "` ", and then an unfinished block containing the rest of the message. lastStableIndex() decides what the streaming renderer may freeze — move out of the live tail and into the page for good. It counted fences the same way, and when the text after an even-numbered one had no newline yet, it answered "all of it". From then on every delta was frozen as it arrived, each in its own

    . A fence is a line. mdSegments() reads the text line by line: a block opens on a line that is, after its indentation, three or more backticks and a language (or anything else without a backtick in it), and closes on a line ending in a run at least as long as the one that opened it. render() and lastStableIndex() both use it, so they cannot disagree again. A block counts as finished only once its closing line has its newline; text after it stays live until the next block finishes. Inline code takes the count seriously too: a run of N backticks opens a span that the next run of exactly N closes, on the same line. That is how three backticks are quoted (inside four), and one (inside two). Kept, because models do it and the old code let them: a fence stuck to the end of a line of prose ("Run this: ```bash") still opens a block — when nothing but a one-word language follows it and it is not the closing half of an inline span — and a closing fence stuck to the last line of code still closes one. Not changed: tildes are not fences; and a line of prose that ENDS in three bare backticks still opens a block, as it did. CommonMark would not; a model that wants to say "```" has an inline span for it, and now it works. chatMarkdownFences.test.js gains the report itself, whole and streamed — in sixty different chunkings, and one character at a time; the property that makes streaming safe (streamed and whole are the same page, and only finished blocks are frozen) over six documents and 400 random mixes of backticks, newlines and words; N-backtick spans; fences nested by length; language lines that say more than a word; and that nothing in a message becomes markup. The six pins from the last commit pass unchanged. The ten new tests fail on the old code. Replayed in Chromium against the shipped page with the session's exact text, in 184 deltas: the paragraph was 57 lines and two code blocks before, and is one paragraph and none after. 21 single-edit mutations of the new code are each caught by the suite. --- extensions/levelcode-ai/media/chat.html | 109 +++++++++--- .../test/chatMarkdownFences.test.js | 155 +++++++++++++++++- 2 files changed, 237 insertions(+), 27 deletions(-) diff --git a/extensions/levelcode-ai/media/chat.html b/extensions/levelcode-ai/media/chat.html index 4a125c1..8c59bf6 100644 --- a/extensions/levelcode-ai/media/chat.html +++ b/extensions/levelcode-ai/media/chat.html @@ -1841,14 +1841,35 @@ s = s.replace(/~~([^~\n]+)~~/g, '$1'); return s; } + // The runs of backticks on one line: [{ at, len }], left to right. + function tickRuns(line){ + const runs = []; let i = 0; + while (i < line.length){ + if (line[i] !== TICK){ i++; continue; } + let j = i; while (j < line.length && line[j] === TICK){ j++; } + runs.push({ at: i, len: j - i }); i = j; + } + return runs; + } // Split text into inline-code spans (escaped, never formatted) and formatted text — no sentinels. + // A run of N backticks opens a span that the next run of exactly N closes, on the same line — which + // is how three backticks are quoted (inside four). A run with no partner is just backticks. function mdInline(text){ - const re = new RegExp(TICK + '([^' + TICK + '\\n]+)' + TICK, 'g'); - let out = '', last = 0, m; - while ((m = re.exec(text))){ - const code = m[1], rel = resolveFile(code.trim()); - out += mdFmt(text.slice(last, m.index)) + (rel ? fileLinkChip(rel, esc(code)) : '' + esc(code) + ''); - last = m.index + m[0].length; + let out = '', last = 0, lineAt = 0; + for (const line of String(text).split('\n')){ + const runs = tickRuns(line); + for (let a = 0; a < runs.length; a++){ + let b = a + 1; while (b < runs.length && runs[b].len !== runs[a].len){ b++; } + if (b >= runs.length){ continue; } + const open = lineAt + runs[a].at, from = open + runs[a].len, to = lineAt + runs[b].at; + let code = text.slice(from, to); + // Padding that exists only to keep the content's own backticks off the delimiters is not content. + if (runs[a].len > 1 && code.length > 2 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()){ code = code.slice(1, -1); } + const rel = resolveFile(code.trim()); + out += mdFmt(text.slice(last, open)) + (rel ? fileLinkChip(rel, esc(code)) : '' + esc(code) + ''); + last = to + runs[b].len; a = b; + } + lineAt += line.length + 1; } return out + mdFmt(text.slice(last)); } @@ -1899,28 +1920,72 @@ } return html; } - function render(md){ - const parts = String(md == null ? '' : md).split(FENCE); - let out = ''; - for (let i = 0; i < parts.length; i++){ - if (i % 2 === 1){ - const body = parts[i].replace(/^[a-zA-Z0-9_+-]*\n/, ''); - out += '
    ' + highlight(body) + '
    '; + // ---- Fenced code ---- + // A fence is a LINE: indentation, three or more backticks, a language (or anything else without a + // backtick in it), and the end of the line. Three backticks in the middle of a sentence are text — + // usually one half of an inline span quoting a fence — and reading them as a fence used to turn the + // rest of the paragraph into a code block and, while it streamed, freeze it one fragment per line. + const FENCE_LINE = new RegExp('^[ \\t]*(' + TICK + '{3,})([^' + TICK + ']*)$'); + // Tolerated, because models do it: a fence stuck to the END of a line of prose ("Run: ```bash"). + // Only when nothing but a one-word language follows it, and it is not closing an inline span. + const FENCE_TAIL = new RegExp('^[ \\t]*[A-Za-z0-9_+.#-]*[ \\t]*$'); + // The end of a block: a run at least as long as the one that opened it, with nothing after it. + const FENCE_END = new RegExp('(' + TICK + '{3,})[ \\t]*$'); + /** + * Text as prose and code: [{ code: false, text } | { code: true, text, closed, end }]. `end` is the + * offset just past a closed block's last line, set only once that line has its newline — until + * then more could still arrive on it, so the block is not finished. + */ + function mdSegments(src){ + const text = String(src == null ? '' : src); + const out = []; let proseFrom = 0, pos = 0, open = null; + while (pos < text.length){ + const nl = text.indexOf('\n', pos), lineEnd = nl < 0 ? text.length : nl, next = nl < 0 ? text.length : nl + 1; + const line = text.slice(pos, lineEnd); + if (!open){ + let at = -1, ticks = 0; + const whole = FENCE_LINE.exec(line); + if (whole){ at = pos; ticks = whole[1].length; } + else if (line.indexOf(FENCE) >= 0){ + const runs = tickRuns(line), last = runs[runs.length - 1]; + // the last run on the line, if it pairs with nothing before it (a pair is an inline span) + let paired = false; + for (let a = 0; a < runs.length - 1 && !paired; a++){ + let b = a + 1; while (b < runs.length && runs[b].len !== runs[a].len){ b++; } + if (b < runs.length){ if (b === runs.length - 1){ paired = true; } a = b; } + } + if (last.len >= 3 && !paired && FENCE_TAIL.test(line.slice(last.at + last.len))){ at = pos + last.at; ticks = last.len; } + } + if (at >= 0){ + if (at > proseFrom){ out.push({ code: false, text: text.slice(proseFrom, at) }); } + open = { ticks: ticks, bodyFrom: next }; + } } else { - out += mdBlocks(parts[i]); + const m = FENCE_END.exec(line); + if (m && m[1].length >= open.ticks){ + out.push({ code: true, text: text.slice(open.bodyFrom, pos + m.index), closed: true, end: nl < 0 ? -1 : next }); + open = null; proseFrom = next; + } } + pos = next; + } + if (open){ out.push({ code: true, text: text.slice(Math.min(open.bodyFrom, text.length)), closed: false, end: -1 }); } + else if (proseFrom < text.length){ out.push({ code: false, text: text.slice(proseFrom) }); } + return out; + } + function render(md){ + let out = ''; + for (const seg of mdSegments(md)){ + out += seg.code ? '
    ' + highlight(seg.text) + '
    ' : mdBlocks(seg.text); } return out; } - // Index just past the last fully-closed code block. Everything before it is stable forever. + // Index just past the last FINISHED code block. Everything before it is stable forever — and + // nothing else is: text after a block stays live until the next block finishes. function lastStableIndex(text){ - const pos = []; let p = text.indexOf(FENCE); - while (p >= 0){ pos.push(p); p = text.indexOf(FENCE, p + 3); } - const stableFences = pos.length - (pos.length % 2); - if (stableFences === 0) { return 0; } - const end = pos[stableFences - 1] + 3; - const nl = text.indexOf('\n', end); - return nl >= 0 ? nl + 1 : text.length; + let stable = 0; + for (const seg of mdSegments(text)){ if (seg.code && seg.closed && seg.end > stable){ stable = seg.end; } } + return stable; } // A streaming renderer: completed code/diff blocks are FROZEN into immutable child nodes // (never re-rendered), so later-streamed text can never truncate or corrupt them. diff --git a/extensions/levelcode-ai/test/chatMarkdownFences.test.js b/extensions/levelcode-ai/test/chatMarkdownFences.test.js index 41169af..0ba1933 100644 --- a/extensions/levelcode-ai/test/chatMarkdownFences.test.js +++ b/extensions/levelcode-ai/test/chatMarkdownFences.test.js @@ -1,10 +1,14 @@ /*--------------------------------------------------------------------------------------------- * The chat's Markdown renderer: code fences and inline code — run: node test/chatMarkdownFences.test.js * - * What it does today, pinned before the way it finds a code fence is changed: an ordinary fenced - * block, two of them with prose between, a block still being typed, a fence indented inside a list - * item, inline code and file chips — and the two sloppy shapes models produce that it already - * tolerates (a fence stuck to the end of a line of prose; a closing fence stuck to the code). + * The bug this suite exists for: an answer that mentioned three backticks in the middle of a + * sentence — the model had quoted them properly, in an inline span of four — was rendered as two + * one-character code blocks, and the rest of the paragraph came out ONE STREAMED FRAGMENT PER LINE + * ("gr / ounded ent / irely in the actual code"). Two causes, both pinned here: + * + * 1. Any three backticks, anywhere, were a code fence. A fence is a line; an inline span is not. + * 2. While streaming, text after a "closed" fence with no newline yet was frozen into the page as + * it arrived, one piece per delta. Only a block that is really finished may be frozen. * * The renderer lives inline in media/chat.html, so its functions are sliced out of the file itself * (the pattern of shHighlight.test.js): these tests run the shipped code, not a copy of it. @@ -35,6 +39,8 @@ function decl(name) { assert.ok(m, 'chat.html no longer declares ' + name); return m[0]; } +const optional = (name) => (html.indexOf('\n function ' + name + '(') >= 0 ? extract(name) : ''); +const optionalDecl = (name) => (new RegExp('\\n const ' + name + ' = ').test(html) ? decl(name) : ''); // A document just big enough for streamFeed(): elements that hold innerHTML and keep their children in order. function fakeDocument() { @@ -47,6 +53,8 @@ const FUNCS = ['esc', 'escAttr', 'highlight', 'resolveFile', 'fileIcon', 'fileLi const boot = new Function('document', [ 'let fileIndex = null; function scrollIfStuck(){}', decl('FENCE'), decl('TICK'), decl('HL_KW'), decl('HTML5_SHIELD'), + optionalDecl('FENCE_LINE'), optionalDecl('FENCE_TAIL'), optionalDecl('FENCE_END'), + optional('tickRuns'), optional('mdSegments'), ...FUNCS.map(extract), 'return { render, mdInline, mdBlocks, lastStableIndex, makeStream, streamFeed, setFiles: function(rels){ const paths = new Set(rels); const byBase = new Map(); for (const r of rels){ const b = r.split("/").pop(); if (!byBase.has(b)) byBase.set(b, []); byBase.get(b).push(r); } fileIndex = { paths: paths, byBase: byBase }; } };' ].join('\n')); @@ -59,11 +67,31 @@ function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } const T = '`', F = '```'; const text = (h) => h.replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); const count = (h, tag) => (h.match(new RegExp('<' + tag + '[ >]', 'g')) || []).length; +/** Feed `full` through the streaming renderer in the given pieces; return what the page holds at the end, and after each piece. */ +function stream(pieces) { + const body = doc.createElement(); + const s = C.makeStream(body); + let acc = ''; const frames = []; + for (const p of pieces) { + acc += p; C.streamFeed(s, acc); + frames.push({ frozen: body.children.filter((c) => c !== s.live).map((c) => c.innerHTML), live: s.live.innerHTML }); + } + return { html: body.children.map((c) => c.innerHTML).join(''), frames, frozen: body.children.filter((c) => c !== s.live).map((c) => c.innerHTML) }; +} +const chunk = (s, sizes) => { const out = []; let i = 0, k = 0; while (i < s.length) { const z = sizes[k++ % sizes.length]; out.push(s.slice(i, i + z)); i += z; } return out; }; +/** Deterministic pseudo-random chunk sizes. */ +const sizesFor = (seed) => { let x = seed * 2654435761 >>> 0; const out = []; for (let i = 0; i < 40; i++) { x = (x * 1664525 + 1013904223) >>> 0; out.push(1 + (x >>> 24) % 17); } return out; }; + +// The paragraph from the report, exactly as the model wrote it. +const REPORTED = 'Four opening fences, matching four diagrams, each closed by a plain ' + F + T + ' ' + F + ' ' + F + T + '. The diagram is grounded entirely in the actual code: config trust tiers (' + T + 'loadServerConfig' + T + '), SHA-256 launch fingerprint (' + T + 'launchFingerprint' + T + '/' + T + 'approveMcpLaunch' + T + '), the ' + T + 'server__tool' + T + ' naming + routes (' + T + 'buildAgentTools' + T + '), and the four §4 gates.\n\nDone: Added ' + T + 'docs/MCP-DIAGRAM.md' + T + ' — four Mermaid diagrams.\n\n- **Flow diagram** traces config sources.\n- **Sequence diagram** walks one ' + T + 'tools/call' + T + '.\n'; const DOCS = { + plain: 'A paragraph.\n\nAnother, with ' + T + 'inline code' + T + ' and **bold**.\n', fenced: 'Here is the code:\n\n' + F + 'js\nconst a = 1;\nconsole.log(a);\n' + F + '\n\nAnd then some prose after it.\n\n- one\n- two\n', twoBlocks: 'First:\n' + F + '\nplain block\n' + F + '\nbetween\n' + F + 'python\nprint("x")\n' + F + '\nlast words', - inList: '1. Install it:\n ' + F + 'bash\n npm install\n ' + F + '\n2. Run it.\n' + inList: '1. Install it:\n ' + F + 'bash\n npm install\n ' + F + '\n2. Run it.\n', + nested: 'A Markdown example:\n\n' + F + T + 'markdown\nUse a fence:\n' + F + 'js\nlet x;\n' + F + '\n' + F + T + '\n\nThat was four backticks around three.\n', + reported: REPORTED }; // --------------------------------------------------------------------------------------------------- @@ -109,6 +137,100 @@ test('PIN: a fence inside a list item, indented, is still a code block', () => { assert.ok(/Run it\./.test(text(h))); }); +test('THE REPORT: three backticks quoted inside four are inline code — one paragraph, no code block', () => { + const h = C.render(REPORTED); + assert.strictEqual(count(h, 'pre'), 0, 'no code block: ' + h.slice(0, 300)); + assert.match(h, /^

    Four opening fences, matching four diagrams, each closed by a plain ```<\/code>\. The diagram is grounded entirely in the actual code: config trust tiers \(loadServerConfig<\/code>\)/); + assert.strictEqual(count(h, 'p'), 2, 'the paragraph, then the Done line'); + assert.match(h, /

    Done: Added docs\/MCP-DIAGRAM\.md<\/code> — four Mermaid diagrams\.<\/p>

    • Flow diagram<\/strong>/); +}); + +test('THE REPORT, STREAMED: however it arrives, nothing is frozen mid-paragraph and the page ends up the same', () => { + const whole = C.render(REPORTED); + for (let seed = 1; seed <= 60; seed++) { + const r = stream(chunk(REPORTED, sizesFor(seed))); + assert.strictEqual(r.html, whole, 'seed ' + seed + ': streamed in pieces, it must render as it does whole'); + assert.deepStrictEqual(r.frozen, [], 'seed ' + seed + ': there is no finished code block here, so nothing may be frozen'); + } + // one character at a time — the worst case for anything that looks at "the text so far" + const slow = stream(REPORTED.split('')); + assert.strictEqual(slow.html, whole); + assert.ok(slow.frames.every((f) => f.frozen.length === 0), 'not at any point along the way either'); +}); + +test('STREAMING: for every document, streamed and whole are the same page — and only finished blocks are frozen', () => { + for (const [name, src] of Object.entries(DOCS)) { + const whole = C.render(src); + for (let seed = 1; seed <= 40; seed++) { + const r = stream(chunk(src, sizesFor(seed * 7 + name.length))); + assert.strictEqual(r.html, whole, name + ', seed ' + seed); + for (const f of r.frozen) { assert.ok(/<\/code><\/pre>$/.test(f) || !/
      /.test(f) || /<\/pre>/.test(f), name + ': a frozen piece ends an open block: ' + f.slice(-80)); }
      +			// frozen pieces are never taken back or changed once written
      +			let seen = [];
      +			for (const frame of r.frames) { assert.deepStrictEqual(frame.frozen.slice(0, seen.length), seen, name + ', seed ' + seed + ': a frozen piece changed'); seen = frame.frozen; }
      +		}
      +	}
      +});
      +
      +test('STREAMING: a finished block is frozen as soon as its closing line is complete, and not a moment before', () => {
      +	const src = 'Intro\n' + F + 'js\nlet a;\n' + F + '\nAfter the block, more words.';
      +	const at = (upto) => stream([src.slice(0, upto)]);
      +	const closeAt = src.indexOf(F + '\nAfter');
      +	assert.deepStrictEqual(at(closeAt + 2).frozen, [], 'two of the three closing backticks: still open');
      +	assert.deepStrictEqual(at(closeAt + 3).frozen, [], 'the closing fence with no newline yet: the line is not finished');
      +	const done = at(closeAt + 4);
      +	assert.strictEqual(done.frozen.length, 1, 'the newline ends the closing line');
      +	assert.match(done.frozen[0], /^

      Intro<\/p>

      [\s\S]*<\/code><\/pre>$/);
      +	// and what follows it, word by word, stays in the live tail — one paragraph, never a pile of fragments
      +	const r = stream(chunk(src, [3]));
      +	assert.strictEqual(r.frozen.length, 1);
      +	assert.strictEqual(r.html, C.render(src));
      +	assert.match(r.html, /

      After the block, more words\.<\/p>$/); +}); + +test('INLINE: N backticks open a span that N backticks close — so backticks can be quoted', () => { + assert.strictEqual(C.mdInline('a ' + T + T + 'x ' + T + ' y' + T + T + ' b'), 'a x ' + T + ' y b', 'one backtick inside two'); + assert.strictEqual(C.mdInline('the marker ' + F + T + ' ' + F + ' ' + F + T + ' opens a block'), 'the marker ``` opens a block', 'three inside four, the padding spaces dropped'); + assert.strictEqual(C.mdInline(T + T + 'plain' + T + T), 'plain'); + assert.strictEqual(C.mdInline('odd ' + T + T + 'x' + T + ' ones'), 'odd ' + T + T + 'x' + T + ' ones', 'two and one do not pair: text'); + assert.strictEqual(C.mdInline('a ' + T + 'b\nc' + T + ' d'), 'a ' + T + 'b\nc' + T + ' d', 'a span does not run across a line break'); + assert.strictEqual(C.mdInline(T + ' spaced ' + T), ' spaced ', 'a single-backtick span keeps its text as written'); + assert.strictEqual(C.mdInline('one ' + T + ' then two ' + T + T + ' end'), 'one ' + T + ' then two ' + T + T + ' end', 'a run is closed by one of the SAME length, not by a longer one'); + assert.strictEqual(C.mdInline('a ' + T + T + ' b ' + F + ' c ' + T + T + ' d'), 'a b ' + F + ' c d', 'a longer run inside is content'); + // a paragraph is handed over whole: spans on its later lines are found where they are + assert.strictEqual(C.mdInline('first line\nsecond ' + T + 'code' + T + ' here\nthird ' + T + T + 'x' + T + T + '.'), 'first line\nsecond code here\nthird x.'); + assert.strictEqual(C.mdBlocks('one ' + T + 'a' + T + '\ntwo ' + T + 'b' + T), '

      one a
      two b

      '); +}); + +test('FENCES: a fence is a line — backticks in the middle of a sentence never start a block', () => { + for (const prose of [ + 'Wrap it in ' + F + T + ' ' + F + ' ' + F + T + ' to quote a fence.', + 'The ' + F + ' marker, then text, then ' + F + ' again on one line.', + 'Three ticks ' + F + ' then words and nothing else on the line', + 'It ends with a quoted fence: ' + F + T + ' ' + F + ' ' + F + T + ]) { + const h = C.render(prose + '\n\nNext paragraph.'); + assert.strictEqual(count(h, 'pre'), 0, 'no code block for: ' + prose); + assert.match(h, /

      Next paragraph\.<\/p>$/, 'and the next paragraph is still a paragraph: ' + prose); + } +}); + +test('FENCES: a longer fence holds shorter ones — a Markdown example inside four backticks is one block', () => { + const h = C.render(DOCS.nested); + assert.strictEqual(count(h, 'pre'), 1); + assert.ok(text(h).includes('Use a fence:\n' + F + 'js\nlet x;\n' + F + '\n'), 'the inner fence is shown as code, backticks and all'); + assert.match(h, /

      That was four backticks around three\.<\/p>$/); +}); + +test('FENCES: the language line may say more than a word, and is never shown as code', () => { + for (const info of ['js', 'diff', 'c++', 'objective-c', 'js title="app.js"', '{r, echo=FALSE}', 'python {1,3}']) { + const h = C.render('Before\n' + F + info + '\nBODY\n' + F + '\nAfter'); + assert.strictEqual(count(h, 'pre'), 1, info); + assert.strictEqual(text(/

      ([\s\S]*?)<\/code><\/pre>/.exec(h)[1]), 'BODY\n', 'info "' + info + '" is not in the block');
      +		assert.match(h, /

      After<\/p>$/); + } +}); + test('FENCES, SLOPPY: a fence stuck to the end of a line of prose, or a closing fence stuck to the code, still works', () => { const opener = C.render('Run this: ' + F + 'bash\nnpm test\n' + F + '\nDone.'); assert.strictEqual(count(opener, 'pre'), 1); @@ -121,4 +243,27 @@ test('FENCES, SLOPPY: a fence stuck to the end of a line of prose, or a closing assert.match(closer, /

      After\.<\/p>$/); }); +test('SAFE: nothing in a message becomes markup — in prose, in inline code, or in a block', () => { + const evil = ' ' + T + '' + T + ' ' + F + T + ' x ' + F + T + '\n' + F + 'html\n\n' + F + '\n'; + const h = C.render(evil); + assert.ok(!//.test(h), h); + assert.ok(h.includes('<img src=x onerror=alert(1)>') && h.includes('<script>alert(2)</script>') && h.includes('<b>x</b>')); + assert.strictEqual(stream(evil.split('')).html, h); +}); + +test('ROBUST: any mix of backticks, newlines and words renders without throwing, streamed or whole, to the same page', () => { + const bits = [T, T + T, F, F + T, '\n', '\n\n', ' ', 'word', 'js', '- item', '**b**', ' ', 'x' + T + 'y', F + 'py\n', '\n' + F + '\n']; + let x = 12345; + const rnd = (k) => { x = (x * 1103515245 + 12345) >>> 0; return (x >>> 16) % k; }; + for (let i = 0; i < 400; i++) { + let src = ''; const len = 3 + rnd(14); + for (let j = 0; j < len; j++) { src += bits[rnd(bits.length)]; } + const whole = C.render(src); + assert.strictEqual(typeof whole, 'string'); + const r = stream(chunk(src, sizesFor(i + 1))); + assert.strictEqual(r.html, whole, 'case ' + i + ': ' + JSON.stringify(src)); + assert.ok(C.lastStableIndex(src) <= src.length); + } +}); + console.log('chatMarkdownFences: ' + n + ' tests passed'); From 42159e59a14af301e484dc21f37e1713db367f5e Mon Sep 17 00:00:00 2001 From: Sergii Demianchuk Date: Mon, 5 Oct 2026 00:26:40 -0400 Subject: [PATCH 3/9] =?UTF-8?q?feat(diagrams):=20the=20spec=20=E2=80=94=20?= =?UTF-8?q?a=20schema,=20a=20validator=20that=20reports=20every=20error=20?= =?UTF-8?q?at=20once,=20and=20the=20repair=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of rich diagrams; docs/RICH-DIAGRAMS.md arrives with the last one. The agent draws flows and architectures out of box-drawing characters. The plan is that it describes STRUCTURE instead — nodes, edges, groups, one accent, never a coordinate or a colour — and the editor draws. This commit is the part that decides whether a description may be drawn. Nothing calls it yet. diagram/schema.js the versioned schema, and a small interpreter for the subset of JSON Schema it uses. One object is both the tool's input schema and what a spec is checked against. Two tiers of limits: HOUSE is what a model is held to (12 nodes; labels 28, second lines 32, edge labels 20, title 80; groups two deep), HARD is what the renderer will still draw (24 nodes, 48 edges). diagram/validate.js schema, then semantics: edge ends exist, ids are unique, one accent, groups known, acyclic, at most two deep. EVERY error, each with a JSON Pointer, what was expected and the valid options: /edges/1/to: unknown node "billing". Known ids: in, jev, bill, rev. diagram/repair.js the ladder. prepare() takes one call up it. The ladder departs from the spec in one place, on purpose. The spec lists "dedupe ids" and "drop edges to unknown nodes" among the fixes made without a model, while its own table says semantic errors go to the model, "because intent is needed". Both cannot hold: an edge to "billing" when the node is "bill" is a typo the model fixes in one pass, and dropping it silently changes what the diagram says. So: 1 auto-fix, lossless only lenient JSON (comments, trailing commas, smart quotes, bare keys), synonyms ("diamond" for decision, source/target for from/to), slugged ids, an edge that names a node by its label, long labels shortened with the full text kept 2 every error, once returned for the model's one repair pass 3 degrade only now the lossy fixes: edges to nowhere dropped, duplicates renamed, one accent kept, counts up to the HARD tier — each loss named An auto-fixed diagram never says something different from what the model wrote; a degraded one always says what it lost. accept() is the same gate for a spec that is read back rather than received — from a session file, say: validate, hand on only the fields the schema declares, and fall back to the rungs that need no model. No Ajv: it is a dependency, and this extension has neither dependencies nor a build step. test/fixtures/diagrams/corpus.json holds 39 broken specs with the exact outcome of each attempt. They are seeded from the ways models are known to get this wrong, not collected in the field; real ones should be added as they turn up. diagramSchema (18 tests) and diagramRepair (65). --- extensions/levelcode-ai/diagram/repair.js | 691 +++++++ extensions/levelcode-ai/diagram/schema.js | 253 +++ extensions/levelcode-ai/diagram/validate.js | 177 ++ .../levelcode-ai/test/diagramRepair.test.js | 361 ++++ .../levelcode-ai/test/diagramSchema.test.js | 207 ++ .../test/fixtures/diagrams/corpus.json | 1811 +++++++++++++++++ 6 files changed, 3500 insertions(+) create mode 100644 extensions/levelcode-ai/diagram/repair.js create mode 100644 extensions/levelcode-ai/diagram/schema.js create mode 100644 extensions/levelcode-ai/diagram/validate.js create mode 100644 extensions/levelcode-ai/test/diagramRepair.test.js create mode 100644 extensions/levelcode-ai/test/diagramSchema.test.js create mode 100644 extensions/levelcode-ai/test/fixtures/diagrams/corpus.json diff --git a/extensions/levelcode-ai/diagram/repair.js b/extensions/levelcode-ai/diagram/repair.js new file mode 100644 index 0000000..d1da189 --- /dev/null +++ b/extensions/levelcode-ai/diagram/repair.js @@ -0,0 +1,691 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the repair ladder (docs/RICH-DIAGRAMS.md, "Validation and repair") + * + * Most broken specs are broken MECHANICALLY — a trailing comma, `"shape": "diamond"`, an edge that + * names a node by its label — so most should never cost a model call. The ladder reaches for a + * model only when deterministic fixes run out, and it never loops: + * + * rung 1 normalize() deterministic, no model. Tidies everything that has ONE obvious reading: + * lenient JSON, synonyms ("diamond" → decision), an edge that names a node by + * its label, a label over the limit (shortened; the full text survives as a + * tooltip and in the screen-reader outline). + * rung 2 (the caller) ONE model pass: whatever needs intent — an edge to a node that does not + * exist, two accents, 17 nodes, groups three deep — goes back as an error list. + * rung 3 normalize({lossy}) + degrade() still broken after that pass: drop the edges that point + * nowhere, rename the colliding ids, keep one accent, flatten the nesting — + * and draw what is left under a banner that says what was lost. Or give up + * honestly (`failed`) so the UI can show the source and a Retry. + * + * THE RULE THAT ORDERS THE RUNGS: an "auto-fixed" diagram never says something different from what + * the model wrote, and a "degraded" one always says what it lost. So a fix that changes MEANING + * (a dropped edge, a renamed duplicate) is never applied before the model has had its one chance + * to do it properly — while a fix that only changes PRESENTATION never costs a model call. + * + * prepare() runs the ladder for one call and says which rung it ended on. It is pure: the caller + * decides what "the repair pass was already spent" means (`final`) and what to do with the answer. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./schema'), require('./validate')); } + else { (root.LCDiagram = root.LCDiagram || {}).repair = factory(root.LCDiagram.schema, root.LCDiagram.validate); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (schema, validator) { + 'use strict'; + + const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + const q = (s) => JSON.stringify(String(s)); + const HOUSE = schema.HOUSE, HARD = schema.HARD; + + // ---- text hygiene --------------------------------------------------------------------------- + // Control characters and bidirectional overrides have no business in a label: the first break + // layout, the second can make a node READ as something other than what it says. + // eslint-disable-next-line no-control-regex + const UNSAFE_CHARS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f​-‏‪-‮⁠-⁩]/g; + /** One clean line: unsafe characters gone, runs of whitespace collapsed. */ + function cleanText(s) { return String(s == null ? '' : s).replace(UNSAFE_CHARS, '').replace(/\s+/g, ' ').trim(); } + const count = (s) => Array.from(s).length; + + /** + * Cut `text` to `max` characters, ending in an ellipsis. Prefers a word boundary when one is + * close, so "Validate the incoming request" shortens to "Validate the incoming…", not "…incoming reque…". + */ + function truncate(text, max) { + const chars = Array.from(String(text)); + if (chars.length <= max) { return String(text); } + let cut = chars.slice(0, Math.max(1, max - 1)).join(''); + const sp = cut.lastIndexOf(' '); + if (sp >= Math.floor(max * 0.6)) { cut = cut.slice(0, sp); } + return cut.replace(/[\s,;:.\-–—(]+$/, '') + '…'; + } + + /** A model-supplied name → a lowercase slug. '' when nothing sluggable is left (the caller invents one). */ + function slugify(s) { + let t = String(s == null ? '' : s); + try { t = t.normalize('NFKD'); } catch (e) { /* no ICU — fine, non-ASCII just becomes '-' */ } + t = t.replace(/[̀-ͯ]/g, '').toLowerCase().trim() + .replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, ''); + return t.slice(0, HOUSE.id).replace(/[-_]+$/, ''); + } + + // ---- lenient JSON --------------------------------------------------------------------------- + const QUOTES = { '"': '"', "'": "'", '“': '”', '”': '”' }; + + /** + * Parse JSON the way models actually write it: comments, trailing commas, single or "smart" + * quotes, bare keys, Python's True/False/None, a ```json fence around the lot. + * + * What it will NOT do is finish a spec that stops mid-structure. Truncated output means the + * model hit its token cap, and guessing at the rest would draw a diagram nobody wrote — so that + * comes back as `truncated`, and the caller re-requests instead of repairing. + * @param {string} text + * @returns {{ ok: boolean, value?: any, lenient?: boolean, truncated?: boolean, error?: string }} + */ + function parseLenient(text) { + let src = String(text == null ? '' : text).replace(/^/, '').trim(); + try { return { ok: true, value: JSON.parse(src), lenient: false }; } catch (e) { /* fall through to the tolerant path */ } + const fence = /^```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n?```\s*$/.exec(src); + if (fence) { src = fence[1].trim(); } + const start = src.search(/[{[]/); + if (start < 0) { return { ok: false, error: 'no JSON object found' }; } + src = src.slice(start); + + let out = '', i = 0, depth = 0; + const n = src.length; + const skipSpaceAndComments = (j) => { + for (;;) { + while (j < n && /\s/.test(src[j])) { j++; } + if (src[j] === '/' && src[j + 1] === '/') { const e = src.indexOf('\n', j); j = e < 0 ? n : e; continue; } + if (src[j] === '/' && src[j + 1] === '*') { const e = src.indexOf('*/', j + 2); j = e < 0 ? n : e + 2; continue; } + return j; + } + }; + while (i < n) { + const c = src[i]; + if (QUOTES[c]) { + const close = QUOTES[c], smart = c !== '"' && c !== "'"; + let j = i + 1, buf = '', closed = false; + while (j < n) { + const d = src[j]; + if (d === '\\') { + if (j + 1 >= n) { break; } + buf += (c === "'" && src[j + 1] === "'") ? "'" : d + src[j + 1]; + j += 2; continue; + } + if (d === close || (smart && d === '“')) { closed = true; j++; break; } + if (d === '"') { buf += '\\"'; j++; continue; } // a bare " inside '…' or “…” + if (d === '\n') { buf += '\\n'; j++; continue; } + if (d === '\r') { j++; continue; } + if (d === '\t') { buf += '\\t'; j++; continue; } + buf += d; j++; + } + if (!closed) { return { ok: false, truncated: true, error: 'unterminated string' }; } + out += '"' + buf + '"'; i = j; continue; + } + if (c === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) { + const j = skipSpaceAndComments(i); + if (j >= n && src[i + 1] === '*' && src.indexOf('*/', i + 2) < 0) { return { ok: false, truncated: true, error: 'unterminated comment' }; } + i = j; continue; + } + if (c === ',') { + const j = skipSpaceAndComments(i + 1); + if (src[j] === '}' || src[j] === ']') { i++; continue; } // trailing comma + } + if (/[A-Za-z_$]/.test(c)) { + let j = i; while (j < n && /[\w$]/.test(src[j])) { j++; } + const word = src.slice(i, j); + const k = skipSpaceAndComments(j); + if (src[k] === ':') { out += '"' + word + '"'; } // bare key + else if (word === 'True') { out += 'true'; } + else if (word === 'False') { out += 'false'; } + else if (word === 'None' || word === 'undefined') { out += 'null'; } + else { out += word; } + i = j; continue; + } + if (c === '{' || c === '[') { depth++; } + else if (c === '}' || c === ']') { + depth--; + if (depth === 0) { out += c; break; } // ignore prose after the object + } + out += c; i++; + } + if (depth > 0) { return { ok: false, truncated: true, error: 'the JSON stops before it is complete' }; } + try { return { ok: true, value: JSON.parse(out), lenient: true }; } + catch (e) { return { ok: false, error: String((e && e.message) || e) }; } + } + + // ---- rung 1: normalize ---------------------------------------------------------------------- + const DIRECTION_SYNONYMS = { + right: 'right', lr: 'right', 'left-to-right': 'right', 'left-right': 'right', ltr: 'right', horizontal: 'right', east: 'right', row: 'right', + rl: 'right', 'right-to-left': 'right', left: 'right', + down: 'down', tb: 'down', td: 'down', 'top-to-bottom': 'down', 'top-down': 'down', 'top-bottom': 'down', vertical: 'down', south: 'down', column: 'down', + bt: 'down', 'bottom-to-top': 'down', up: 'down' + }; + const SHAPE_SYNONYMS = { + box: 'box', rect: 'box', rectangle: 'box', square: 'box', process: 'box', step: 'box', node: 'box', default: 'box', task: 'box', service: 'box', component: 'box', + decision: 'decision', diamond: 'decision', rhombus: 'decision', condition: 'decision', choice: 'decision', branch: 'decision', 'if': 'decision', gateway: 'decision', + store: 'store', database: 'store', db: 'store', cylinder: 'store', storage: 'store', datastore: 'store', 'data-store': 'store', table: 'store', cache: 'store', queue: 'store', bucket: 'store', + actor: 'actor', person: 'actor', user: 'actor', human: 'actor', external: 'actor', pill: 'actor', stadium: 'actor', terminal: 'actor', terminator: 'actor', round: 'actor', rounded: 'actor', start: 'actor', end: 'actor', client: 'actor' + }; + const STYLE_SYNONYMS = { + solid: 'solid', line: 'solid', normal: 'solid', plain: 'solid', thick: 'solid', bold: 'solid', + dashed: 'dashed', dash: 'dashed', dashes: 'dashed', dotted: 'dashed', dots: 'dashed', dot: 'dashed', broken: 'dashed', optional: 'dashed', async: 'dashed' + }; + const WRAPPERS = ['spec', 'diagram', 'graph', 'input', 'json', 'arguments', 'data']; + const key = (s) => String(s == null ? '' : s).toLowerCase().trim().replace(/[\s_]+/g, '-'); + const pick = (obj, names) => { for (const n of names) { if (obj[n] !== undefined && obj[n] !== null) { return obj[n]; } } return undefined; }; + const asBool = (v) => (v === true || v === 1 || (typeof v === 'string' && /^(true|yes|1)$/i.test(v.trim()))); + const scalar = (v) => (typeof v === 'string' || typeof v === 'number') ? String(v) : undefined; + + /** + * Rung 1. Turn whatever the model sent into a clean spec in canonical form, recording each thing + * it changed. + * + * Three kinds of fix come out of here, and the difference is what the user is told: + * (plain) a tidy-up with one obvious reading — `"shape": "diamond"`, a trailing comma, an id + * with a space in it. The drawing is what the model meant; nobody needs a badge. + * `show` the drawing differs from what was written, but says the same thing: a label over + * the limit was shortened, a shape nobody knows became a box. "auto-fixed" badge. + * `lossy` it changed what the diagram SAYS — an edge dropped, a duplicate id renamed. Only + * ever applied when `opts.lossy` is set, which the ladder does on its last rung. + * + * Never throws. A value it cannot make sense of is passed through for validate() to name. + * @param {any} input + * @param {{ lossy?: boolean }} [opts] + * @returns {{ spec: any, fixes: Array<{pointer:string, cls:string, message:string, show?:boolean, lossy?:boolean, shortened?:boolean}>, truncated?: boolean, syntax?: string }} + */ + function normalize(input, opts) { + const allowLossy = !!(opts && opts.lossy); + /** @type {Array<{pointer:string, cls:string, message:string, show?:boolean, lossy?:boolean, shortened?:boolean}>} */ + const fixes = []; + const fix = (pointer, cls, message, flags) => { fixes.push(Object.assign({ pointer, cls, message }, flags || {})); }; + + // -- syntax: text → object, and unwrap a spec that arrived inside an envelope + let raw = input; + for (let hop = 0; hop < 3; hop++) { + if (typeof raw === 'string') { + const p = parseLenient(raw); + if (!p.ok) { return p.truncated ? { spec: null, fixes, truncated: true } : { spec: null, fixes, syntax: p.error || 'not valid JSON' }; } + fix('', p.lenient ? 'lenient-json' : 'stringified', p.lenient ? 'JSON repaired (comments, quotes or trailing commas).' : 'spec arrived as a JSON string; parsed it.'); + raw = p.value; continue; + } + if (isObj(raw) && raw.nodes === undefined) { + const w = WRAPPERS.find((k) => raw[k] !== undefined && (isObj(raw[k]) || typeof raw[k] === 'string')); + if (w) { fix('', 'unwrapped', 'spec was nested under "' + w + '"; unwrapped it.'); raw = raw[w]; continue; } + } + break; + } + if (!isObj(raw)) { return { spec: raw, fixes }; } + // A structural copy: plain data only, and nothing shared with the caller's object. + try { raw = JSON.parse(JSON.stringify(raw)); } catch (e) { return { spec: null, fixes, syntax: 'spec is not plain JSON data' }; } + + const spec = {}; + let dropped = 0; // unknown fields, counted not listed + // `tip` is the renderer's own field (the full text of a label that was shortened). A spec that + // has been through here before carries them, and going through again must not lose them. + const keepTip = (from, to) => { if (typeof from.tip === 'string' && cleanText(from.tip)) { to.tip = truncate(cleanText(from.tip), HARD.tip); } }; + keepTip(raw, spec); + + // -- v + if (raw.v === undefined || raw.v === null) { spec.v = schema.VERSION; } + else if (typeof raw.v === 'string' && /^\d+$/.test(raw.v.trim())) { spec.v = Number(raw.v); fix('/v', 'coerced', 'version given as a string; read it as a number.'); } + else { spec.v = raw.v; } + + // -- title + const title = scalar(pick(raw, ['title', 'name', 'caption', 'heading'])); + if (title !== undefined) { spec.title = cleanText(title); } + if (raw.title === undefined && title !== undefined) { fix('/title', 'renamed-field', 'used the "name"/"caption" field as the title.'); } + + // -- direction + const dirRaw = pick(raw, ['direction', 'dir', 'rankdir', 'orientation', 'flow']); + if (dirRaw === undefined) { spec.direction = 'right'; } + else { + const d = DIRECTION_SYNONYMS[key(dirRaw)]; + if (d) { spec.direction = d; if (d !== dirRaw) { fix('/direction', 'synonym', JSON.stringify(dirRaw) + ' read as "' + d + '".'); } } + else { spec.direction = 'right'; fix('/direction', 'enum', JSON.stringify(dirRaw) + ' is not a direction; used "right".', { show: true }); } + } + + // -- groups first: nodes refer to them + const groupIdOf = new Map(); // what the model wrote → the slug we settled on + let groupsIn = raw.groups; + if (isObj(groupsIn)) { + groupsIn = Object.keys(groupsIn).map((k) => (isObj(groupsIn[k]) ? Object.assign({ id: k }, groupsIn[k]) : { id: k, label: scalar(groupsIn[k]) })); + fix('/groups', 'coerced', 'groups given as an object; read as a list.'); + } + let groups; + if (Array.isArray(groupsIn)) { + groups = []; + groupsIn.forEach((g, i) => { + if (typeof g === 'string') { g = { id: g, label: g }; } + if (!isObj(g)) { groups.push(g); return; } + const out = {}; + const rawId = scalar(pick(g, ['id', 'key', 'name'])); + const label = scalar(pick(g, ['label', 'title', 'name', 'text'])); + let id = rawId !== undefined ? slugify(rawId) : ''; + if (!id) { id = slugify(label || '') || ('g' + (i + 1)); } + if (rawId !== undefined) { groupIdOf.set(rawId, id); groupIdOf.set(key(rawId), id); } + if (rawId !== id && rawId !== undefined) { fix('/groups/' + i + '/id', 'slug', q(rawId) + ' written as the slug "' + id + '".'); } + out.id = id; + out.label = label !== undefined && cleanText(label) ? cleanText(label) : (rawId !== undefined ? cleanText(rawId) : id); + if (label === undefined) { fix('/groups/' + i + '/label', 'defaulted', 'group had no label; used its id.'); } + const parent = scalar(pick(g, ['parent', 'in', 'group'])); + if (parent !== undefined && cleanText(parent)) { out.parent = parent; } + keepTip(g, out); + dropped += Object.keys(g).filter((k) => ['id', 'key', 'name', 'label', 'title', 'text', 'parent', 'in', 'group', 'tip'].indexOf(k) < 0).length; + groups.push(out); + }); + const gid = (ref) => (groupIdOf.has(ref) ? groupIdOf.get(ref) : groupIdOf.has(key(ref)) ? groupIdOf.get(key(ref)) : (slugify(ref) || String(ref))); + for (const g of groups) { if (isObj(g) && g.parent !== undefined) { g.parent = gid(g.parent); } } + } else if (groupsIn !== undefined && groupsIn !== null) { groups = groupsIn; } + const groupRef = (ref) => (groupIdOf.has(ref) ? groupIdOf.get(ref) : groupIdOf.has(key(ref)) ? groupIdOf.get(key(ref)) : (slugify(ref) || String(ref))); + + // -- nodes + const idOf = new Map(); // what the model wrote → the slug we settled on + const idByLabel = new Map(); // lowercased label → id (null when two nodes share a label) + let nodesIn = raw.nodes; + if (isObj(nodesIn)) { + nodesIn = Object.keys(nodesIn).map((k) => (isObj(nodesIn[k]) ? Object.assign({ id: k }, nodesIn[k]) : { id: k, label: scalar(nodesIn[k]) })); + fix('/nodes', 'coerced', 'nodes given as an object; read as a list.'); + } + let nodes; + if (Array.isArray(nodesIn)) { + nodes = []; + nodesIn.forEach((n, i) => { + const at = '/nodes/' + i; + if (typeof n === 'string' || typeof n === 'number') { n = { id: String(n), label: String(n) }; fix(at, 'coerced', 'node given as a bare name; used it as both id and label.'); } + if (!isObj(n)) { nodes.push(n); return; } + const out = {}; + const rawId = scalar(pick(n, ['id', 'key'])); + let labelRaw = scalar(pick(n, ['label', 'name', 'title', 'text'])); + let subRaw = scalar(pick(n, ['sub', 'subtitle', 'sublabel', 'description', 'desc', 'detail', 'note'])); + if (n.label === undefined && labelRaw !== undefined) { fix(at + '/label', 'renamed-field', 'used the "name"/"title" field as the label.'); } + if (n.sub === undefined && subRaw !== undefined) { fix(at + '/sub', 'renamed-field', 'used the "description"/"subtitle" field as the second line.'); } + // "Jev\nreturns probabilities" is a label and a sub written in one field. + if (labelRaw !== undefined && subRaw === undefined && /\r?\n|/i.test(labelRaw)) { + const parts = labelRaw.split(/\r?\n|/i).map(cleanText).filter(Boolean); + if (parts.length > 1) { labelRaw = parts[0]; subRaw = parts.slice(1).join(' '); fix(at + '/label', 'split-label', 'two-line label split into label and sub.'); } + } + const label = labelRaw !== undefined ? cleanText(labelRaw) : undefined; + let id = rawId !== undefined ? slugify(rawId) : ''; + if (!id) { id = slugify(label || '') || ('n' + (i + 1)); if (rawId === undefined) { fix(at + '/id', 'defaulted', 'node had no id; made "' + id + '" from its label.'); } } + if (rawId !== undefined && rawId !== id) { fix(at + '/id', 'slug', q(rawId) + ' written as the slug "' + id + '".'); } + if (rawId !== undefined) { if (!idOf.has(rawId)) { idOf.set(rawId, id); } if (!idOf.has(key(rawId))) { idOf.set(key(rawId), id); } } + out.id = id; + if (label !== undefined && label) { out.label = label; } + else if (rawId !== undefined && cleanText(rawId)) { out.label = cleanText(rawId); fix(at + '/label', 'defaulted', 'node had no label; used its id.'); } + if (out.label) { const lk = out.label.toLowerCase(); idByLabel.set(lk, idByLabel.has(lk) ? null : id); } + const sub = subRaw !== undefined ? cleanText(subRaw) : ''; + if (sub) { out.sub = sub; } + const shapeRaw = pick(n, ['shape', 'type', 'kind']); + if (shapeRaw !== undefined) { + const s = SHAPE_SYNONYMS[key(shapeRaw)]; + if (s) { if (s !== 'box') { out.shape = s; } if (s !== shapeRaw) { fix(at + '/shape', 'synonym', JSON.stringify(shapeRaw) + ' read as "' + s + '".'); } } + else { fix(at + '/shape', 'enum', JSON.stringify(shapeRaw) + ' is not a shape; drew a box.', { show: true }); } + } + const accentRaw = pick(n, ['accent', 'highlight', 'primary', 'emphasis']); + if (accentRaw !== undefined) { + if (asBool(accentRaw)) { out.accent = true; } + if (typeof accentRaw !== 'boolean') { fix(at + '/accent', 'coerced', 'accent read as ' + asBool(accentRaw) + '.'); } + } + const groupRaw = scalar(pick(n, ['group', 'parent', 'cluster', 'in'])); + if (groupRaw !== undefined && cleanText(groupRaw)) { out.group = groupRef(groupRaw); } + const link = normalizeLink(pick(n, ['link', 'file', 'href', 'path'])); + if (link) { out.link = link; } + keepTip(n, out); + dropped += Object.keys(n).filter((k) => NODE_KEYS.indexOf(k) < 0).length; + nodes.push(out); + }); + } else if (nodesIn !== undefined) { nodes = nodesIn; } + + // -- duplicate ids. The same node listed twice is one node. Two DIFFERENT nodes under one id + // is a question only the model can answer (which one do the edges mean?), so that is + // left for validate() to report — and settled by renaming only on the last rung. + if (Array.isArray(nodes)) { + const seen = new Map(); + const kept = []; + nodes.forEach((n, i) => { + if (!isObj(n) || typeof n.id !== 'string') { kept.push(n); return; } + if (!seen.has(n.id)) { seen.set(n.id, n); kept.push(n); return; } + if (JSON.stringify(seen.get(n.id)) === JSON.stringify(n)) { + fix('/nodes/' + i, 'duplicate-node', 'node "' + n.id + '" was listed twice; kept one.'); + return; + } + if (!allowLossy) { kept.push(n); return; } + let k = 2, id = n.id + '-' + k; + const taken = (x) => seen.has(x) || nodes.some((m) => isObj(m) && m !== n && m.id === x); + while (taken(id)) { id = n.id + '-' + (++k); } + fix('/nodes/' + i + '/id', 'duplicate-id', 'duplicate id "' + n.id + '" renamed to "' + id + '"; edges to "' + n.id + '" point at the first one.', { lossy: true }); + n.id = id; seen.set(id, n); kept.push(n); + }); + nodes = kept; + } + if (Array.isArray(groups)) { + const seen = new Set(); + groups = groups.filter((g, i) => { + if (!isObj(g) || typeof g.id !== 'string') { return true; } + if (!seen.has(g.id)) { seen.add(g.id); return true; } + const twin = groups.find((x) => isObj(x) && x.id === g.id); + if (JSON.stringify(twin) === JSON.stringify(g)) { fix('/groups/' + i, 'duplicate-node', 'group "' + g.id + '" was listed twice; kept one.'); return false; } + if (!allowLossy) { return true; } + fix('/groups/' + i, 'duplicate-id', 'duplicate group id "' + g.id + '"; kept the first.', { lossy: true }); + return false; + }); + } + const known = new Set((Array.isArray(nodes) ? nodes : []).filter((n) => isObj(n) && typeof n.id === 'string').map((n) => n.id)); + const knownList = Array.from(known); + + // -- edges + const nodeRef = (ref) => { + if (idOf.has(ref)) { return idOf.get(ref); } + if (idOf.has(key(ref))) { return idOf.get(key(ref)); } + const s = slugify(ref); + if (known.has(s)) { return s; } + // Models often point an edge at a node's LABEL. When exactly one node has that label, + // that is not a guess — it is a lookup. + const byLabel = idByLabel.get(cleanText(ref).toLowerCase()); + if (byLabel) { fix('/edges', 'ref-by-label', 'an edge named a node by its label (' + q(ref) + '); matched it to "' + byLabel + '".'); return byLabel; } + return s || String(ref); + }; + let edgesIn = raw.edges !== undefined ? raw.edges : pick(raw, ['links', 'connections', 'arrows']); + if (raw.edges === undefined && edgesIn !== undefined) { fix('/edges', 'renamed-field', 'used the "links"/"connections" field as the edges.'); } + let edges; + if (edgesIn === undefined || edgesIn === null) { edges = []; if (raw.edges === undefined) { fix('/edges', 'defaulted', 'no edges given; drew the nodes alone.'); } } + else if (Array.isArray(edgesIn)) { + edges = []; + const seenEdge = new Set(); + edgesIn.forEach((e, i) => { + const at = '/edges/' + i; + if (typeof e === 'string') { + const m = /^\s*(.+?)\s*(-{1,3}|={1,3}|\.{1,3}-?)>\s*(.+?)(?:\s*:\s*(.+))?$/.exec(e); + if (m) { e = { from: m[1], to: m[3], label: m[4], style: m[2][0] === '.' ? 'dashed' : undefined }; fix(at, 'coerced', 'edge written as "a -> b"; read it as from/to.'); } + } else if (Array.isArray(e) && e.length >= 2) { e = { from: e[0], to: e[1], label: e[2] }; fix(at, 'coerced', 'edge given as a list; read it as from/to.'); } + if (!isObj(e)) { edges.push(e); return; } + const out = {}; + const fromRaw = scalar(pick(e, ['from', 'source', 'src', 'start', 'a'])); + const toRaw = scalar(pick(e, ['to', 'target', 'dst', 'dest', 'end', 'b'])); + if (e.from === undefined && fromRaw !== undefined) { fix(at + '/from', 'renamed-field', 'used "source" as the from end.'); } + if (fromRaw !== undefined) { out.from = nodeRef(fromRaw); } + if (toRaw !== undefined) { out.to = nodeRef(toRaw); } + const label = scalar(pick(e, ['label', 'text', 'name', 'title'])); + if (label !== undefined && cleanText(label)) { out.label = cleanText(label); } + const styleRaw = pick(e, ['style', 'type', 'line', 'kind']); + if (styleRaw !== undefined) { + const s = STYLE_SYNONYMS[key(styleRaw)]; + if (s === 'dashed') { out.style = 'dashed'; } + if (!s) { fix(at + '/style', 'enum', JSON.stringify(styleRaw) + ' is not a line style; drew it solid.', { show: true }); } + else if (s !== styleRaw) { fix(at + '/style', 'synonym', JSON.stringify(styleRaw) + ' read as "' + s + '".'); } + } else if (e.dashed === true) { out.style = 'dashed'; } + keepTip(e, out); + dropped += Object.keys(e).filter((k) => EDGE_KEYS.indexOf(k) < 0).length; + // An edge to a node that does not exist. On the last rung it is dropped (and the banner + // says so); before that it stays, so validate() can hand the model the list of known ids. + const bad = ['from', 'to'].filter((end) => typeof out[end] === 'string' && !known.has(out[end])); + if (bad.length && known.size && allowLossy) { + fix(at, 'unknown-node', 'dropped: unknown node ' + bad.map((end) => q(end === 'from' ? fromRaw : toRaw)).join(' and ') + '. Known ids: ' + validator.listIds(knownList) + '.', { lossy: true }); + return; + } + const sig = out.from + '\u0000' + out.to + '\u0000' + (out.label || '') + '\u0000' + (out.style || ''); + if (out.from !== undefined && out.to !== undefined && seenEdge.has(sig)) { fix(at, 'duplicate-edge', 'the same edge was listed twice; kept one.'); return; } + seenEdge.add(sig); + edges.push(out); + }); + } else { edges = edgesIn; } + + // -- long text: the spec's "truncate long labels with a tooltip". Presentation, not meaning — + // the whole label is still there on hover and in the outline — so it never costs a model call. + const shorten = (obj, field, max, pointer) => { + if (!isObj(obj) || typeof obj[field] !== 'string' || count(obj[field]) <= max) { return null; } + const full = obj[field]; + obj[field] = truncate(full, max); + fix(pointer, 'length', count(full) + ' chars, max ' + max + ' — shortened to ' + q(obj[field]) + ' (the full text is kept as a tooltip).', { shortened: true, show: true }); + return full; + }; + const tip = (s) => truncate(s, HARD.tip); + const fullTitle = shorten(spec, 'title', HOUSE.title, '/title'); + if (fullTitle) { spec.tip = tip(fullTitle); } + if (Array.isArray(nodes)) { + nodes.forEach((n, i) => { + if (!isObj(n)) { return; } + const before = [n.label, n.sub]; + const a = shorten(n, 'label', HOUSE.label, '/nodes/' + i + '/label'); + const b = shorten(n, 'sub', HOUSE.sub, '/nodes/' + i + '/sub'); + if (a || b) { n.tip = tip([before[0], before[1]].filter(Boolean).join(' — ')); } + }); + } + if (Array.isArray(edges)) { edges.forEach((e, i) => { const f = shorten(e, 'label', HOUSE.edgeLabel, '/edges/' + i + '/label'); if (f) { e.tip = tip(f); } }); } + if (Array.isArray(groups)) { groups.forEach((g, i) => { const f = shorten(g, 'label', HOUSE.groupLabel, '/groups/' + i + '/label'); if (f) { g.tip = tip(f); } }); } + + // -- a group nothing lives in has no geometry; drop it rather than draw an empty frame + if (Array.isArray(groups) && Array.isArray(nodes)) { + const used = new Set(nodes.filter((n) => isObj(n) && typeof n.group === 'string').map((n) => n.group)); + let changed = true; + while (changed) { + changed = false; + for (const g of groups) { if (isObj(g) && used.has(g.id) && typeof g.parent === 'string' && !used.has(g.parent)) { used.add(g.parent); changed = true; } } + } + const declared = new Set(groups.filter(isObj).map((g) => g.id)); + const before = groups.length; + groups = groups.filter((g) => !isObj(g) || used.has(g.id) || (typeof g.parent === 'string' && !declared.has(g.parent))); + if (groups.length !== before) { fix('/groups', 'empty-group', (before - groups.length) + ' empty group(s) left out.'); } + } + + spec.nodes = nodes; + spec.edges = edges; + if (Array.isArray(groups) ? groups.length : groups !== undefined) { spec.groups = groups; } + const KNOWN_TOP = ['v', 'title', 'name', 'caption', 'heading', 'direction', 'dir', 'rankdir', 'orientation', 'flow', 'nodes', 'edges', 'links', 'connections', 'arrows', 'groups', 'tip']; + dropped += Object.keys(raw).filter((k) => KNOWN_TOP.indexOf(k) < 0).length; + if (dropped) { fix('', 'unknown-field', dropped + ' unrecognised field(s) ignored.'); } + return { spec, fixes }; + } + const NODE_KEYS = ['id', 'key', 'label', 'name', 'title', 'text', 'sub', 'subtitle', 'sublabel', 'description', 'desc', 'detail', 'note', 'shape', 'type', 'kind', 'accent', 'highlight', 'primary', 'emphasis', 'group', 'parent', 'cluster', 'in', 'link', 'file', 'href', 'path', 'tip']; + const EDGE_KEYS = ['from', 'source', 'src', 'start', 'a', 'to', 'target', 'dst', 'dest', 'end', 'b', 'label', 'text', 'name', 'title', 'style', 'type', 'line', 'kind', 'dashed', 'tip']; + + /** + * `link` in any of the ways a model writes one — an object, "src/app.js:42", "src/app.js#render". + * Only shapes it; WHETHER the path may be opened is decided by the host against the workspace. + */ + function normalizeLink(v) { + if (v === undefined || v === null) { return null; } + let p, symbol, line; + if (typeof v === 'string') { + const m = /^(.*?)(?::(\d+)(?::\d+)?|#(.+))?$/.exec(v.trim()); + p = m ? m[1] : v; line = m && m[2] ? Number(m[2]) : undefined; symbol = m && m[3] ? m[3] : undefined; + } else if (isObj(v)) { + p = scalar(pick(v, ['path', 'file', 'uri', 'href'])); + symbol = scalar(pick(v, ['symbol', 'name', 'function', 'fn'])); + const l = pick(v, ['line', 'lineNumber', 'row']); + line = (typeof l === 'number' || (typeof l === 'string' && /^\d+$/.test(l.trim()))) ? Number(l) : undefined; + } else { return null; } + p = cleanText(p == null ? '' : p).replace(/^file:\/\//i, ''); + if (!p) { return null; } + const out = { path: Array.from(p).slice(0, HOUSE.path).join('') }; + if (symbol !== undefined && cleanText(symbol)) { out.symbol = Array.from(cleanText(symbol)).slice(0, HOUSE.symbol).join(''); } + if (Number.isInteger(line) && line >= 1) { out.line = line; } + return out; + } + + // ---- rung 3: degrade ------------------------------------------------------------------------ + /** + * The repair pass is spent and the spec is still invalid. Cut it down to the part that IS valid, + * and say exactly what was cut — or return null when nothing drawable is left. + * + * Counts relax here and nowhere else: a 17-node diagram the model twice declined to split is + * drawn whole under a banner, because showing 12 of 17 would be a different diagram presented as + * the answer. The HARD ceiling still applies; past it, the tail really is dropped. + * @param {any} spec a normalize()d spec + * @returns {{ spec: any, notes: string[] } | null} + */ + function degrade(spec) { + if (!isObj(spec) || !Array.isArray(spec.nodes)) { return null; } + const notes = []; + const plural = (n, word) => n + ' ' + word + (n === 1 ? '' : 's'); + const out = { v: schema.VERSION, title: '', direction: schema.DIRECTIONS.indexOf(spec.direction) >= 0 ? spec.direction : 'right', nodes: [], edges: [] }; + if (typeof spec.title === 'string' && cleanText(spec.title)) { out.title = truncate(cleanText(spec.title), HOUSE.title); } + else { out.title = 'Untitled diagram'; notes.push('no title given'); } + if (typeof spec.tip === 'string') { out.tip = truncate(spec.tip, HARD.tip); } + // A spec from a NEWER editor than this one: draw what this schema understands, and say so. + if (spec.v !== undefined && !schema.SCHEMAS[spec.v]) { notes.push('written for schema v' + String(spec.v).slice(0, 8) + '; drawn as v' + schema.VERSION); } + + // nodes: keep the well-formed ones, up to the hard ceiling + const ids = new Set(); + let badNodes = 0; + for (const n of spec.nodes) { + if (!isObj(n) || typeof n.id !== 'string' || !schema.ID_RE.test(n.id) || ids.has(n.id) || typeof n.label !== 'string' || !n.label) { badNodes++; continue; } + if (out.nodes.length >= HARD.nodesMax) { badNodes++; continue; } + const m = { id: n.id.slice(0, HOUSE.id), label: truncate(n.label, HOUSE.label) }; + if (typeof n.sub === 'string' && n.sub) { m.sub = truncate(n.sub, HOUSE.sub); } + if (schema.SHAPES.indexOf(n.shape) >= 0 && n.shape !== 'box') { m.shape = n.shape; } + if (n.accent === true) { m.accent = true; } + if (typeof n.group === 'string') { m.group = n.group; } + if (isObj(n.link) && typeof n.link.path === 'string' && n.link.path) { + m.link = { path: n.link.path.slice(0, HOUSE.path) }; + if (typeof n.link.symbol === 'string' && n.link.symbol) { m.link.symbol = n.link.symbol.slice(0, HOUSE.symbol); } + if (Number.isInteger(n.link.line) && n.link.line >= 1) { m.link.line = n.link.line; } + } + if (typeof n.tip === 'string') { m.tip = truncate(n.tip, HARD.tip); } + ids.add(m.id); out.nodes.push(m); + } + if (!out.nodes.length) { return null; } + if (badNodes) { notes.push(plural(badNodes, 'node') + ' dropped: malformed or over the limit'); } + if (out.nodes.length > HOUSE.nodesMax) { notes.push(out.nodes.length + ' nodes — over the ' + HOUSE.nodesMax + '-node limit, drawn anyway'); } + + // one accent + const accented = out.nodes.filter((n) => n.accent); + if (accented.length > 1) { accented.slice(1).forEach((n) => { delete n.accent; }); notes.push(plural(accented.length - 1, 'extra accent') + ' removed'); } + + // groups: unknown parents and cycles lose their parent; anything deeper than the limit is lifted + const groups = []; + const gids = new Set(); + for (const g of (Array.isArray(spec.groups) ? spec.groups : [])) { + if (!isObj(g) || typeof g.id !== 'string' || !schema.ID_RE.test(g.id) || gids.has(g.id) || groups.length >= HARD.groupsMax) { continue; } + const m = { id: g.id.slice(0, HOUSE.id), label: truncate(typeof g.label === 'string' && g.label ? g.label : g.id, HOUSE.groupLabel) }; + if (typeof g.parent === 'string') { m.parent = g.parent; } + if (typeof g.tip === 'string') { m.tip = truncate(g.tip, HARD.tip); } + gids.add(m.id); groups.push(m); + } + let regrouped = 0; + for (const g of groups) { if (g.parent !== undefined && (!gids.has(g.parent) || g.parent === g.id)) { delete g.parent; regrouped++; } } + let depths = validator.groupDepths(groups); + for (const g of groups) { const d = depths.get(g.id); if (d && d.cycle && g.parent !== undefined) { delete g.parent; regrouped++; depths = validator.groupDepths(groups); } } + // too deep: fold the group into its parent (its nodes move up a level) until everything fits + const byId = new Map(groups.map((g) => [g.id, g])); + const folded = new Map(); + for (let guard = 0; guard < 32; guard++) { + depths = validator.groupDepths(groups.filter((g) => !folded.has(g.id))); + const deep = groups.find((g) => !folded.has(g.id) && (depths.get(g.id) || { depth: 1 }).depth > HOUSE.groupDepth); + if (!deep) { break; } + folded.set(deep.id, deep.parent); + for (const g of groups) { if (g.parent === deep.id) { g.parent = deep.parent; } } + } + const live = groups.filter((g) => !folded.has(g.id)); + const resolveGroup = (id) => { let cur = id, guard = 0; while (folded.has(cur) && guard++ < 32) { cur = folded.get(cur); } return cur; }; + let ungrouped = 0; + for (const n of out.nodes) { + if (n.group === undefined) { continue; } + const g = resolveGroup(n.group); + if (g !== undefined && byId.has(g) && !folded.has(g)) { n.group = g; } else { delete n.group; ungrouped++; } + } + if (folded.size) { notes.push(plural(folded.size, 'group') + ' flattened: nested too deep'); } + if (regrouped || ungrouped) { notes.push('group references that did not resolve were removed'); } + // groups left with nothing in them are not drawn + const used = new Set(out.nodes.map((n) => n.group).filter(Boolean)); + for (let changed = true; changed;) { changed = false; for (const g of live) { if (used.has(g.id) && g.parent && !used.has(g.parent)) { used.add(g.parent); changed = true; } } } + const keptGroups = live.filter((g) => used.has(g.id)); + if (keptGroups.length) { out.groups = keptGroups; } + + // edges: both ends must exist + let droppedEdges = 0; + for (const e of (Array.isArray(spec.edges) ? spec.edges : [])) { + if (!isObj(e) || !ids.has(e.from) || !ids.has(e.to) || out.edges.length >= HARD.edgesMax) { droppedEdges++; continue; } + const m = { from: e.from, to: e.to }; + if (typeof e.label === 'string' && e.label) { m.label = truncate(e.label, HOUSE.edgeLabel); } + if (e.style === 'dashed') { m.style = 'dashed'; } + if (typeof e.tip === 'string') { m.tip = truncate(e.tip, HARD.tip); } + out.edges.push(m); + } + if (droppedEdges) { notes.push(plural(droppedEdges, 'edge') + ' dropped: no valid ends'); } + return { spec: out, notes }; + } + + // ---- the ladder ----------------------------------------------------------------------------- + /** What the "auto-fixed" popover and the tool result list: the fixes a person might care about. */ + function visibleFixes(fixes) { return (fixes || []).filter((f) => f.show || f.lossy); } + const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many); + /** "2 edges dropped: unknown nodes" — what a degraded diagram's banner says it lost. */ + function lossNotes(fixes) { + const lossy = (fixes || []).filter((f) => f.lossy); + const edges = lossy.filter((f) => f.cls === 'unknown-node').length; + const ids = lossy.filter((f) => f.cls === 'duplicate-id').length; + const notes = []; + if (edges) { notes.push(plural(edges, 'edge', 'edges') + ' dropped: unknown nodes'); } + if (ids) { notes.push(plural(ids, 'duplicate id', 'duplicate ids') + ' renamed'); } + return notes; + } + /** "2 labels shortened" — the short form that sits beside the auto-fixed badge. */ + function fixSummary(fixes) { + const n = (fixes || []).filter((f) => f.shortened).length; + return n ? plural(n, 'label', 'labels') + ' shortened' : ''; + } + + /** + * Run the ladder for ONE render_diagram call. + * + * status ok valid as written (defaults aside) → draw it + * fixed rung 1 tidied something, meaning untouched → draw it, "auto-fixed" badge + * errors needs the model's one repair pass (only when !final) → return the error list + * degraded repair pass spent; the valid part is drawn → draw it, banner + Retry + * failed nothing drawable → source + errors + Retry + * truncated the JSON stops mid-structure → re-request, never repair + * + * @param {any} input the tool call's arguments (an object), or raw text + * @param {{ final?: boolean }} [opts] final: the model has had its repair pass (or there is no + * model to ask — a replay, a fence) so `errors` is not an option; degrade instead. + */ + function prepare(input, opts) { + const final = !!(opts && opts.final); + const n = normalize(input); + if (n.truncated) { return { status: 'truncated', fixes: n.fixes, errors: [{ pointer: '', cls: 'truncated', message: 'the spec was cut off before it was complete.' }], notes: [] }; } + if (n.syntax) { + const errors = [{ pointer: '', cls: 'syntax', message: 'not valid JSON (' + n.syntax + '). Send one JSON object with "title", "nodes" and "edges".' }]; + return { status: final ? 'failed' : 'errors', fixes: n.fixes, errors, notes: [] }; + } + const first = validator.validate(n.spec, { tier: 'house' }); + if (first.ok) { + const summary = fixSummary(n.fixes); + return { status: visibleFixes(n.fixes).length ? 'fixed' : 'ok', spec: n.spec, fixes: n.fixes, errors: [], notes: summary ? [summary] : [] }; + } + if (!final) { return { status: 'errors', fixes: n.fixes, errors: first.errors, notes: [] }; } + + // Rung 3. The same tidy-up again, this time allowed to drop and rename; then cut away + // whatever is still invalid. `first.errors` is kept: it is what the banner's details show. + const last = normalize(input, { lossy: true }); + const d = degrade(last.spec); + if (d) { + const again = validator.validate(d.spec, { tier: 'hard' }); + if (again.ok) { return { status: 'degraded', spec: d.spec, fixes: last.fixes, errors: first.errors, notes: lossNotes(last.fixes).concat(d.notes) }; } + } + return { status: 'failed', fixes: last.fixes, errors: first.errors, notes: [] }; + } + + /** + * Accept a spec the RENDERER was handed — from the host, or from a session written by an older + * build. It has already been through the ladder once, so this is the cheap re-check that makes + * "no unvalidated spec reaches the renderer" true at the last possible moment: migrate, validate + * against the HARD tier, and if (and only if) that fails, run the ladder with no model to ask. + * @returns {{ ok: boolean, spec?: any, notes?: string[], errors?: any[] }} + */ + function accept(spec) { + const m = validator.migrate(spec); + const r = validator.validate(m, { tier: 'hard' }); + if (r.ok) { + // Valid — but the validator checks the fields it knows and is silent about any others, and a + // record from a session file can carry anything. What is handed on is the declared shape only. + const declared = schema.project(schema.SCHEMAS[m.v].hard, m); + return { ok: true, spec: Object.assign({ v: m.v }, declared), notes: [] }; + } + const p = prepare(spec, { final: true }); + if (p.spec) { return { ok: true, spec: p.spec, notes: p.notes }; } + return { ok: false, errors: p.errors }; + } + + return { parseLenient, normalize, degrade, prepare, accept, truncate, slugify, cleanText, visibleFixes, lossNotes, fixSummary }; +})); diff --git a/extensions/levelcode-ai/diagram/schema.js b/extensions/levelcode-ai/diagram/schema.js new file mode 100644 index 0000000..bb263b1 --- /dev/null +++ b/extensions/levelcode-ai/diagram/schema.js @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · the Graph JSON schema (docs/RICH-DIAGRAMS.md, "Diagram spec") + * + * The model describes STRUCTURE only — nodes, edges, groups and one accent. It never sets + * coordinates, colours or font sizes, so it cannot violate the style guide; the renderer owns those. + * + * This file is the single definition of what a spec may contain. The SAME object is: + * • sent to the model as `render_diagram`'s input schema (diagram/tool.js), and + * • interpreted here to validate every spec that comes back (check()), + * so the contract the model is shown and the contract it is held to cannot drift apart. + * + * Two tiers of limits, deliberately: + * HOUSE — what the model is asked for (12 nodes, 28-char labels …). Breaking one is an error the + * repair ladder deals with. + * HARD — what the renderer will ever accept, even from a degraded spec. These exist because a + * spec is untrusted input: they bound layout time and DOM size no matter what arrives. + * + * Versioned: `v` names the schema a spec was written against, and SCHEMAS keeps every version this + * editor can still read, so a chat saved today renders after the schema moves on (NFR-4). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(); } + else { (root.LCDiagram = root.LCDiagram || {}).schema = factory(); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + /** The schema version new specs are written against. */ + const VERSION = 1; + + const SHAPES = ['box', 'decision', 'store', 'actor']; + const DIRECTIONS = ['right', 'down']; + const EDGE_STYLES = ['solid', 'dashed']; + /** "Lowercase slug": starts alphanumeric, then letters, digits, `_` or `-`. */ + const ID_RE = /^[a-z0-9][a-z0-9_-]*$/; + const ID_PATTERN = '^[a-z0-9][a-z0-9_-]*$'; + + /** What the model is asked for — the numbers in the spec's field tables. */ + const HOUSE = Object.freeze({ + title: 80, label: 28, sub: 32, edgeLabel: 20, groupLabel: 28, + nodesMin: 1, nodesMax: 12, groupDepth: 2, + // Not in the field tables, but a spec is untrusted and these have to be SOME number. Chosen + // so no diagram that respects the 12-node cap can run into them. + edgesMax: 30, groupsMax: 8, id: 32, path: 260, symbol: 80 + }); + /** + * What the renderer accepts at all. A degraded spec may exceed a HOUSE count (it is drawn anyway, + * under a banner) but never these; text limits do not relax, because truncation always applies. + */ + const HARD = Object.freeze(Object.assign({}, HOUSE, { nodesMax: 24, edgesMax: 48, groupsMax: 12, tip: 400 })); + + /** + * Build the JSON Schema for one tier. + * + * `house` is the wire schema — plain JSON Schema with nothing a provider could reject (no custom + * keywords, no `additionalProperties`: a model that adds a stray field is tidied, not failed). + * `v` is deliberately not in it: the version is checked by validate() before any schema is + * chosen, and a model writing a new spec never needs to state it. + * `hard` is the render-ready shape: the same fields under the HARD limits, plus `tip` — the full + * text of a label the auto-fixer had to truncate, which the renderer shows as a tooltip. `tip` is + * ours; the model is never told about it. + * @param {'house'|'hard'} tier + */ + function build(tier) { + const L = tier === 'hard' ? HARD : HOUSE; + const hard = tier === 'hard'; + const tip = hard ? { tip: { type: 'string', maxLength: HARD.tip } } : {}; + // Only a DECLARED id carries the slug rule. A reference (an edge end, a node's group, a group's + // parent) needs no rule of its own: the semantic layer requires it to equal a declared id, so it + // is a slug or it is an error either way — and every keyword here is paid for on each request. + const id = { type: 'string', maxLength: L.id, pattern: ID_PATTERN }; + const ref = { type: 'string' }; + return { + type: 'object', + properties: Object.assign({ + title: { type: 'string', minLength: 1, maxLength: L.title, description: 'The takeaway, not the topic.' }, + direction: { type: 'string', enum: DIRECTIONS, description: 'Default right.' }, + nodes: { + type: 'array', minItems: L.nodesMin, maxItems: L.nodesMax, + items: { + type: 'object', + properties: Object.assign({ + id: Object.assign({ description: 'Unique lowercase slug.' }, id), + label: { type: 'string', minLength: 1, maxLength: L.label }, + sub: { type: 'string', maxLength: L.sub, description: 'Second line.' }, + shape: { type: 'string', enum: SHAPES, description: 'box=step (default), decision=branch, store=data at rest, actor=person or outside system.' }, + accent: { type: 'boolean', description: 'At most one node.' }, + group: Object.assign({ description: 'Group id.' }, ref), + link: { + type: 'object', + description: 'Workspace file to open on click.', + properties: { + path: { type: 'string', minLength: 1, maxLength: L.path }, + symbol: { type: 'string', maxLength: L.symbol }, + line: { type: 'integer', minimum: 1 } + }, + required: ['path'] + } + }, tip), + required: ['id', 'label'] + } + }, + edges: { + type: 'array', maxItems: L.edgesMax, + items: { + type: 'object', + properties: Object.assign({ + from: ref, to: ref, + label: { type: 'string', maxLength: L.edgeLabel }, + style: { type: 'string', enum: EDGE_STYLES } + }, tip), + required: ['from', 'to'] + } + }, + groups: { + type: 'array', maxItems: L.groupsMax, + items: { + type: 'object', + properties: Object.assign({ + id: id, + label: { type: 'string', minLength: 1, maxLength: L.groupLabel }, + parent: Object.assign({ description: 'Enclosing group (depth 2 max).' }, ref) + }, tip), + required: ['id', 'label'] + } + } + }, tip), + required: ['title', 'nodes', 'edges'] + }; + } + + /** + * Every schema version this editor can read, each in both tiers. Adding v2 means adding a row + * here and a step in validate.migrate() — never editing v1, which stored chats still point at. + */ + const deepFreeze = (o) => { if (o && typeof o === 'object' && !Object.isFrozen(o)) { Object.freeze(o); for (const k of Object.keys(o)) { deepFreeze(o[k]); } } return o; }; + const SCHEMAS = deepFreeze({ + 1: { house: build('house'), hard: build('hard') } + }); + const KNOWN_VERSIONS = Object.keys(SCHEMAS).map(Number); + + // ---- a JSON-Schema-subset interpreter ------------------------------------------------------- + // Exactly the keywords build() uses, and no more: type, properties, required, items, enum, + // minLength/maxLength, minItems/maxItems, pattern, minimum. Ajv would do this too, but it would be + // the extension's first runtime dependency for ~80 lines of work — and its messages are not the + // ones the repair ladder needs. Each error names WHAT WAS EXPECTED and THE VALID OPTIONS, because + // that is what turns a model's repair into a lookup instead of a guess. + + /** JSON's own type names, with `integer` and `array` told apart from number/object. */ + function typeOf(v) { + if (v === null) { return 'null'; } + if (Array.isArray(v)) { return 'array'; } + if (typeof v === 'number') { return Number.isInteger(v) ? 'integer' : 'number'; } + return typeof v; + } + /** Escape one JSON Pointer segment (RFC 6901). */ + function seg(s) { return String(s).replace(/~/g, '~0').replace(/\//g, '~1'); } + + /** + * What a too-long / too-many error should tell the model to DO. Keyed by the tail of the + * pointer, so `/nodes/3/label` and `/nodes/7/label` share one hint. + */ + const HINTS = { + 'title': 'Say the takeaway in fewer words.', + 'label': 'Shorten or move detail to prose.', + 'sub': 'Shorten or move detail to prose.', + 'nodes': 'Draw an overview, then one diagram per sub-flow.', + 'edges': 'Keep the connections that carry the point.', + 'groups': 'Use fewer containers.' + }; + function hintFor(pointer) { + const tail = String(pointer).split('/').pop() || ''; + return HINTS[tail] ? ' ' + HINTS[tail] : ''; + } + const NOUN = { nodes: 'nodes', edges: 'edges', groups: 'groups' }; + + /** + * Check `value` against `schema`, appending every violation to `errors`. + * @param {any} schema + * @param {any} value + * @param {string} pointer JSON Pointer of `value` ('' for the root) + * @param {Array<{pointer:string, cls:string, message:string}>} errors + */ + function check(schema, value, pointer, errors) { + const got = typeOf(value); + const want = schema.type; + const typeOk = want === got || (want === 'number' && got === 'integer'); + if (!typeOk) { + errors.push({ pointer, cls: 'type', message: 'expected ' + (want === 'object' ? 'an object' : want === 'array' ? 'an array' : want === 'integer' ? 'an integer' : 'a ' + want) + ', got ' + got + '.' }); + return; // nothing below means anything on a value of the wrong type + } + if (schema.enum && schema.enum.indexOf(value) < 0) { + errors.push({ pointer, cls: pointer === '/v' ? 'version' : 'enum', message: JSON.stringify(value) + ' is not allowed. Use one of: ' + schema.enum.join(', ') + '.' }); + } + if (want === 'string') { + const n = Array.from(value).length; // code points — an emoji is one character, not two + if (schema.maxLength != null && n > schema.maxLength) { + errors.push({ pointer, cls: 'length', message: n + ' chars, max ' + schema.maxLength + '.' + hintFor(pointer) }); + } + if (schema.minLength != null && n < schema.minLength) { + errors.push({ pointer, cls: 'required', message: 'must not be empty.' }); + } else if (schema.pattern && n <= (schema.maxLength != null ? schema.maxLength : n) && !new RegExp(schema.pattern).test(value)) { + errors.push({ pointer, cls: 'pattern', message: JSON.stringify(value) + ' is not a lowercase slug (letters, digits, "-" or "_"; e.g. "auth-check").' }); + } + } + if ((want === 'integer' || want === 'number') && schema.minimum != null && value < schema.minimum) { + errors.push({ pointer, cls: 'range', message: value + ' is below the minimum ' + schema.minimum + '.' }); + } + if (want === 'array') { + const noun = NOUN[String(pointer).split('/').pop() || ''] || 'items'; + if (schema.maxItems != null && value.length > schema.maxItems) { + errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', max ' + schema.maxItems + '.' + hintFor(pointer) }); + } + if (schema.minItems != null && value.length < schema.minItems) { + errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', min ' + schema.minItems + '.' }); + } + if (schema.items) { + for (let i = 0; i < value.length; i++) { check(schema.items, value[i], pointer + '/' + i, errors); } + } + } + if (want === 'object') { + for (const key of (schema.required || [])) { + if (value[key] === undefined) { errors.push({ pointer: pointer + '/' + seg(key), cls: 'required', message: 'missing. This field is required.' }); } + } + const props = schema.properties || {}; + for (const key of Object.keys(props)) { + if (value[key] !== undefined) { check(props[key], value[key], pointer + '/' + seg(key), errors); } + } + } + } + + /** + * A copy of `value` holding only what `schema` declares, in the order the value had it. + * check() says whether the declared fields are right; it does not object to fields it has never + * heard of — a model that adds `color` should not be sent back for it. So after a spec passes, + * this is what makes "validated" mean "exactly this shape, and nothing that came along with it". + */ + function project(schema, value) { + if (schema.type === 'object' && typeOf(value) === 'object') { + const props = schema.properties || {}; + const out = {}; + for (const key of Object.keys(value)) { + if (Object.prototype.hasOwnProperty.call(props, key) && value[key] !== undefined) { out[key] = project(props[key], value[key]); } + } + return out; + } + if (schema.type === 'array' && Array.isArray(value)) { return value.map((v) => (schema.items ? project(schema.items, v) : v)); } + return value; + } + + return { VERSION, KNOWN_VERSIONS, SHAPES, DIRECTIONS, EDGE_STYLES, ID_RE, ID_PATTERN, HOUSE, HARD, SCHEMAS, build, check, project, typeOf, seg }; +})); diff --git a/extensions/levelcode-ai/diagram/validate.js b/extensions/levelcode-ai/diagram/validate.js new file mode 100644 index 0000000..1b45819 --- /dev/null +++ b/extensions/levelcode-ai/diagram/validate.js @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * LevelCode — AI · rich diagrams · layered validation (docs/RICH-DIAGRAMS.md, "Validation and repair") + * + * THE ONE GATE. Every spec — whichever model wrote it, whether it arrived a second ago or was read + * back from a session saved last month — passes through validate() before anything lays it out. + * That is what keeps the quality of a diagram independent of the model that asked for it. + * + * Layers, cheapest first: + * version `v` names a schema this editor still reads (migrate() lifts old ones forward) + * schema fields, types, enums, lengths, counts — schema.check(), driven by the wire schema + * semantics the things a schema cannot say: edge ends exist, ids are unique, one accent at most, + * groups nest two deep and never in a circle + * + * EVERY error comes back at once, each as : . + * A model handed "unknown node" has to guess; handed the list of known ids it only has to look. + * + * Pure, synchronous, dependency-free — it runs in the extension host (the tool result) and again in + * the webview (the renderer refuses anything this rejects). + *--------------------------------------------------------------------------------------------*/ +// @ts-check +(function (root, factory) { + 'use strict'; + if (typeof module === 'object' && module.exports) { module.exports = factory(require('./schema')); } + else { (root.LCDiagram = root.LCDiagram || {}).validate = factory(root.LCDiagram.schema); } +}(typeof globalThis !== 'undefined' ? globalThis : this, function (schema) { + 'use strict'; + + const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v); + const q = (s) => JSON.stringify(String(s)); + /** "a, b, c" — capped, so one error line can never carry a whole oversized spec back to the model. */ + function listIds(ids, max) { + const cap = max || 16; + const shown = ids.slice(0, cap).join(', '); + return ids.length > cap ? shown + ', … (' + (ids.length - cap) + ' more)' : (shown || '(none)'); + } + + /** + * Lift a spec written against an older schema to the current one. Today there is only v1, so + * this is the identity — but it is the single place a future v2 teaches the editor to keep + * reading v1, and render paths call it so that promise is exercised from day one. + * A spec with no `v` at all is read as v1 (the tool marks the field optional). + */ + function migrate(spec) { + if (!isObj(spec)) { return spec; } + if (spec.v === undefined) { return Object.assign({ v: 1 }, spec); } + return spec; + } + + /** + * The group each group id sits in, walking `parent` links. Returns { depth, cycle } per id. + * Depth 1 = a top-level group. A cycle is reported once, on every member of it. + */ + function groupDepths(groups) { + const byId = new Map(); + for (const g of groups) { if (isObj(g) && typeof g.id === 'string' && !byId.has(g.id)) { byId.set(g.id, g); } } + const out = new Map(); + for (const id of byId.keys()) { + let depth = 1, cur = byId.get(id), cycle = false; + const seen = new Set([id]); + while (cur && typeof cur.parent === 'string' && byId.has(cur.parent)) { + if (seen.has(cur.parent)) { cycle = true; break; } + seen.add(cur.parent); + cur = byId.get(cur.parent); + depth++; + } + out.set(id, { depth, cycle }); + } + return out; + } + + /** The semantic layer. Assumes nothing about shape — it skips whatever the schema layer already flagged. */ + function checkSemantics(spec, errors, limits) { + const nodes = Array.isArray(spec.nodes) ? spec.nodes : []; + const edges = Array.isArray(spec.edges) ? spec.edges : []; + const groups = Array.isArray(spec.groups) ? spec.groups : []; + + // ids are unique + const firstAt = new Map(); + const ids = []; + nodes.forEach((n, i) => { + if (!isObj(n) || typeof n.id !== 'string') { return; } + if (firstAt.has(n.id)) { + errors.push({ pointer: '/nodes/' + i + '/id', cls: 'duplicate-id', message: 'duplicate id ' + q(n.id) + ' (already used by /nodes/' + firstAt.get(n.id) + '). Every node needs its own id.' }); + } else { firstAt.set(n.id, i); ids.push(n.id); } + }); + + // one accent at most + const accented = nodes.filter((n) => isObj(n) && n.accent === true).map((n) => String(n.id)); + if (accented.length > 1) { + errors.push({ pointer: '/nodes', cls: 'accent-count', message: accented.length + ' nodes have accent=true, max 1 (' + listIds(accented) + '). Keep it on the node the title is about.' }); + } + + // groups: unique ids, parents exist, no cycles, depth within the limit + const groupAt = new Map(); + const groupIds = []; + groups.forEach((g, i) => { + if (!isObj(g) || typeof g.id !== 'string') { return; } + if (groupAt.has(g.id)) { + errors.push({ pointer: '/groups/' + i + '/id', cls: 'duplicate-id', message: 'duplicate group id ' + q(g.id) + ' (already used by /groups/' + groupAt.get(g.id) + ').' }); + } else { groupAt.set(g.id, i); groupIds.push(g.id); } + }); + const depths = groupDepths(groups); + groups.forEach((g, i) => { + if (!isObj(g) || typeof g.id !== 'string' || groupAt.get(g.id) !== i) { return; } + if (typeof g.parent === 'string') { + if (g.parent === g.id) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-cycle', message: 'a group cannot contain itself. Remove "parent" or name another group.' }); + return; + } + if (!groupAt.has(g.parent)) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'unknown-group', message: 'unknown group ' + q(g.parent) + '. Known groups: ' + listIds(groupIds) + '.' }); + return; + } + } + const d = depths.get(g.id); + if (d && d.cycle) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-cycle', message: 'groups contain each other in a circle (' + q(g.id) + ' → ' + q(g.parent) + ' → …). Nesting must be a tree.' }); + } else if (d && d.depth > limits.groupDepth) { + errors.push({ pointer: '/groups/' + i + '/parent', cls: 'group-depth', message: 'nested ' + d.depth + ' deep, max ' + limits.groupDepth + '. Flatten it.' }); + } + }); + nodes.forEach((n, i) => { + if (isObj(n) && typeof n.group === 'string' && !groupAt.has(n.group)) { + errors.push({ pointer: '/nodes/' + i + '/group', cls: 'unknown-group', message: 'unknown group ' + q(n.group) + '. Known groups: ' + listIds(groupIds) + '.' }); + } + }); + + // each end of an edge names an existing node + edges.forEach((e, i) => { + if (!isObj(e)) { return; } + for (const end of ['from', 'to']) { + if (typeof e[end] === 'string' && !firstAt.has(e[end])) { + errors.push({ pointer: '/edges/' + i + '/' + end, cls: 'unknown-node', message: 'unknown node ' + q(e[end]) + '. Known ids: ' + listIds(ids) + '.' }); + } + } + }); + } + + /** + * Validate a parsed spec. + * @param {any} spec + * @param {{ tier?: 'house'|'hard' }} [opts] `house` (default) is what the model is held to; `hard` + * is what the renderer accepts — see schema.js. + * @returns {{ ok: boolean, errors: Array<{pointer:string, cls:string, message:string}> }} + */ + function validate(spec, opts) { + const tier = opts && opts.tier === 'hard' ? 'hard' : 'house'; + /** @type {Array<{pointer:string, cls:string, message:string}>} */ + const errors = []; + if (!isObj(spec)) { + errors.push({ pointer: '', cls: 'type', message: 'expected an object with "title", "nodes" and "edges", got ' + schema.typeOf(spec) + '.' }); + return { ok: false, errors }; + } + const v = spec.v === undefined ? schema.VERSION : spec.v; + const known = schema.SCHEMAS[v]; + if (!known) { + errors.push({ pointer: '/v', cls: 'version', message: 'unknown schema version ' + JSON.stringify(spec.v) + '. This editor reads: ' + schema.KNOWN_VERSIONS.join(', ') + '.' }); + return { ok: false, errors }; + } + schema.check(known[tier], spec, '', errors); + checkSemantics(spec, errors, tier === 'hard' ? schema.HARD : schema.HOUSE); + return { ok: errors.length === 0, errors }; + } + + /** One error as the line the model (and the details popover) reads. */ + function formatError(e) { return (e.pointer || '/') + ': ' + e.message; } + /** Every error, one per line — the spec's "Error format". */ + function formatErrors(errors) { return (errors || []).map(formatError).join('\n'); } + /** Error classes with counts — what telemetry records (classes, never the text). */ + function errorClasses(errors) { + const out = {}; + for (const e of (errors || [])) { out[e.cls] = (out[e.cls] || 0) + 1; } + return out; + } + + return { validate, migrate, groupDepths, formatError, formatErrors, errorClasses, listIds }; +})); diff --git a/extensions/levelcode-ai/test/diagramRepair.test.js b/extensions/levelcode-ai/test/diagramRepair.test.js new file mode 100644 index 0000000..e1cf867 --- /dev/null +++ b/extensions/levelcode-ai/test/diagramRepair.test.js @@ -0,0 +1,361 @@ +/*--------------------------------------------------------------------------------------------- + * Rich diagrams — the repair ladder — run: node test/diagramRepair.test.js + * + * docs/RICH-DIAGRAMS.md, "Validation and repair". Two things are tested here: + * + * 1. THE GOLDEN CORPUS (test/fixtures/diagrams/corpus.json): every recorded way a model gets a + * spec wrong, with the outcome the ladder must reach — the exact errors returned to the model, + * the fixes shown to the user, and what is drawn once the one repair pass is spent. A change to + * the validator or the auto-fixer must keep every case green. + * + * 2. THE RULES THAT ORDER THE RUNGS, as properties of prepare() itself: a deterministic fix never + * costs a model call; a fix that changes what the diagram SAYS is never applied before the + * model's one pass; nothing loops; truncated output is re-requested, never repaired. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const R = require('../diagram/repair'); +const V = require('../diagram/validate'); +const schema = require('../diagram/schema'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +const corpus = JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures', 'diagrams', 'corpus.json'), 'utf8')); +const jev = () => JSON.parse(JSON.stringify(corpus.find((c) => c.name === 'spec-example').input)); + +/** What the fixture records about one attempt, recomputed from the live code. */ +function outcome(input) { + const a = R.prepare(input), b = R.prepare(input, { final: true }); + const first = { status: a.status }; + if (a.errors.length) { first.errors = V.formatErrors(a.errors).split('\n'); } + const shown = R.visibleFixes(a.fixes).map((f) => (f.pointer || '/') + ': ' + f.message); + if (shown.length) { first.shown = shown; } + const tidied = Array.from(new Set(a.fixes.filter((f) => !f.show && !f.lossy).map((f) => f.cls))).sort(); + if (tidied.length) { first.tidied = tidied; } + const final = { status: b.status }; + if (b.notes.length) { final.notes = b.notes; } + if (b.spec) { final.drawn = { nodes: b.spec.nodes.length, edges: b.spec.edges.length, groups: (b.spec.groups || []).length }; } + return { first, final, a, b }; +} + +test('CORPUS: it exists, every case says why it is there, and names are unique', () => { + assert.ok(corpus.length >= 30, 'a corpus of ' + corpus.length + ' is not a corpus'); + const names = new Set(); + for (const c of corpus) { + assert.ok(c.name && !names.has(c.name), 'duplicate or missing name: ' + c.name); + names.add(c.name); + assert.ok(typeof c.why === 'string' && c.why.length > 15, c.name + ' needs a `why`'); + assert.ok(c.first && c.final, c.name + ' records both attempts'); + } +}); + +for (const c of corpus) { + test('CORPUS · ' + c.name + ' — ' + c.first.status + ' → ' + c.final.status, () => { + const o = outcome(c.input); + assert.deepStrictEqual(o.first, c.first, 'first attempt'); + assert.deepStrictEqual(o.final, c.final, 'after the repair pass'); + }); +} + +test('LADDER: whatever prepare() hands the renderer is valid — every corpus case, both attempts', () => { + for (const c of corpus) { + const { a, b } = outcome(c.input); + for (const r of [a, b]) { + if (r.status === 'ok' || r.status === 'fixed') { assert.deepStrictEqual(V.validate(r.spec), { ok: true, errors: [] }, c.name + ': ' + r.status + ' must meet the HOUSE limits'); } + else if (r.status === 'degraded') { assert.deepStrictEqual(V.validate(r.spec, { tier: 'hard' }), { ok: true, errors: [] }, c.name + ': degraded must meet the HARD limits'); } + else { assert.strictEqual(r.spec, undefined, c.name + ': ' + r.status + ' carries no spec to draw'); } + } + } +}); + +test('LADDER: a fix that changes MEANING is never applied before the model has had its pass', () => { + for (const c of corpus) { + const { a, b } = outcome(c.input); + assert.ok(!a.fixes.some((f) => f.lossy), c.name + ': a lossy fix on the first attempt'); + if (a.status === 'ok' || a.status === 'fixed') { + // what is drawn has exactly as many nodes and edges as when nothing lossy is allowed + assert.strictEqual(a.spec.nodes.length, b.spec.nodes.length, c.name); + assert.strictEqual(a.spec.edges.length, b.spec.edges.length, c.name); + assert.deepStrictEqual(a.spec, b.spec, c.name + ': `final` changes nothing when the first attempt was fine'); + } + if (b.status === 'degraded') { assert.ok(b.notes.length > 0, c.name + ': a degraded diagram always says what it lost'); } + } +}); + +test('LADDER: the first attempt never degrades, and the final attempt never asks again', () => { + for (const c of corpus) { + const { a, b } = outcome(c.input); + assert.ok(['ok', 'fixed', 'errors', 'truncated'].includes(a.status), c.name + ': first=' + a.status); + assert.ok(['ok', 'fixed', 'degraded', 'failed', 'truncated'].includes(b.status), c.name + ': final=' + b.status + ' — "no automatic second repair"'); + if (a.status === 'errors') { assert.ok(a.errors.length > 0, c.name + ': `errors` with no errors would be a blank result'); } + } +}); + +test('LADDER: truncated output is re-requested, never repaired — even on the final attempt', () => { + for (const cut of ['{"title":"T","nodes":[{"id":"a","label":"A"},{"id":"b","la', '{"title":"T","nodes":[', '{"title":"T","nodes":[{"id":"a","label":"A"}],"edges":[{"from":"a"', '{"title": "T" /* comment never closed']) { + for (const final of [false, true]) { + const r = R.prepare(cut, { final }); + assert.strictEqual(r.status, 'truncated', JSON.stringify(cut)); + assert.strictEqual(r.spec, undefined, 'nothing is drawn from a guess'); + } + } + // ...but a complete object followed by chatter is complete + assert.strictEqual(R.prepare('{"title":"T","nodes":[{"id":"a","label":"A"}],"edges":[]} Hope that helps!').status, 'ok'); +}); + +test('LADDER: it is idempotent — a prepared spec passes again untouched, with nothing to fix', () => { + for (const c of corpus) { + const { b } = outcome(c.input); + if (!b.spec) { continue; } + const again = R.prepare(b.spec, { final: true }); + // a degraded spec may exceed the HOUSE counts by design; everything else must come back clean + if (b.status !== 'degraded') { assert.strictEqual(again.status, 'ok', c.name + ': ' + JSON.stringify(again.errors)); } + assert.deepStrictEqual(R.visibleFixes(again.fixes), [], c.name + ': a second pass found something to fix'); + assert.deepStrictEqual(again.spec, b.spec, c.name + ': a second pass changed the spec'); + } +}); + +test('FIXED vs DEGRADED: shortening a label keeps the whole text, as a tooltip', () => { + const r = R.prepare({ title: 'T', nodes: [{ id: 'a', label: 'Validate the incoming request payload', sub: 'and then hand the whole thing to the next stage' }], edges: [] }); + assert.strictEqual(r.status, 'fixed'); + assert.strictEqual(r.spec.nodes[0].label, 'Validate the incoming…'); + assert.ok(Array.from(r.spec.nodes[0].label).length <= schema.HOUSE.label); + assert.ok(Array.from(r.spec.nodes[0].sub).length <= schema.HOUSE.sub); + assert.strictEqual(r.spec.nodes[0].tip, 'Validate the incoming request payload — and then hand the whole thing to the next stage'); + assert.deepStrictEqual(r.notes, ['2 labels shortened']); + assert.ok(r.fixes.every((f) => !f.lossy), 'nothing was lost, so nothing is lossy'); +}); + +test('FIXED vs DEGRADED: an edge to nowhere goes to the model first; only then is it dropped, loudly', () => { + const spec = { title: 'T', nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ from: 'a', to: 'b' }, { from: 'a', to: 'ghost' }] }; + const first = R.prepare(spec); + assert.strictEqual(first.status, 'errors'); + assert.deepStrictEqual(V.formatErrors(first.errors), '/edges/1/to: unknown node "ghost". Known ids: a, b.'); + const last = R.prepare(spec, { final: true }); + assert.strictEqual(last.status, 'degraded'); + assert.deepStrictEqual(last.spec.edges, [{ from: 'a', to: 'b' }]); + assert.deepStrictEqual(last.notes, ['1 edge dropped: unknown nodes']); +}); + +test('TIDY: an edge that names a node by its label is a lookup, not a guess — unless two nodes share it', () => { + const one = R.prepare({ title: 'T', nodes: [{ id: 'n1', label: 'Load balancer' }, { id: 'n2', label: 'API' }], edges: [{ from: 'load balancer', to: 'API' }] }); + assert.strictEqual(one.status, 'ok'); + assert.deepStrictEqual(one.spec.edges, [{ from: 'n1', to: 'n2' }]); + const two = R.prepare({ title: 'T', nodes: [{ id: 'n1', label: 'API' }, { id: 'n2', label: 'API' }, { id: 'n3', label: 'DB' }], edges: [{ from: 'API', to: 'DB' }] }); + assert.strictEqual(two.status, 'errors', 'two nodes are called "API": which one is a question for the model'); + assert.match(V.formatErrors(two.errors), /unknown node "api"\. Known ids: n1, n2, n3\./); +}); + +test('TIDY: ids are slugged, and every edge follows its node to the new id', () => { + const r = R.prepare({ title: 'T', nodes: [{ id: 'Auth Service', label: 'Auth' }, { id: 'Müller & Söhne', label: 'M' }, { id: ' ', label: 'Blank id' }], edges: [{ from: 'Auth Service', to: 'Müller & Söhne' }, { from: 'auth service', to: 'Blank id' }] }); + assert.strictEqual(r.status, 'ok', JSON.stringify(r.errors)); + assert.deepStrictEqual(r.spec.nodes.map((x) => x.id), ['auth-service', 'muller-sohne', 'blank-id']); + assert.deepStrictEqual(r.spec.edges, [{ from: 'auth-service', to: 'muller-sohne' }, { from: 'auth-service', to: 'blank-id' }]); + for (const x of r.spec.nodes) { assert.match(x.id, schema.ID_RE); } +}); + +test('TIDY: defaults are not fixes — the plain example comes back byte-for-byte, with none recorded', () => { + const r = R.prepare(jev()); + assert.strictEqual(r.status, 'ok'); + assert.deepStrictEqual(r.fixes, []); + assert.strictEqual(JSON.stringify(r.spec), JSON.stringify(jev())); + const bare = R.prepare({ title: 'T', nodes: [{ id: 'a', label: 'A' }] }); + assert.strictEqual(bare.status, 'ok'); + assert.deepStrictEqual(bare.spec, { v: 1, title: 'T', direction: 'right', nodes: [{ id: 'a', label: 'A' }], edges: [] }); +}); + +test('TIDY: prepare() never mutates what it was given', () => { + for (const c of corpus) { + if (typeof c.input !== 'object') { continue; } + const before = JSON.stringify(c.input); + R.prepare(c.input); R.prepare(c.input, { final: true }); + assert.strictEqual(JSON.stringify(c.input), before, c.name); + } +}); + +test('DEGRADE: counts relax up to the hard ceiling and no further; what is cut is counted', () => { + const many = (k) => ({ title: 'T', nodes: Array.from({ length: k }, (_, i) => ({ id: 'n' + i, label: 'L' + i })), edges: Array.from({ length: k - 1 }, (_, i) => ({ from: 'n' + i, to: 'n' + (i + 1) })) }); + const d14 = R.prepare(many(14), { final: true }); + assert.strictEqual(d14.status, 'degraded'); + assert.strictEqual(d14.spec.nodes.length, 14, 'showing 12 of 14 would be a different diagram presented as the answer'); + assert.deepStrictEqual(d14.notes, ['14 nodes — over the 12-node limit, drawn anyway']); + const d40 = R.prepare(many(40), { final: true }); + assert.strictEqual(d40.spec.nodes.length, schema.HARD.nodesMax); + assert.ok(d40.spec.edges.every((e) => d40.spec.nodes.some((x) => x.id === e.from) && d40.spec.nodes.some((x) => x.id === e.to)), 'no edge is left pointing at a dropped node'); + assert.match(d40.notes.join(' | '), /16 nodes dropped/); +}); + +test('DEGRADE: one accent survives — the first; groups past depth two fold into their parent', () => { + const acc = R.prepare({ title: 'T', nodes: [{ id: 'a', label: 'A', accent: true }, { id: 'b', label: 'B', accent: true }, { id: 'c', label: 'C', accent: true }], edges: [] }, { final: true }); + assert.deepStrictEqual(acc.spec.nodes.filter((x) => x.accent).map((x) => x.id), ['a']); + assert.deepStrictEqual(acc.notes, ['2 extra accents removed']); + const deep = R.prepare({ title: 'T', groups: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B', parent: 'a' }, { id: 'c', label: 'C', parent: 'b' }, { id: 'd', label: 'D', parent: 'c' }], nodes: [{ id: 'x', label: 'X', group: 'd' }, { id: 'y', label: 'Y', group: 'c' }, { id: 'z', label: 'Z', group: 'b' }], edges: [] }, { final: true }); + assert.strictEqual(deep.status, 'degraded'); + assert.deepStrictEqual(deep.spec.groups.map((g) => g.id), ['a', 'b']); + assert.deepStrictEqual(deep.spec.nodes.map((x) => x.group), ['b', 'b', 'b'], 'the nodes move up to the deepest group that is still allowed'); +}); + +test('DEGRADE: whatever part is well-formed is drawn — one good node among junk is a diagram', () => { + const r = R.prepare({ title: 'T', nodes: [{}, { id: 7 }, null, { id: 'ok', label: 'Fine' }], edges: [{ from: 'ok', to: '7' }, { from: 'ok' }] }, { final: true }); + assert.strictEqual(r.status, 'degraded'); + assert.deepStrictEqual(r.spec.nodes, [{ id: '7', label: '7' }, { id: 'ok', label: 'Fine' }], 'a node with only an id is labelled by it'); + assert.deepStrictEqual(r.spec.edges, [{ from: 'ok', to: '7' }]); + assert.match(r.notes.join(' | '), /2 nodes dropped/); +}); + +test('DEGRADE: nothing drawable is `failed` — never an empty picture', () => { + for (const bad of [{ title: 'T', nodes: [], edges: [] }, { title: 'T', nodes: [{}, { sub: 'no name' }, null, []], edges: [] }, { title: 'T' }, 'prose', 42, null, [], { nodes: 'a,b' }]) { + const r = R.prepare(bad, { final: true }); + assert.strictEqual(r.status, 'failed', JSON.stringify(bad)); + assert.ok(r.errors.length > 0, 'failed, with the reason'); + assert.strictEqual(r.spec, undefined); + } +}); + +test('ACCEPT: the renderer\'s last-moment check — valid passes, old versions migrate, broken is cut down or refused', () => { + assert.deepStrictEqual(R.accept(jev()), { ok: true, spec: jev(), notes: [] }); + const noV = jev(); delete noV.v; + assert.strictEqual(R.accept(noV).spec.v, 1); + const broken = jev(); broken.edges.push({ from: 'jev', to: 'ghost' }); + const cut = R.accept(broken); + assert.strictEqual(cut.ok, true); + assert.strictEqual(cut.spec.edges.length, 3); + assert.deepStrictEqual(cut.notes, ['1 edge dropped: unknown nodes']); + assert.strictEqual(R.accept({ title: 'T', nodes: [] }).ok, false); + assert.strictEqual(R.accept(null).ok, false); + // a degraded spec (over the house count) is accepted as-is: it already went through the ladder + const big = { v: 1, title: 'T', direction: 'right', nodes: Array.from({ length: 15 }, (_, i) => ({ id: 'n' + i, label: 'L' })), edges: [] }; + assert.deepStrictEqual(R.accept(big), { ok: true, spec: big, notes: [] }); +}); + +test('ACCEPT: what is handed to the renderer is the declared shape and nothing that came along with it', () => { + // a record from a session file can carry anything; the validator is silent about fields it does not know + const stored = jev(); + stored.script = 'alert(1)'; stored.onload = 'x()'; stored.nodes[0].onclick = 'alert(2)'; stored.nodes[0].style = 'position:fixed'; + stored.nodes[1].link = { path: 'agent.js', symbol: 'runAgent', href: 'javascript:alert(3)', command: 'rm -rf' }; + stored.edges[0].href = 'https://example.invalid/'; stored.nodes[0].tip = 'the full label'; + const a = R.accept(stored); + assert.strictEqual(a.ok, true); + assert.deepStrictEqual(a.notes, [], 'nothing was lost that a picture shows, so there is nothing to tell the user'); + const want = jev(); want.nodes[0].tip = 'the full label'; want.nodes[1].link = { path: 'agent.js', symbol: 'runAgent' }; + assert.deepStrictEqual(a.spec, want); + assert.ok(!/alert|javascript:|rm -rf|example\.invalid|position:fixed/.test(JSON.stringify(a.spec))); + assert.strictEqual(stored.script, 'alert(1)', 'the input is not edited in place'); + // a clean spec comes back equal, field for field and in the order it was stored — Copy source does not reshuffle + const clean = R.prepare(jev()).spec; + assert.strictEqual(JSON.stringify(R.accept(clean).spec), JSON.stringify(clean)); + // and accepting twice is accepting once + assert.deepStrictEqual(R.accept(a.spec).spec, a.spec); + // the version is kept: it is how an old chat is told from a new one + assert.strictEqual(a.spec.v, 1); + assert.strictEqual(Object.keys(a.spec)[0], 'v'); + // names every object already answers to are not "declared" just because a lookup finds them + const sly = JSON.parse('{"v":1,"title":"T","toString":"x","hasOwnProperty":"y","constructor":"z","__proto__":{"polluted":1},"nodes":[{"id":"a","label":"A","constructor":"z","valueOf":"w","__proto__":{"polluted":2}}],"edges":[]}'); + const got = R.accept(sly); + assert.strictEqual(got.ok, true); + assert.deepStrictEqual([Object.keys(got.spec), Object.keys(got.spec.nodes[0])], [['v', 'title', 'nodes', 'edges'], ['id', 'label']]); + assert.strictEqual(got.spec.polluted, undefined); assert.strictEqual(got.spec.nodes[0].polluted, undefined); + assert.strictEqual(Object.getPrototypeOf(got.spec), Object.prototype); assert.strictEqual(Object.getPrototypeOf(got.spec.nodes[0]), Object.prototype); + assert.strictEqual(({}).polluted, undefined); + assert.strictEqual(typeof got.spec.toString, 'function', 'and the real ones are still the real ones'); +}); + +test('LENIENT JSON: what is inside a string is left alone', () => { + const src = '{"title": "a } b // not a comment, /* nor this */ and a trailing , ]", "nodes": [{"id": "a", "label": "it\'s \\"quoted\\" \\\\ here"}], "edges": [],}'; + const p = R.parseLenient(src); + assert.strictEqual(p.ok, true, p.error); + assert.strictEqual(p.value.title, 'a } b // not a comment, /* nor this */ and a trailing , ]'); + assert.strictEqual(p.value.nodes[0].label, 'it\'s "quoted" \\ here'); +}); + +test('LENIENT JSON: strict JSON takes the strict path; numbers, nesting and unicode survive the tolerant one', () => { + assert.deepStrictEqual(R.parseLenient('{"a":[1,2.5,-3e2,true,null,{"b":"é🙂"}]}'), { ok: true, value: { a: [1, 2.5, -300, true, null, { b: 'é🙂' }] }, lenient: false }); + const p = R.parseLenient("{a: [1, 2.5, -3e2, True, None, {b: 'é🙂'},],}"); + assert.deepStrictEqual(p, { ok: true, value: { a: [1, 2.5, -300, true, null, { b: 'é🙂' }] }, lenient: true }); + assert.strictEqual(R.parseLenient('').ok, false); + assert.strictEqual(R.parseLenient('no braces here').ok, false); + assert.strictEqual(R.parseLenient('{"a": }').ok, false, 'garbage is not guessed at'); + assert.strictEqual(R.parseLenient('{"a": }').truncated, undefined, 'and it is not mistaken for truncation'); +}); + +test('TEXT: truncate() cuts at a word when one is near, counts characters, and never exceeds the limit', () => { + assert.strictEqual(R.truncate('Validate the incoming request payload', 28), 'Validate the incoming…'); + assert.strictEqual(R.truncate('short', 28), 'short'); + assert.strictEqual(R.truncate('Supercalifragilisticexpialidocious', 12), 'Supercalifr…'); + assert.strictEqual(Array.from(R.truncate('🙂'.repeat(40), 10)).length, 10, 'an emoji is one character and is never split'); + for (let max = 2; max < 40; max++) { assert.ok(Array.from(R.truncate('The quick brown fox jumps over the lazy dog, twice.', max)).length <= max); } +}); + +test('TEXT: control characters, zero-width characters and bidi overrides never reach a label', () => { + const r = R.prepare({ title: 'a\u0000b\u0007c', nodes: [{ id: 'a', label: 'invoice‮fdp.exe', sub: 'zero​width and\ttab\nnewline' }], edges: [] }); + assert.strictEqual(r.spec.title, 'abc'); + assert.strictEqual(r.spec.nodes[0].label, 'invoicefdp.exe', 'the right-to-left override is gone, so the label reads as what it is'); + assert.strictEqual(r.spec.nodes[0].sub, 'zerowidth and tab newline'); + // eslint-disable-next-line no-control-regex + assert.ok(!/[\u0000-\u001f​-‏‪-‮⁦-⁩]/.test(JSON.stringify(r.spec).replace(/\\u/g, ''))); +}); + +test('TEXT: markup in a label is TEXT — kept exactly, never interpreted (the painter escapes it)', () => { + const r = R.prepare({ title: '', nodes: [{ id: 'a', label: 'hi' }], edges: [] }); + assert.strictEqual(r.spec.title, ''); + assert.strictEqual(r.spec.nodes[0].label, 'hi'); +}); + +test('LINKS: shaped here, never trusted here — the host decides what may be opened', () => { + const r = R.prepare({ title: 'T', nodes: [ + { id: 'a', label: 'A', link: { path: 'src/agent.js', symbol: 'runAgent', line: '12' } }, + { id: 'b', label: 'B', link: 'src/agent.js:890' }, + { id: 'c', label: 'C', link: 'src/agent.js#runTool' }, + { id: 'd', label: 'D', link: 'file:///etc/passwd' }, + { id: 'e', label: 'E', link: { path: '../../secrets.env', line: -4 } }, + { id: 'f', label: 'F', link: { symbol: 'no path' } }, + { id: 'g', label: 'G', link: 42 } + ], edges: [] }); + assert.strictEqual(r.status, 'ok', JSON.stringify(r.errors)); + const links = r.spec.nodes.map((x) => x.link); + assert.deepStrictEqual(links[0], { path: 'src/agent.js', symbol: 'runAgent', line: 12 }); + assert.deepStrictEqual(links[1], { path: 'src/agent.js', line: 890 }); + assert.deepStrictEqual(links[2], { path: 'src/agent.js', symbol: 'runTool' }); + assert.deepStrictEqual(links[3], { path: '/etc/passwd' }, 'kept as a path; refusing it is the host\'s job, and it does'); + assert.deepStrictEqual(links[4], { path: '../../secrets.env' }, 'a bad line number is dropped, the path is not judged here'); + assert.strictEqual(links[5], undefined, 'no path, no link'); + assert.strictEqual(links[6], undefined); +}); + +test('SAFETY: a spec cannot reach Object.prototype', () => { + const evil = '{"title":"T","__proto__":{"polluted":1},"constructor":{"prototype":{"polluted":1}},"nodes":[{"id":"a","label":"A","__proto__":{"polluted":1}}],"edges":[{"from":"a","to":"a","__proto__":{"polluted":1}}],"groups":{"__proto__":{"label":"x"}}}'; + const r = R.prepare(evil, { final: true }); + assert.ok(r.spec, r.status); + assert.strictEqual(({}).polluted, undefined); + assert.strictEqual(Object.prototype.polluted, undefined); + assert.strictEqual(Object.getPrototypeOf(r.spec), Object.prototype); + for (const x of r.spec.nodes) { assert.deepStrictEqual(Object.keys(x).sort(), ['id', 'label']); } +}); + +test('ROBUST: prepare() never throws and always names a status — seeded junk, both attempts', () => { + let seed = 20261004; + const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }; + const atoms = [null, undefined, 0, -1, 1e21, NaN, '', 'a', 'right', 'true', true, false, [], {}, [[]], { id: 'a' }, { from: 'a', to: 'b' }, 'a -> b', ['a', 'b'], '\u0000', '🙂', { path: 'x' }]; + const pick = () => atoms[Math.floor(rnd() * atoms.length)]; + const STATUSES = ['ok', 'fixed', 'errors', 'degraded', 'failed', 'truncated']; + for (let i = 0; i < 1500; i++) { + const spec = {}; + for (const k of ['v', 'title', 'direction', 'nodes', 'edges', 'groups']) { + if (rnd() < 0.75) { spec[k] = rnd() < 0.5 ? pick() : Array.from({ length: Math.floor(rnd() * 5) }, () => (rnd() < 0.5 ? pick() : { id: pick(), label: pick(), sub: pick(), shape: pick(), accent: pick(), group: pick(), link: pick(), from: pick(), to: pick(), parent: pick(), style: pick() })); } + } + for (const final of [false, true]) { + const r = R.prepare(rnd() < 0.2 ? JSON.stringify(spec) : spec, { final }); + assert.ok(STATUSES.includes(r.status), r.status); + assert.ok(Array.isArray(r.fixes) && Array.isArray(r.errors) && Array.isArray(r.notes)); + if (r.spec) { assert.strictEqual(V.validate(r.spec, { tier: 'hard' }).ok, true, 'whatever is drawn is valid: ' + JSON.stringify(spec)); } + if (final) { assert.notStrictEqual(r.status, 'errors'); } + } + } +}); + +console.log('diagramRepair: ' + n + ' tests passed'); diff --git a/extensions/levelcode-ai/test/diagramSchema.test.js b/extensions/levelcode-ai/test/diagramSchema.test.js new file mode 100644 index 0000000..1682224 --- /dev/null +++ b/extensions/levelcode-ai/test/diagramSchema.test.js @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Rich diagrams — the Graph JSON schema and the validator — run: node test/diagramSchema.test.js + * + * docs/RICH-DIAGRAMS.md: "The validator is the one gate every format passes." These tests pin the + * contract a model is shown (the wire schema), the contract it is held to (validate), and the one + * thing that makes a broken spec cheap to fix: every error at once, each naming what was expected. + *--------------------------------------------------------------------------------------------*/ +// @ts-check +'use strict'; + +const assert = require('assert'); +const schema = require('../diagram/schema'); +const V = require('../diagram/validate'); + +let n = 0; +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } + +/** The worked example from the spec, verbatim. */ +const jev = () => ({ + v: 1, title: 'Jev classifies; your code decides the action', direction: 'right', + nodes: [ + { id: 'in', label: 'Customer message', sub: 'plus account details' }, + { id: 'jev', label: 'Jev', sub: 'returns probabilities', accent: true }, + { id: 'bill', label: 'Route to billing' }, + { id: 'rev', label: 'Human review' } + ], + edges: [{ from: 'in', to: 'jev' }, { from: 'jev', to: 'bill', label: '0.90 or more' }, { from: 'jev', to: 'rev', label: 'under 0.90' }] +}); +const lines = (r) => V.formatErrors(r.errors).split('\n'); + +test('LIMITS: the numbers are the ones in the spec\'s field tables', () => { + const H = schema.HOUSE; + assert.strictEqual(H.title, 80); + assert.strictEqual(H.label, 28); + assert.strictEqual(H.sub, 32); + assert.strictEqual(H.edgeLabel, 20); + assert.strictEqual(H.nodesMin, 1); + assert.strictEqual(H.nodesMax, 12); + assert.strictEqual(H.groupDepth, 2); + assert.deepStrictEqual(schema.SHAPES, ['box', 'decision', 'store', 'actor']); + assert.deepStrictEqual(schema.DIRECTIONS, ['right', 'down']); + assert.deepStrictEqual(schema.EDGE_STYLES, ['solid', 'dashed']); + assert.strictEqual(schema.VERSION, 1); +}); + +test('LIMITS: the hard tier only ever RELAXES counts — text limits hold even for a degraded spec', () => { + const H = schema.HOUSE, X = schema.HARD; + assert.ok(X.nodesMax > H.nodesMax && X.edgesMax > H.edgesMax && X.groupsMax > H.groupsMax); + for (const k of ['title', 'label', 'sub', 'edgeLabel', 'groupLabel', 'groupDepth', 'id']) { + assert.strictEqual(X[k], H[k], k + ' must not relax'); + } + assert.ok(X.nodesMax <= 32, 'the hard ceiling exists to bound layout time and DOM size'); +}); + +test('WIRE: the schema sent to providers uses plain JSON Schema and nothing a provider could reject', () => { + const ALLOWED = new Set(['type', 'properties', 'required', 'items', 'enum', 'minLength', 'maxLength', 'minItems', 'maxItems', 'pattern', 'minimum', 'description']); + const walk = (node, where) => { + for (const k of Object.keys(node)) { assert.ok(ALLOWED.has(k), 'keyword "' + k + '" at ' + where + ' is not plain JSON Schema'); } + for (const [k, v] of Object.entries(node.properties || {})) { walk(v, where + '/' + k); } + if (node.items) { walk(node.items, where + '[]'); } + }; + walk(schema.SCHEMAS[1].house, ''); + const wire = JSON.stringify(schema.SCHEMAS[1].house); + assert.ok(!/additionalProperties/.test(wire), 'a stray field is tidied, not failed — so it is never forbidden on the wire'); + assert.ok(!/"tip"/.test(wire), '`tip` is the renderer\'s, never the model\'s'); + assert.ok(/"tip"/.test(JSON.stringify(schema.SCHEMAS[1].hard)), 'the render-ready shape carries tooltips'); + assert.deepStrictEqual(schema.SCHEMAS[1].house.required, ['title', 'nodes', 'edges']); +}); + +test('WIRE: the schema the model sees and the schema it is checked against are one object', () => { + // If these ever diverge, a spec could satisfy what the model was told and still be rejected. + const r = { errors: [] }; + schema.check(schema.SCHEMAS[1].house, jev(), '', r.errors); + assert.deepStrictEqual(r.errors, []); + assert.deepStrictEqual(V.validate(jev()), { ok: true, errors: [] }); +}); + +test('EXAMPLE: the spec\'s worked example is valid, and validating it does not change it', () => { + const spec = jev(), before = JSON.stringify(spec); + assert.strictEqual(V.validate(spec).ok, true); + assert.strictEqual(JSON.stringify(spec), before, 'validate() is read-only'); +}); + +test('ERROR FORMAT: the three lines the spec prints, word for word', () => { + const bad = jev(); + bad.edges[1].to = 'billing'; + bad.nodes[0].sub = 'x'.repeat(46); + bad.nodes[2].accent = true; + const out = lines(V.validate(bad)); + assert.ok(out.includes('/edges/1/to: unknown node "billing". Known ids: in, jev, bill, rev.'), out.join('\n')); + assert.ok(out.includes('/nodes/0/sub: 46 chars, max 32. Shorten or move detail to prose.'), out.join('\n')); + assert.ok(out.some((l) => l.startsWith('/nodes: 2 nodes have accent=true, max 1 (jev, bill).')), out.join('\n')); + assert.strictEqual(out.length, 3, 'EVERY error at once — and no extras'); +}); + +test('ERROR FORMAT: each error is a JSON Pointer, what was expected, and the valid options', () => { + const r = V.validate({ title: 'T', direction: 'sideways', nodes: [{ id: 'a', label: 'A', shape: 'blob' }], edges: [{ from: 'a', to: 'a', style: 'wavy' }] }); + const out = lines(r); + assert.ok(out.includes('/direction: "sideways" is not allowed. Use one of: right, down.'), out.join('\n')); + assert.ok(out.includes('/nodes/0/shape: "blob" is not allowed. Use one of: box, decision, store, actor.'), out.join('\n')); + assert.ok(out.includes('/edges/0/style: "wavy" is not allowed. Use one of: solid, dashed.'), out.join('\n')); + for (const e of r.errors) { + assert.ok(e.pointer === '' || e.pointer[0] === '/', 'pointer: ' + e.pointer); + assert.ok(typeof e.cls === 'string' && e.cls, 'every error has a class for telemetry'); + assert.ok(/[.]$/.test(e.message), 'a sentence, not a code: ' + e.message); + } +}); + +test('ERROR FORMAT: a pointer escapes "/" and "~" (RFC 6901)', () => { + assert.strictEqual(schema.seg('a/b~c'), 'a~1b~0c'); +}); + +test('ERROR FORMAT: the list of known ids is capped, so one error cannot carry a whole spec back', () => { + const many = { title: 'T', nodes: Array.from({ length: 24 }, (_, i) => ({ id: 'n' + i, label: 'L' })), edges: [{ from: 'n0', to: 'ghost' }] }; + const line = lines(V.validate(many, { tier: 'hard' })).find((l) => l.startsWith('/edges/0/to')); + assert.ok(line, 'reported'); + assert.match(line, /Known ids: n0, n1, .*n15, … \(8 more\)\.$/); + assert.ok(line.length < 220, 'bounded: ' + line.length); +}); + +test('SCHEMA: required fields, types, lengths and counts', () => { + assert.ok(lines(V.validate({ nodes: [{ id: 'a', label: 'A' }], edges: [] })).includes('/title: missing. This field is required.')); + assert.ok(lines(V.validate({ title: 'T', nodes: 'a,b', edges: [] })).includes('/nodes: expected an array, got string.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [], edges: [] })).includes('/nodes: 0 nodes, min 1.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a' }], edges: [] })).includes('/nodes/0/label: missing. This field is required.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a', label: 7 }], edges: [] })).includes('/nodes/0/label: expected a string, got integer.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a', label: '' }], edges: [] })).includes('/nodes/0/label: must not be empty.')); + assert.ok(lines(V.validate({ title: 'x'.repeat(81), nodes: [{ id: 'a', label: 'A' }], edges: [] })).includes('/title: 81 chars, max 80. Say the takeaway in fewer words.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a', label: 'A' }], edges: [{ from: 'a', to: 'a', label: 'x'.repeat(21) }] })).includes('/edges/0/label: 21 chars, max 20. Shorten or move detail to prose.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'Not A Slug', label: 'A' }], edges: [] }))[0].startsWith('/nodes/0/id: "Not A Slug" is not a lowercase slug')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a', label: 'A', link: { path: 'a.js', line: 0 } }], edges: [] })).includes('/nodes/0/link/line: 0 is below the minimum 1.')); + assert.ok(lines(V.validate({ title: 'T', nodes: [{ id: 'a', label: 'A', link: {} }], edges: [] })).includes('/nodes/0/link/path: missing. This field is required.')); +}); + +test('SCHEMA: over twelve nodes is an error that tells the model what to do instead', () => { + const spec = { title: 'T', nodes: Array.from({ length: 13 }, (_, i) => ({ id: 'n' + i, label: 'L' })), edges: [] }; + assert.deepStrictEqual(lines(V.validate(spec)), ['/nodes: 13 nodes, max 12. Draw an overview, then one diagram per sub-flow.']); + assert.strictEqual(V.validate(spec, { tier: 'hard' }).ok, true, 'the renderer can still draw it — that is what degrading relies on'); + spec.nodes = Array.from({ length: schema.HARD.nodesMax + 1 }, (_, i) => ({ id: 'n' + i, label: 'L' })); + assert.strictEqual(V.validate(spec, { tier: 'hard' }).ok, false, 'but never past the hard ceiling'); +}); + +test('SCHEMA: length is counted in characters, not UTF-16 units', () => { + const spec = { title: 'T', nodes: [{ id: 'a', label: '🙂'.repeat(28) }], edges: [] }; + assert.strictEqual(V.validate(spec).ok, true, '28 emoji are 28 characters'); + spec.nodes[0].label = '🙂'.repeat(29); + assert.ok(lines(V.validate(spec)).includes('/nodes/0/label: 29 chars, max 28. Shorten or move detail to prose.')); +}); + +test('SEMANTICS: ids are unique — nodes and groups each', () => { + const r = V.validate({ title: 'T', groups: [{ id: 'g', label: 'G' }, { id: 'g', label: 'H' }], nodes: [{ id: 'a', label: 'A', group: 'g' }, { id: 'a', label: 'B' }], edges: [] }); + const out = lines(r); + assert.ok(out.includes('/nodes/1/id: duplicate id "a" (already used by /nodes/0). Every node needs its own id.'), out.join('\n')); + assert.ok(out.includes('/groups/1/id: duplicate group id "g" (already used by /groups/0).'), out.join('\n')); + assert.deepStrictEqual(V.errorClasses(r.errors), { 'duplicate-id': 2 }); +}); + +test('SEMANTICS: groups — unknown parent, self parent, a circle, and nesting past two', () => { + const base = (groups, nodes) => ({ title: 'T', groups, nodes: nodes || [{ id: 'x', label: 'X', group: groups[0].id }], edges: [] }); + assert.ok(lines(V.validate(base([{ id: 'a', label: 'A', parent: 'zz' }]))).includes('/groups/0/parent: unknown group "zz". Known groups: a.')); + assert.ok(lines(V.validate(base([{ id: 'a', label: 'A', parent: 'a' }]))).includes('/groups/0/parent: a group cannot contain itself. Remove "parent" or name another group.')); + const circle = V.validate(base([{ id: 'a', label: 'A', parent: 'b' }, { id: 'b', label: 'B', parent: 'a' }])); + assert.deepStrictEqual(V.errorClasses(circle.errors), { 'group-cycle': 2 }); + const deep = V.validate(base([{ id: 'a', label: 'A' }, { id: 'b', label: 'B', parent: 'a' }, { id: 'c', label: 'C', parent: 'b' }])); + assert.deepStrictEqual(lines(deep), ['/groups/2/parent: nested 3 deep, max 2. Flatten it.']); + assert.strictEqual(V.validate(base([{ id: 'a', label: 'A' }, { id: 'b', label: 'B', parent: 'a' }])).ok, true, 'depth 2 is allowed'); + assert.ok(lines(V.validate({ title: 'T', groups: [{ id: 'a', label: 'A' }], nodes: [{ id: 'x', label: 'X', group: 'nope' }], edges: [] })).includes('/nodes/0/group: unknown group "nope". Known groups: a.')); +}); + +test('SEMANTICS: a self-loop and a cycle are legal — a state machine needs both', () => { + const r = V.validate({ title: 'T', nodes: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }], edges: [{ from: 'a', to: 'a' }, { from: 'a', to: 'b' }, { from: 'b', to: 'a' }] }); + assert.deepStrictEqual(r, { ok: true, errors: [] }); +}); + +test('VERSION: an unknown version is refused by name; no version reads as v1', () => { + const future = jev(); future.v = 3; + assert.deepStrictEqual(lines(V.validate(future)), ['/v: unknown schema version 3. This editor reads: 1.']); + const none = jev(); delete none.v; + assert.strictEqual(V.validate(none).ok, true); + assert.strictEqual(V.migrate(none).v, 1); + assert.strictEqual(none.v, undefined, 'migrate() returns a copy'); +}); + +test('VERSION: every schema version in the registry has both tiers and still reads the example (NFR-4)', () => { + assert.ok(schema.KNOWN_VERSIONS.includes(schema.VERSION)); + for (const v of schema.KNOWN_VERSIONS) { + assert.ok(schema.SCHEMAS[v].house && schema.SCHEMAS[v].hard, 'v' + v + ' has both tiers'); + const spec = V.migrate(Object.assign(jev(), { v })); + assert.strictEqual(V.validate(spec, { tier: 'hard' }).ok, true, 'a v' + v + ' spec still renders'); + } + assert.ok(Object.isFrozen(schema.SCHEMAS) && Object.isFrozen(schema.SCHEMAS[1]), 'a stored chat points at v1; v1 must not be edited in place'); +}); + +test('ROBUST: validate() never throws, whatever it is handed', () => { + const junk = [null, undefined, 0, 'x', [], [1, 2], true, {}, { nodes: null }, { nodes: [null, 1, 'a', [], {}] }, { title: {}, nodes: {}, edges: {} }, + { title: 'T', nodes: [{ id: 'a', label: 'A', link: 'x' }], edges: [null, 3, { from: {}, to: [] }] }, { title: 'T', nodes: [{ id: 'a', label: 'A' }], edges: [], groups: [null, { id: 4 }, 'g'] }]; + for (const j of junk) { + const r = V.validate(j); + assert.strictEqual(typeof r.ok, 'boolean'); + assert.ok(Array.isArray(r.errors)); + assert.strictEqual(r.ok, r.errors.length === 0); + assert.strictEqual(typeof V.formatErrors(r.errors), 'string'); + } + assert.strictEqual(lines(V.validate('nope'))[0], '/: expected an object with "title", "nodes" and "edges", got string.'); +}); + +console.log('diagramSchema: ' + n + ' tests passed'); diff --git a/extensions/levelcode-ai/test/fixtures/diagrams/corpus.json b/extensions/levelcode-ai/test/fixtures/diagrams/corpus.json new file mode 100644 index 0000000..a34a45a --- /dev/null +++ b/extensions/levelcode-ai/test/fixtures/diagrams/corpus.json @@ -0,0 +1,1811 @@ +[ + { + "name": "spec-example", + "why": "The worked example in the spec. Must pass untouched.", + "input": { + "v": 1, + "title": "Jev classifies; your code decides the action", + "direction": "right", + "nodes": [ + { + "id": "in", + "label": "Customer message", + "sub": "plus account details" + }, + { + "id": "jev", + "label": "Jev", + "sub": "returns probabilities", + "accent": true + }, + { + "id": "bill", + "label": "Route to billing" + }, + { + "id": "rev", + "label": "Human review" + } + ], + "edges": [ + { + "from": "in", + "to": "jev" + }, + { + "from": "jev", + "to": "bill", + "label": "0.90 or more" + }, + { + "from": "jev", + "to": "rev", + "label": "under 0.90" + } + ] + }, + "first": { + "status": "ok" + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "spec-error-format", + "why": "The three errors the spec uses to show the error format: an unknown node, a 46-char sub, two accents.", + "input": { + "v": 1, + "title": "Jev classifies; your code decides the action", + "direction": "right", + "nodes": [ + { + "id": "in", + "label": "Customer message", + "sub": "plus the account details we already hold on file" + }, + { + "id": "jev", + "label": "Jev", + "sub": "returns probabilities", + "accent": true + }, + { + "id": "bill", + "label": "Route to billing", + "accent": true + }, + { + "id": "rev", + "label": "Human review" + } + ], + "edges": [ + { + "from": "in", + "to": "jev" + }, + { + "from": "jev", + "to": "billing", + "label": "0.90 or more" + }, + { + "from": "jev", + "to": "rev", + "label": "under 0.90" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: 2 nodes have accent=true, max 1 (jev, bill). Keep it on the node the title is about.", + "/edges/1/to: unknown node \"billing\". Known ids: in, jev, bill, rev." + ], + "shown": [ + "/nodes/0/sub: 48 chars, max 32 — shortened to \"plus the account details we…\" (the full text is kept as a tooltip)." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "1 edge dropped: unknown nodes", + "1 extra accent removed" + ], + "drawn": { + "nodes": 4, + "edges": 2, + "groups": 0 + } + } + }, + { + "name": "trailing-commas-and-comments", + "why": "Models write JSON5: comments and a comma after the last item.", + "input": "{\n \"title\": \"Requests are cached before they are routed\", // the takeaway\n \"nodes\": [\n {\"id\": \"a\", \"label\": \"Request\"},\n {\"id\": \"b\", \"label\": \"Cache\"},\n ],\n \"edges\": [{\"from\": \"a\", \"to\": \"b\"},],\n}", + "first": { + "status": "ok", + "tidied": [ + "lenient-json" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "fenced", + "why": "A spec pasted inside a Markdown fence.", + "input": "```json\n{\"title\":\"One step\",\"nodes\":[{\"id\":\"a\",\"label\":\"A\"}],\"edges\":[]}\n```", + "first": { + "status": "ok", + "tidied": [ + "lenient-json" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 1, + "edges": 0, + "groups": 0 + } + } + }, + { + "name": "single-quotes-bare-keys", + "why": "JavaScript object literal instead of JSON.", + "input": "{ title: 'It is a two-step flow', nodes: [{ id: 'a', label: 'Parse' }, { id: 'b', label: 'Don\\'t retry' }], edges: [{ from: 'a', to: 'b' }] }", + "first": { + "status": "ok", + "tidied": [ + "lenient-json" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "smart-quotes", + "why": "Typographic quotes from a chat UI.", + "input": "{“title”: “Two boxes”, “nodes”: [{“id”: “a”, “label”: “A”}, {“id”: “b”, “label”: “B”}], “edges”: [{“from”: “a”, “to”: “b”}]}", + "first": { + "status": "ok", + "tidied": [ + "lenient-json" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "python-literals", + "why": "True/False/None from a Python-flavoured model.", + "input": "{\"title\": \"One accent\", \"nodes\": [{\"id\": \"a\", \"label\": \"A\", \"accent\": True}, {\"id\": \"b\", \"label\": \"B\", \"accent\": False, \"sub\": None}], \"edges\": []}", + "first": { + "status": "ok", + "tidied": [ + "lenient-json" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 0, + "groups": 0 + } + } + }, + { + "name": "truncated-mid-string", + "why": "The model hit max_tokens inside a label. Never repaired: re-requested.", + "input": "{\"title\":\"Cut off\",\"nodes\":[{\"id\":\"a\",\"label\":\"A\"},{\"id\":\"b\",\"la", + "first": { + "status": "truncated", + "errors": [ + "/: the spec was cut off before it was complete." + ] + }, + "final": { + "status": "truncated" + } + }, + { + "name": "truncated-mid-array", + "why": "The model hit max_tokens between nodes.", + "input": "{\"title\":\"Cut off\",\"nodes\":[{\"id\":\"a\",\"label\":\"A\"},", + "first": { + "status": "truncated", + "errors": [ + "/: the spec was cut off before it was complete." + ] + }, + "final": { + "status": "truncated" + } + }, + { + "name": "wrapped-in-spec", + "why": "The arguments arrive nested under a \"spec\" key.", + "input": { + "spec": { + "v": 1, + "title": "Jev classifies; your code decides the action", + "direction": "right", + "nodes": [ + { + "id": "in", + "label": "Customer message", + "sub": "plus account details" + }, + { + "id": "jev", + "label": "Jev", + "sub": "returns probabilities", + "accent": true + }, + { + "id": "bill", + "label": "Route to billing" + }, + { + "id": "rev", + "label": "Human review" + } + ], + "edges": [ + { + "from": "in", + "to": "jev" + }, + { + "from": "jev", + "to": "bill", + "label": "0.90 or more" + }, + { + "from": "jev", + "to": "rev", + "label": "under 0.90" + } + ] + } + }, + "first": { + "status": "ok", + "tidied": [ + "unwrapped" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "stringified", + "why": "The whole spec arrives as one JSON string.", + "input": "{\"v\":1,\"title\":\"Jev classifies; your code decides the action\",\"direction\":\"right\",\"nodes\":[{\"id\":\"in\",\"label\":\"Customer message\",\"sub\":\"plus account details\"},{\"id\":\"jev\",\"label\":\"Jev\",\"sub\":\"returns probabilities\",\"accent\":true},{\"id\":\"bill\",\"label\":\"Route to billing\"},{\"id\":\"rev\",\"label\":\"Human review\"}],\"edges\":[{\"from\":\"in\",\"to\":\"jev\"},{\"from\":\"jev\",\"to\":\"bill\",\"label\":\"0.90 or more\"},{\"from\":\"jev\",\"to\":\"rev\",\"label\":\"under 0.90\"}]}", + "first": { + "status": "ok", + "tidied": [ + "stringified" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "stringified-inside-wrapper", + "why": "Both at once: {\"diagram\": \"\"}.", + "input": { + "diagram": "{\"v\":1,\"title\":\"Jev classifies; your code decides the action\",\"direction\":\"right\",\"nodes\":[{\"id\":\"in\",\"label\":\"Customer message\",\"sub\":\"plus account details\"},{\"id\":\"jev\",\"label\":\"Jev\",\"sub\":\"returns probabilities\",\"accent\":true},{\"id\":\"bill\",\"label\":\"Route to billing\"},{\"id\":\"rev\",\"label\":\"Human review\"}],\"edges\":[{\"from\":\"in\",\"to\":\"jev\"},{\"from\":\"jev\",\"to\":\"bill\",\"label\":\"0.90 or more\"},{\"from\":\"jev\",\"to\":\"rev\",\"label\":\"under 0.90\"}]}" + }, + "first": { + "status": "ok", + "tidied": [ + "stringified", + "unwrapped" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "shape-synonyms", + "why": "Mermaid and draw.io vocabulary for shapes.", + "input": { + "title": "Shapes by any other name", + "nodes": [ + { + "id": "a", + "label": "User", + "shape": "person" + }, + { + "id": "b", + "label": "Valid?", + "shape": "diamond" + }, + { + "id": "c", + "label": "Orders", + "shape": "database" + }, + { + "id": "d", + "label": "Step", + "shape": "rectangle" + }, + { + "id": "e", + "label": "Blob", + "shape": "hexagon" + } + ], + "edges": [ + { + "from": "a", + "to": "b" + }, + { + "from": "b", + "to": "c" + }, + { + "from": "b", + "to": "d" + }, + { + "from": "d", + "to": "e" + } + ] + }, + "first": { + "status": "fixed", + "shown": [ + "/nodes/4/shape: \"hexagon\" is not a shape; drew a box." + ], + "tidied": [ + "synonym" + ] + }, + "final": { + "status": "fixed", + "drawn": { + "nodes": 5, + "edges": 4, + "groups": 0 + } + } + }, + { + "name": "direction-synonyms", + "why": "Graphviz rankdir.", + "input": { + "title": "Top to bottom", + "rankdir": "TB", + "nodes": [ + { + "id": "a", + "label": "A" + }, + { + "id": "b", + "label": "B" + } + ], + "edges": [ + { + "from": "a", + "to": "b" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "synonym" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "nodes-as-map", + "why": "Nodes keyed by id instead of listed.", + "input": { + "title": "A map of nodes", + "nodes": { + "api": { + "label": "API" + }, + "db": { + "label": "Postgres", + "shape": "store" + } + }, + "edges": [ + { + "from": "api", + "to": "db" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "coerced" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "edges-as-pairs-and-arrows", + "why": "Three ways to write an edge that are not {from,to}.", + "input": { + "title": "Edges written three ways", + "nodes": [ + { + "id": "a", + "label": "A" + }, + { + "id": "b", + "label": "B" + }, + { + "id": "c", + "label": "C" + }, + { + "id": "d", + "label": "D" + } + ], + "edges": [ + [ + "a", + "b" + ], + "b -> c: next", + { + "source": "c", + "target": "d", + "style": "dotted" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "coerced", + "renamed-field", + "synonym" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "edge-names-a-label", + "why": "An edge points at a node by its label, not its id. One node has that label, so this is a lookup.", + "input": { + "title": "The edge names the label", + "nodes": [ + { + "id": "n1", + "label": "Load balancer" + }, + { + "id": "n2", + "label": "API server" + } + ], + "edges": [ + { + "from": "Load balancer", + "to": "API server" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "ref-by-label" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "ids-not-slugs", + "why": "Ids with capitals and spaces; edges use the same spelling.", + "input": { + "title": "Ids that need tidying", + "nodes": [ + { + "id": "Auth Service", + "label": "Auth" + }, + { + "id": "DB_Main", + "label": "Database", + "shape": "store" + } + ], + "edges": [ + { + "from": "Auth Service", + "to": "DB_Main" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "slug" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "label-too-long", + "why": "The most common violation: a label over 28 characters.", + "input": { + "title": "Long labels are shortened, not rejected", + "nodes": [ + { + "id": "a", + "label": "Validate the incoming request payload" + }, + { + "id": "b", + "label": "Store" + } + ], + "edges": [ + { + "from": "a", + "to": "b", + "label": "when every field checks out" + } + ] + }, + "first": { + "status": "fixed", + "shown": [ + "/nodes/0/label: 37 chars, max 28 — shortened to \"Validate the incoming…\" (the full text is kept as a tooltip).", + "/edges/0/label: 27 chars, max 20 — shortened to \"when every field…\" (the full text is kept as a tooltip)." + ] + }, + "final": { + "status": "fixed", + "notes": [ + "2 labels shortened" + ], + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "two-line-label", + "why": "Label and sub written as one string with a newline.", + "input": { + "title": "One field, two lines", + "nodes": [ + { + "id": "jev", + "label": "Jev\nreturns probabilities" + } + ], + "edges": [] + }, + "first": { + "status": "ok", + "tidied": [ + "split-label" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 1, + "edges": 0, + "groups": 0 + } + } + }, + { + "name": "same-node-twice", + "why": "A node repeated verbatim.", + "input": { + "title": "Listed twice", + "nodes": [ + { + "id": "a", + "label": "A" + }, + { + "id": "a", + "label": "A" + }, + { + "id": "b", + "label": "B" + } + ], + "edges": [ + { + "from": "a", + "to": "b" + } + ] + }, + "first": { + "status": "ok", + "tidied": [ + "duplicate-node" + ] + }, + "final": { + "status": "ok", + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "two-nodes-one-id", + "why": "Two different nodes share an id — which one do the edges mean? Only the model knows.", + "input": { + "title": "One id, two nodes", + "nodes": [ + { + "id": "api", + "label": "Public API" + }, + { + "id": "api", + "label": "Admin API" + }, + { + "id": "db", + "label": "Postgres" + } + ], + "edges": [ + { + "from": "api", + "to": "db" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes/1/id: duplicate id \"api\" (already used by /nodes/0). Every node needs its own id." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "1 duplicate id renamed" + ], + "drawn": { + "nodes": 3, + "edges": 1, + "groups": 0 + } + } + }, + { + "name": "fourteen-nodes", + "why": "Over the 12-node cap: the model must split it.", + "input": { + "title": "Too much for one picture", + "nodes": [ + { + "id": "n0", + "label": "Step 1" + }, + { + "id": "n1", + "label": "Step 2" + }, + { + "id": "n2", + "label": "Step 3" + }, + { + "id": "n3", + "label": "Step 4" + }, + { + "id": "n4", + "label": "Step 5" + }, + { + "id": "n5", + "label": "Step 6" + }, + { + "id": "n6", + "label": "Step 7" + }, + { + "id": "n7", + "label": "Step 8" + }, + { + "id": "n8", + "label": "Step 9" + }, + { + "id": "n9", + "label": "Step 10" + }, + { + "id": "n10", + "label": "Step 11" + }, + { + "id": "n11", + "label": "Step 12" + }, + { + "id": "n12", + "label": "Step 13" + }, + { + "id": "n13", + "label": "Step 14" + } + ], + "edges": [ + { + "from": "n0", + "to": "n1" + }, + { + "from": "n1", + "to": "n2" + }, + { + "from": "n2", + "to": "n3" + }, + { + "from": "n3", + "to": "n4" + }, + { + "from": "n4", + "to": "n5" + }, + { + "from": "n5", + "to": "n6" + }, + { + "from": "n6", + "to": "n7" + }, + { + "from": "n7", + "to": "n8" + }, + { + "from": "n8", + "to": "n9" + }, + { + "from": "n9", + "to": "n10" + }, + { + "from": "n10", + "to": "n11" + }, + { + "from": "n11", + "to": "n12" + }, + { + "from": "n12", + "to": "n13" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: 14 nodes, max 12. Draw an overview, then one diagram per sub-flow." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "14 nodes — over the 12-node limit, drawn anyway" + ], + "drawn": { + "nodes": 14, + "edges": 13, + "groups": 0 + } + } + }, + { + "name": "thirty-nodes", + "why": "Over the HARD ceiling: even a degraded diagram stops at 24.", + "input": { + "title": "Too much for one picture", + "nodes": [ + { + "id": "n0", + "label": "Step 1" + }, + { + "id": "n1", + "label": "Step 2" + }, + { + "id": "n2", + "label": "Step 3" + }, + { + "id": "n3", + "label": "Step 4" + }, + { + "id": "n4", + "label": "Step 5" + }, + { + "id": "n5", + "label": "Step 6" + }, + { + "id": "n6", + "label": "Step 7" + }, + { + "id": "n7", + "label": "Step 8" + }, + { + "id": "n8", + "label": "Step 9" + }, + { + "id": "n9", + "label": "Step 10" + }, + { + "id": "n10", + "label": "Step 11" + }, + { + "id": "n11", + "label": "Step 12" + }, + { + "id": "n12", + "label": "Step 13" + }, + { + "id": "n13", + "label": "Step 14" + }, + { + "id": "n14", + "label": "Step 15" + }, + { + "id": "n15", + "label": "Step 16" + }, + { + "id": "n16", + "label": "Step 17" + }, + { + "id": "n17", + "label": "Step 18" + }, + { + "id": "n18", + "label": "Step 19" + }, + { + "id": "n19", + "label": "Step 20" + }, + { + "id": "n20", + "label": "Step 21" + }, + { + "id": "n21", + "label": "Step 22" + }, + { + "id": "n22", + "label": "Step 23" + }, + { + "id": "n23", + "label": "Step 24" + }, + { + "id": "n24", + "label": "Step 25" + }, + { + "id": "n25", + "label": "Step 26" + }, + { + "id": "n26", + "label": "Step 27" + }, + { + "id": "n27", + "label": "Step 28" + }, + { + "id": "n28", + "label": "Step 29" + }, + { + "id": "n29", + "label": "Step 30" + } + ], + "edges": [ + { + "from": "n0", + "to": "n1" + }, + { + "from": "n1", + "to": "n2" + }, + { + "from": "n2", + "to": "n3" + }, + { + "from": "n3", + "to": "n4" + }, + { + "from": "n4", + "to": "n5" + }, + { + "from": "n5", + "to": "n6" + }, + { + "from": "n6", + "to": "n7" + }, + { + "from": "n7", + "to": "n8" + }, + { + "from": "n8", + "to": "n9" + }, + { + "from": "n9", + "to": "n10" + }, + { + "from": "n10", + "to": "n11" + }, + { + "from": "n11", + "to": "n12" + }, + { + "from": "n12", + "to": "n13" + }, + { + "from": "n13", + "to": "n14" + }, + { + "from": "n14", + "to": "n15" + }, + { + "from": "n15", + "to": "n16" + }, + { + "from": "n16", + "to": "n17" + }, + { + "from": "n17", + "to": "n18" + }, + { + "from": "n18", + "to": "n19" + }, + { + "from": "n19", + "to": "n20" + }, + { + "from": "n20", + "to": "n21" + }, + { + "from": "n21", + "to": "n22" + }, + { + "from": "n22", + "to": "n23" + }, + { + "from": "n23", + "to": "n24" + }, + { + "from": "n24", + "to": "n25" + }, + { + "from": "n25", + "to": "n26" + }, + { + "from": "n26", + "to": "n27" + }, + { + "from": "n27", + "to": "n28" + }, + { + "from": "n28", + "to": "n29" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: 30 nodes, max 12. Draw an overview, then one diagram per sub-flow." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "6 nodes dropped: malformed or over the limit", + "24 nodes — over the 12-node limit, drawn anyway", + "6 edges dropped: no valid ends" + ], + "drawn": { + "nodes": 24, + "edges": 23, + "groups": 0 + } + } + }, + { + "name": "two-accents", + "why": "More than one accent.", + "input": { + "v": 1, + "title": "Jev classifies; your code decides the action", + "direction": "right", + "nodes": [ + { + "id": "in", + "label": "Customer message", + "sub": "plus account details", + "accent": true + }, + { + "id": "jev", + "label": "Jev", + "sub": "returns probabilities", + "accent": true + }, + { + "id": "bill", + "label": "Route to billing" + }, + { + "id": "rev", + "label": "Human review" + } + ], + "edges": [ + { + "from": "in", + "to": "jev" + }, + { + "from": "jev", + "to": "bill", + "label": "0.90 or more" + }, + { + "from": "jev", + "to": "rev", + "label": "under 0.90" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: 2 nodes have accent=true, max 1 (in, jev). Keep it on the node the title is about." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "1 extra accent removed" + ], + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "groups-three-deep", + "why": "Nesting past depth 2.", + "input": { + "title": "Nested too deep", + "groups": [ + { + "id": "a", + "label": "Cloud" + }, + { + "id": "b", + "label": "Region", + "parent": "a" + }, + { + "id": "c", + "label": "Zone", + "parent": "b" + } + ], + "nodes": [ + { + "id": "x", + "label": "VM", + "group": "c" + }, + { + "id": "y", + "label": "LB", + "group": "a" + } + ], + "edges": [ + { + "from": "y", + "to": "x" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/groups/2/parent: nested 3 deep, max 2. Flatten it." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "1 group flattened: nested too deep" + ], + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 2 + } + } + }, + { + "name": "groups-in-a-circle", + "why": "a contains b contains a.", + "input": { + "title": "Groups in a circle", + "groups": [ + { + "id": "a", + "label": "A", + "parent": "b" + }, + { + "id": "b", + "label": "B", + "parent": "a" + } + ], + "nodes": [ + { + "id": "x", + "label": "X", + "group": "a" + }, + { + "id": "y", + "label": "Y", + "group": "b" + } + ], + "edges": [ + { + "from": "x", + "to": "y" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/groups/0/parent: groups contain each other in a circle (\"a\" → \"b\" → …). Nesting must be a tree.", + "/groups/1/parent: groups contain each other in a circle (\"b\" → \"a\" → …). Nesting must be a tree." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "group references that did not resolve were removed" + ], + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 2 + } + } + }, + { + "name": "unknown-group", + "why": "A node names a group that was never declared.", + "input": { + "title": "A group that is not there", + "groups": [ + { + "id": "edge", + "label": "Edge" + } + ], + "nodes": [ + { + "id": "x", + "label": "CDN", + "group": "edge" + }, + { + "id": "y", + "label": "API", + "group": "backend" + } + ], + "edges": [ + { + "from": "x", + "to": "y" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes/1/group: unknown group \"backend\". Known groups: edge." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "group references that did not resolve were removed" + ], + "drawn": { + "nodes": 2, + "edges": 1, + "groups": 1 + } + } + }, + { + "name": "no-title", + "why": "The takeaway is missing.", + "input": { + "nodes": [ + { + "id": "a", + "label": "A" + } + ], + "edges": [] + }, + "first": { + "status": "errors", + "errors": [ + "/title: missing. This field is required." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "no title given" + ], + "drawn": { + "nodes": 1, + "edges": 0, + "groups": 0 + } + } + }, + { + "name": "no-nodes", + "why": "Nothing to draw.", + "input": { + "title": "Empty", + "nodes": [], + "edges": [] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: 0 nodes, min 1." + ] + }, + "final": { + "status": "failed" + } + }, + { + "name": "not-json", + "why": "Prose where a spec should be.", + "input": "I would draw a box that points at another box.", + "first": { + "status": "errors", + "errors": [ + "/: not valid JSON (no JSON object found). Send one JSON object with \"title\", \"nodes\" and \"edges\"." + ] + }, + "final": { + "status": "failed" + } + }, + { + "name": "nodes-not-a-list", + "why": "A scalar where the node list should be.", + "input": { + "title": "Wrong type", + "nodes": "a, b, c", + "edges": [] + }, + "first": { + "status": "errors", + "errors": [ + "/nodes: expected an array, got string." + ] + }, + "final": { + "status": "failed" + } + }, + { + "name": "future-version", + "why": "A spec from a newer editor, read back from a saved session.", + "input": { + "v": 3, + "title": "Jev classifies; your code decides the action", + "direction": "right", + "nodes": [ + { + "id": "in", + "label": "Customer message", + "sub": "plus account details" + }, + { + "id": "jev", + "label": "Jev", + "sub": "returns probabilities", + "accent": true + }, + { + "id": "bill", + "label": "Route to billing" + }, + { + "id": "rev", + "label": "Human review" + } + ], + "edges": [ + { + "from": "in", + "to": "jev" + }, + { + "from": "jev", + "to": "bill", + "label": "0.90 or more" + }, + { + "from": "jev", + "to": "rev", + "label": "under 0.90" + } + ] + }, + "first": { + "status": "errors", + "errors": [ + "/v: unknown schema version 3. This editor reads: 1." + ] + }, + "final": { + "status": "degraded", + "notes": [ + "written for schema v3; drawn as v1" + ], + "drawn": { + "nodes": 4, + "edges": 3, + "groups": 0 + } + } + }, + { + "name": "hostile-text", + "why": "Markup, control characters and a bidi override in labels. They are TEXT: kept as written, minus the invisible ones.", + "input": { + "title": "", + "nodes": [ + { + "id": "a", + "label": "\">" + }, + { + "id": "b", + "label": "safe‮gnp.exe", + "sub": "bell\u0007 and ​zero width" + } + ], + "edges": [ + { + "from": "a", + "to": "b", + "label": "', + nodes: [ + { id: 'a', label: '', sub: '">' }, + { id: 'b', label: "'>", link: { path: 'javascript:alert(4)', symbol: '">', sub: '<b> ]]> & <' }, + { id: 'd', label: 'x', shape: 'decision' } + ], + edges: [{ from: 'a', to: 'b', label: '' }, { from: 'b', to: 'c', label: '" onmouseover="x' }, { from: 'c', to: 'd' }], + groups: [{ id: 'g', label: '