Skip to content

Commit 42159e5

Browse files
committed
feat(diagrams): the spec — a schema, a validator that reports every error at once, and the repair ladder
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).
1 parent 3b05fd3 commit 42159e5

6 files changed

Lines changed: 3500 additions & 0 deletions

File tree

‎extensions/levelcode-ai/diagram/repair.js‎

Lines changed: 691 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* LevelCode — AI · rich diagrams · the Graph JSON schema (docs/RICH-DIAGRAMS.md, "Diagram spec")
3+
*
4+
* The model describes STRUCTURE only — nodes, edges, groups and one accent. It never sets
5+
* coordinates, colours or font sizes, so it cannot violate the style guide; the renderer owns those.
6+
*
7+
* This file is the single definition of what a spec may contain. The SAME object is:
8+
* • sent to the model as `render_diagram`'s input schema (diagram/tool.js), and
9+
* • interpreted here to validate every spec that comes back (check()),
10+
* so the contract the model is shown and the contract it is held to cannot drift apart.
11+
*
12+
* Two tiers of limits, deliberately:
13+
* HOUSE — what the model is asked for (12 nodes, 28-char labels …). Breaking one is an error the
14+
* repair ladder deals with.
15+
* HARD — what the renderer will ever accept, even from a degraded spec. These exist because a
16+
* spec is untrusted input: they bound layout time and DOM size no matter what arrives.
17+
*
18+
* Versioned: `v` names the schema a spec was written against, and SCHEMAS keeps every version this
19+
* editor can still read, so a chat saved today renders after the schema moves on (NFR-4).
20+
*--------------------------------------------------------------------------------------------*/
21+
// @ts-check
22+
(function (root, factory) {
23+
'use strict';
24+
if (typeof module === 'object' && module.exports) { module.exports = factory(); }
25+
else { (root.LCDiagram = root.LCDiagram || {}).schema = factory(); }
26+
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
27+
'use strict';
28+
29+
/** The schema version new specs are written against. */
30+
const VERSION = 1;
31+
32+
const SHAPES = ['box', 'decision', 'store', 'actor'];
33+
const DIRECTIONS = ['right', 'down'];
34+
const EDGE_STYLES = ['solid', 'dashed'];
35+
/** "Lowercase slug": starts alphanumeric, then letters, digits, `_` or `-`. */
36+
const ID_RE = /^[a-z0-9][a-z0-9_-]*$/;
37+
const ID_PATTERN = '^[a-z0-9][a-z0-9_-]*$';
38+
39+
/** What the model is asked for — the numbers in the spec's field tables. */
40+
const HOUSE = Object.freeze({
41+
title: 80, label: 28, sub: 32, edgeLabel: 20, groupLabel: 28,
42+
nodesMin: 1, nodesMax: 12, groupDepth: 2,
43+
// Not in the field tables, but a spec is untrusted and these have to be SOME number. Chosen
44+
// so no diagram that respects the 12-node cap can run into them.
45+
edgesMax: 30, groupsMax: 8, id: 32, path: 260, symbol: 80
46+
});
47+
/**
48+
* What the renderer accepts at all. A degraded spec may exceed a HOUSE count (it is drawn anyway,
49+
* under a banner) but never these; text limits do not relax, because truncation always applies.
50+
*/
51+
const HARD = Object.freeze(Object.assign({}, HOUSE, { nodesMax: 24, edgesMax: 48, groupsMax: 12, tip: 400 }));
52+
53+
/**
54+
* Build the JSON Schema for one tier.
55+
*
56+
* `house` is the wire schema — plain JSON Schema with nothing a provider could reject (no custom
57+
* keywords, no `additionalProperties`: a model that adds a stray field is tidied, not failed).
58+
* `v` is deliberately not in it: the version is checked by validate() before any schema is
59+
* chosen, and a model writing a new spec never needs to state it.
60+
* `hard` is the render-ready shape: the same fields under the HARD limits, plus `tip` — the full
61+
* text of a label the auto-fixer had to truncate, which the renderer shows as a tooltip. `tip` is
62+
* ours; the model is never told about it.
63+
* @param {'house'|'hard'} tier
64+
*/
65+
function build(tier) {
66+
const L = tier === 'hard' ? HARD : HOUSE;
67+
const hard = tier === 'hard';
68+
const tip = hard ? { tip: { type: 'string', maxLength: HARD.tip } } : {};
69+
// Only a DECLARED id carries the slug rule. A reference (an edge end, a node's group, a group's
70+
// parent) needs no rule of its own: the semantic layer requires it to equal a declared id, so it
71+
// is a slug or it is an error either way — and every keyword here is paid for on each request.
72+
const id = { type: 'string', maxLength: L.id, pattern: ID_PATTERN };
73+
const ref = { type: 'string' };
74+
return {
75+
type: 'object',
76+
properties: Object.assign({
77+
title: { type: 'string', minLength: 1, maxLength: L.title, description: 'The takeaway, not the topic.' },
78+
direction: { type: 'string', enum: DIRECTIONS, description: 'Default right.' },
79+
nodes: {
80+
type: 'array', minItems: L.nodesMin, maxItems: L.nodesMax,
81+
items: {
82+
type: 'object',
83+
properties: Object.assign({
84+
id: Object.assign({ description: 'Unique lowercase slug.' }, id),
85+
label: { type: 'string', minLength: 1, maxLength: L.label },
86+
sub: { type: 'string', maxLength: L.sub, description: 'Second line.' },
87+
shape: { type: 'string', enum: SHAPES, description: 'box=step (default), decision=branch, store=data at rest, actor=person or outside system.' },
88+
accent: { type: 'boolean', description: 'At most one node.' },
89+
group: Object.assign({ description: 'Group id.' }, ref),
90+
link: {
91+
type: 'object',
92+
description: 'Workspace file to open on click.',
93+
properties: {
94+
path: { type: 'string', minLength: 1, maxLength: L.path },
95+
symbol: { type: 'string', maxLength: L.symbol },
96+
line: { type: 'integer', minimum: 1 }
97+
},
98+
required: ['path']
99+
}
100+
}, tip),
101+
required: ['id', 'label']
102+
}
103+
},
104+
edges: {
105+
type: 'array', maxItems: L.edgesMax,
106+
items: {
107+
type: 'object',
108+
properties: Object.assign({
109+
from: ref, to: ref,
110+
label: { type: 'string', maxLength: L.edgeLabel },
111+
style: { type: 'string', enum: EDGE_STYLES }
112+
}, tip),
113+
required: ['from', 'to']
114+
}
115+
},
116+
groups: {
117+
type: 'array', maxItems: L.groupsMax,
118+
items: {
119+
type: 'object',
120+
properties: Object.assign({
121+
id: id,
122+
label: { type: 'string', minLength: 1, maxLength: L.groupLabel },
123+
parent: Object.assign({ description: 'Enclosing group (depth 2 max).' }, ref)
124+
}, tip),
125+
required: ['id', 'label']
126+
}
127+
}
128+
}, tip),
129+
required: ['title', 'nodes', 'edges']
130+
};
131+
}
132+
133+
/**
134+
* Every schema version this editor can read, each in both tiers. Adding v2 means adding a row
135+
* here and a step in validate.migrate() — never editing v1, which stored chats still point at.
136+
*/
137+
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; };
138+
const SCHEMAS = deepFreeze({
139+
1: { house: build('house'), hard: build('hard') }
140+
});
141+
const KNOWN_VERSIONS = Object.keys(SCHEMAS).map(Number);
142+
143+
// ---- a JSON-Schema-subset interpreter -------------------------------------------------------
144+
// Exactly the keywords build() uses, and no more: type, properties, required, items, enum,
145+
// minLength/maxLength, minItems/maxItems, pattern, minimum. Ajv would do this too, but it would be
146+
// the extension's first runtime dependency for ~80 lines of work — and its messages are not the
147+
// ones the repair ladder needs. Each error names WHAT WAS EXPECTED and THE VALID OPTIONS, because
148+
// that is what turns a model's repair into a lookup instead of a guess.
149+
150+
/** JSON's own type names, with `integer` and `array` told apart from number/object. */
151+
function typeOf(v) {
152+
if (v === null) { return 'null'; }
153+
if (Array.isArray(v)) { return 'array'; }
154+
if (typeof v === 'number') { return Number.isInteger(v) ? 'integer' : 'number'; }
155+
return typeof v;
156+
}
157+
/** Escape one JSON Pointer segment (RFC 6901). */
158+
function seg(s) { return String(s).replace(/~/g, '~0').replace(/\//g, '~1'); }
159+
160+
/**
161+
* What a too-long / too-many error should tell the model to DO. Keyed by the tail of the
162+
* pointer, so `/nodes/3/label` and `/nodes/7/label` share one hint.
163+
*/
164+
const HINTS = {
165+
'title': 'Say the takeaway in fewer words.',
166+
'label': 'Shorten or move detail to prose.',
167+
'sub': 'Shorten or move detail to prose.',
168+
'nodes': 'Draw an overview, then one diagram per sub-flow.',
169+
'edges': 'Keep the connections that carry the point.',
170+
'groups': 'Use fewer containers.'
171+
};
172+
function hintFor(pointer) {
173+
const tail = String(pointer).split('/').pop() || '';
174+
return HINTS[tail] ? ' ' + HINTS[tail] : '';
175+
}
176+
const NOUN = { nodes: 'nodes', edges: 'edges', groups: 'groups' };
177+
178+
/**
179+
* Check `value` against `schema`, appending every violation to `errors`.
180+
* @param {any} schema
181+
* @param {any} value
182+
* @param {string} pointer JSON Pointer of `value` ('' for the root)
183+
* @param {Array<{pointer:string, cls:string, message:string}>} errors
184+
*/
185+
function check(schema, value, pointer, errors) {
186+
const got = typeOf(value);
187+
const want = schema.type;
188+
const typeOk = want === got || (want === 'number' && got === 'integer');
189+
if (!typeOk) {
190+
errors.push({ pointer, cls: 'type', message: 'expected ' + (want === 'object' ? 'an object' : want === 'array' ? 'an array' : want === 'integer' ? 'an integer' : 'a ' + want) + ', got ' + got + '.' });
191+
return; // nothing below means anything on a value of the wrong type
192+
}
193+
if (schema.enum && schema.enum.indexOf(value) < 0) {
194+
errors.push({ pointer, cls: pointer === '/v' ? 'version' : 'enum', message: JSON.stringify(value) + ' is not allowed. Use one of: ' + schema.enum.join(', ') + '.' });
195+
}
196+
if (want === 'string') {
197+
const n = Array.from(value).length; // code points — an emoji is one character, not two
198+
if (schema.maxLength != null && n > schema.maxLength) {
199+
errors.push({ pointer, cls: 'length', message: n + ' chars, max ' + schema.maxLength + '.' + hintFor(pointer) });
200+
}
201+
if (schema.minLength != null && n < schema.minLength) {
202+
errors.push({ pointer, cls: 'required', message: 'must not be empty.' });
203+
} else if (schema.pattern && n <= (schema.maxLength != null ? schema.maxLength : n) && !new RegExp(schema.pattern).test(value)) {
204+
errors.push({ pointer, cls: 'pattern', message: JSON.stringify(value) + ' is not a lowercase slug (letters, digits, "-" or "_"; e.g. "auth-check").' });
205+
}
206+
}
207+
if ((want === 'integer' || want === 'number') && schema.minimum != null && value < schema.minimum) {
208+
errors.push({ pointer, cls: 'range', message: value + ' is below the minimum ' + schema.minimum + '.' });
209+
}
210+
if (want === 'array') {
211+
const noun = NOUN[String(pointer).split('/').pop() || ''] || 'items';
212+
if (schema.maxItems != null && value.length > schema.maxItems) {
213+
errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', max ' + schema.maxItems + '.' + hintFor(pointer) });
214+
}
215+
if (schema.minItems != null && value.length < schema.minItems) {
216+
errors.push({ pointer, cls: 'count', message: value.length + ' ' + noun + ', min ' + schema.minItems + '.' });
217+
}
218+
if (schema.items) {
219+
for (let i = 0; i < value.length; i++) { check(schema.items, value[i], pointer + '/' + i, errors); }
220+
}
221+
}
222+
if (want === 'object') {
223+
for (const key of (schema.required || [])) {
224+
if (value[key] === undefined) { errors.push({ pointer: pointer + '/' + seg(key), cls: 'required', message: 'missing. This field is required.' }); }
225+
}
226+
const props = schema.properties || {};
227+
for (const key of Object.keys(props)) {
228+
if (value[key] !== undefined) { check(props[key], value[key], pointer + '/' + seg(key), errors); }
229+
}
230+
}
231+
}
232+
233+
/**
234+
* A copy of `value` holding only what `schema` declares, in the order the value had it.
235+
* check() says whether the declared fields are right; it does not object to fields it has never
236+
* heard of — a model that adds `color` should not be sent back for it. So after a spec passes,
237+
* this is what makes "validated" mean "exactly this shape, and nothing that came along with it".
238+
*/
239+
function project(schema, value) {
240+
if (schema.type === 'object' && typeOf(value) === 'object') {
241+
const props = schema.properties || {};
242+
const out = {};
243+
for (const key of Object.keys(value)) {
244+
if (Object.prototype.hasOwnProperty.call(props, key) && value[key] !== undefined) { out[key] = project(props[key], value[key]); }
245+
}
246+
return out;
247+
}
248+
if (schema.type === 'array' && Array.isArray(value)) { return value.map((v) => (schema.items ? project(schema.items, v) : v)); }
249+
return value;
250+
}
251+
252+
return { VERSION, KNOWN_VERSIONS, SHAPES, DIRECTIONS, EDGE_STYLES, ID_RE, ID_PATTERN, HOUSE, HARD, SCHEMAS, build, check, project, typeOf, seg };
253+
}));

0 commit comments

Comments
 (0)