|
| 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