@eriknaslund/skiss 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,7 @@ Skiss is text you can type while someone talks. A field is one line. A change is
41
41
  - **What you leave out means "not yet".** Never "unknown", never "none". A sketch is silent about what you do not know.
42
42
  - **`@Catalog` says which system owns a thing.** On a class, or on a single field when the box is really a view over two systems.
43
43
  - **`~` and `=` are two different statements.** `~` says two classes are about the same thing. `=` says which field joins to which. The first is a domain conversation, the second an integration conversation.
44
+ - **`<` inherits, once.** `Jedi < Character` on the class line: the child has everything the parent has and adds its own. One parent, no mixins; it compiles to `is_a`.
44
45
  - **`#` describes, `?` doubts.** A description ends up in generated documentation. A doubt never does.
45
46
  - **Every line stands on its own.** A half-typed line breaks that line and nothing else, so the diagram keeps rendering while you type.
46
47
  - **Everything you can write compiles to valid LinkML.** Skiss is a strict subset. Nothing is lost on the way up.
@@ -62,9 +63,11 @@ Or without installing: `npx @eriknaslund/skiss diagram model.skiss`.
62
63
 
63
64
  `skiss diagram` writes a Mermaid class diagram to standard output; `-o diagram.mmd` writes it to a file, `--notes` includes the `?` doubts as notes, and `-` reads standard input. Anything the file gets wrong is reported on standard error, one line each, and the diagram is still drawn from the lines that parse. `--strict` makes that a non-zero exit. `skiss --help` lists the rest.
64
65
 
66
+ `skiss render model.skiss -o model.svg` writes a picture rather than the Mermaid text. A class diagram is laid out in a browser, so this one command needs a browser: it delegates to the official [Mermaid CLI](https://github.com/mermaid-js/mermaid-cli), which you install yourself with `npm install -g @mermaid-js/mermaid-cli`. Nothing in the package or the library depends on it, and `skiss render` says that line and exits 2 when it is not there. The format is the extension of `-o` — `.svg` or `.png` — and `--format svg|png` overrides it; with no `-o` the SVG goes to standard output, and a PNG always needs an `-o`. `--scale` is what the PNG is rasterised at and defaults to 2; `--notes`, `--strict` and the diagnostics work as they do for `diagram`. When `mmdc` itself fails, its exit status is the command's.
67
+
65
68
  `skiss compile model.skiss` writes a LinkML schema instead, as YAML on standard output. `--json` writes the same schema as JSON, `--name` sets the schema name (it defaults to the file name without its extension, and to `sketch` when reading standard input), and `-o`, `-`, `--strict` and the diagnostics work exactly as they do for `diagram`.
66
69
 
67
- `skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry — `is_a`, patterns, `required`, the names it had to convert — is one line on standard error before the diagnostics, because a sketch is a smaller language than LinkML and that line says by how much. `-o`, `-` and the diagnostics work as they do for `compile`; `--strict` exits 1 when the projected sketch has a diagnostic, and also when anything was dropped. An input that is not a schema at all — not YAML, or YAML no schema can be read out of — produces no sketch, says why on standard error, and exits 2, the code every command uses for unreadable input.
70
+ `skiss import schema.linkml.yaml` goes the other way: it reads a LinkML schema, YAML or JSON, and writes Skiss. What the projection could not carry — patterns, `required`, `mixins`, the names it had to convert — is one line on standard error before the diagnostics, because a sketch is a smaller language than LinkML and that line says by how much. `-o`, `-` and the diagnostics work as they do for `compile`; `--strict` exits 1 when the projected sketch has a diagnostic, and also when anything was dropped. An input that is not a schema at all — not YAML, or YAML no schema can be read out of — produces no sketch, says why on standard error, and exits 2, the code every command uses for unreadable input.
68
71
 
69
72
  `npm install @eriknaslund/skiss` also gives you the library: `compile`, `formatDiagnostic`, `parse`, `resolve`, `toLinkML`, `serialize`, `toMermaid`, `toSkiss`, `fromLinkML`, `formatDropped`, `importLinkML` and `VERSION`, browser-safe.
70
73
 
@@ -84,9 +87,9 @@ Editors that render Skiss live are separate projects built on this package. This
84
87
 
85
88
  ## Status
86
89
 
87
- 0.4.0, published on npm as [`@eriknaslund/skiss`](https://www.npmjs.com/package/@eriknaslund/skiss). The language parses, resolves and diagnoses; `skiss diagram` draws it, `skiss compile` writes LinkML that the real LinkML toolchain validates in CI, and `skiss import` reads a LinkML schema back into a sketch. The library exports the same in both directions and is browser-safe.
90
+ 0.5.0, published on npm as [`@eriknaslund/skiss`](https://www.npmjs.com/package/@eriknaslund/skiss). The language parses, resolves and diagnoses; `skiss diagram` draws it, `skiss compile` writes LinkML that the real LinkML toolchain validates in CI, and `skiss import` reads a LinkML schema back into a sketch. The library exports the same in both directions and is browser-safe.
88
91
 
89
- The [specification](docs/SPEC.md) is at 0.2 and moves when the language does, which is not with every release. Not built: inheritance, and editing a projected schema so that what Skiss cannot express survives the edit (SPEC §8.1). [CHANGELOG.md](CHANGELOG.md) is what changed when.
92
+ The [specification](docs/SPEC.md) is at 0.3 and moves when the language does, which is not with every release. Not built: editing a projected schema so that what Skiss cannot express survives the edit (SPEC §8.1). [CHANGELOG.md](CHANGELOG.md) is what changed when.
90
93
 
91
94
  ## License
92
95
 
package/dist/cli.js CHANGED
@@ -1,10 +1,92 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFile, writeFile } from "fs/promises";
5
- import { basename, extname } from "path";
4
+ import { spawnSync } from "child_process";
5
+ import { constants } from "fs";
6
+ import { access, mkdtemp, readFile, rm, writeFile } from "fs/promises";
7
+ import { tmpdir } from "os";
8
+ import { basename, delimiter, extname, join } from "path";
6
9
  import { parseArgs } from "util";
7
10
 
11
+ // src/inheritance.ts
12
+ function inheritance(classes) {
13
+ const declared = /* @__PURE__ */ new Map();
14
+ for (const cls of classes) {
15
+ if (!declared.has(cls.name.text)) declared.set(cls.name.text, cls);
16
+ }
17
+ const circles = /* @__PURE__ */ new Map();
18
+ const parentOf = (cls) => {
19
+ if (cls.parent === void 0 || circles.has(cls)) return void 0;
20
+ return declared.get(cls.parent.text);
21
+ };
22
+ for (const cls of declared.values()) {
23
+ const path = [cls];
24
+ const seen = new Set(path);
25
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
26
+ if (seen.has(next)) {
27
+ const circle = path.slice(path.indexOf(next));
28
+ let closer = circle[0];
29
+ for (const member2 of circle) {
30
+ if (member2.line > closer.line) closer = member2;
31
+ }
32
+ const at = circle.indexOf(closer);
33
+ circles.set(closer, [...circle.slice(at), ...circle.slice(0, at)]);
34
+ break;
35
+ }
36
+ path.push(next);
37
+ seen.add(next);
38
+ }
39
+ }
40
+ const ancestors = (cls) => {
41
+ const out = [];
42
+ const seen = /* @__PURE__ */ new Set([cls]);
43
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
44
+ if (seen.has(next)) break;
45
+ out.push(next);
46
+ seen.add(next);
47
+ }
48
+ return out;
49
+ };
50
+ const find = (cls, matches) => {
51
+ for (const from of ancestors(cls)) {
52
+ const field = from.fields.find(matches);
53
+ if (field !== void 0) return { field, from };
54
+ }
55
+ return void 0;
56
+ };
57
+ return {
58
+ ancestors,
59
+ inherited: (cls, fieldName) => find(cls, (field) => field.name.text === fieldName),
60
+ inheritedIdentifier: (cls) => find(cls, (field) => field.identifier),
61
+ circle: (cls) => circles.get(cls)
62
+ };
63
+ }
64
+ function sameField(child, inheritedField) {
65
+ return meaning(child) === meaning(inheritedField);
66
+ }
67
+ var meaning = (field) => JSON.stringify([
68
+ field.name.text,
69
+ field.identifier,
70
+ typeMeaning(field),
71
+ field.system?.text,
72
+ field.joinsTo === void 0 ? void 0 : [field.joinsTo.className.text, field.joinsTo.fieldName.text],
73
+ field.description,
74
+ field.note
75
+ ]);
76
+ function typeMeaning(field) {
77
+ const type = field.type;
78
+ if (type === void 0) return void 0;
79
+ switch (type.kind) {
80
+ case "primitive":
81
+ return [type.kind, type.name, type.many];
82
+ case "class":
83
+ case "unknown":
84
+ return [type.kind, type.name.text, type.many];
85
+ case "enum":
86
+ return [type.kind, type.values.map((v) => v.text), type.many];
87
+ }
88
+ }
89
+
8
90
  // src/parse.ts
9
91
  var isWs = (c) => c === " " || c === " ";
10
92
  var trimWs = (s) => s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
@@ -28,7 +110,7 @@ function primitiveFor(word) {
28
110
  return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
29
111
  }
30
112
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
31
- var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
113
+ var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
32
114
  function parse(source) {
33
115
  const classes = [];
34
116
  const diagnostics = [];
@@ -115,7 +197,7 @@ function splitTrailer(text) {
115
197
  return trailer;
116
198
  }
117
199
  var WORD = /[\p{L}\p{N}_-]+/uy;
118
- var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
200
+ var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
119
201
  function tokenize(head) {
120
202
  const tokens = [];
121
203
  let i = 0;
@@ -177,6 +259,13 @@ function parseClassLine(text, line) {
177
259
  );
178
260
  }
179
261
  const node = { name: name(head, line), line };
262
+ const lt = peek(c);
263
+ if (lt?.kind === "<") {
264
+ take(c);
265
+ const parent = parseParent(c, lt);
266
+ if (!parent.ok) return parent;
267
+ node.parent = parent.value;
268
+ }
180
269
  const at = peek(c);
181
270
  if (at?.kind === "@") {
182
271
  take(c);
@@ -306,6 +395,31 @@ function parseFieldLine(text, line) {
306
395
  if (trailer.note !== void 0) node.note = trailer.note;
307
396
  return { ok: true, value: node };
308
397
  }
398
+ function parseParent(c, lt) {
399
+ const parent = take(c);
400
+ if (parent === void 0 || parent.kind !== "word") {
401
+ return fail(
402
+ "E_UNPARSABLE",
403
+ "Expected a class name after `<`, e.g. `Jedi < Character`",
404
+ c.line,
405
+ parent ?? afterSpan(lt)
406
+ );
407
+ }
408
+ if (!CLASS_NAME.test(parent.text)) {
409
+ const message = Object.hasOwn(PRIMITIVES, parent.text) ? `\`${parent.text}\` is a primitive, not a class; a parent is a class` : `The class after \`<\` must be UpperCamelCase: \`${parent.text}\``;
410
+ return fail("E_BAD_NAME", message, c.line, parent);
411
+ }
412
+ const next = peek(c);
413
+ if (next?.kind === "," || next?.kind === "<") {
414
+ return fail(
415
+ "E_UNPARSABLE",
416
+ "A class has one parent; `<` takes a single class name",
417
+ c.line,
418
+ next
419
+ );
420
+ }
421
+ return { ok: true, value: name(parent, c.line) };
422
+ }
309
423
  function parseSystem(c, at) {
310
424
  const system = take(c);
311
425
  if (system === void 0 || system.kind !== "word") {
@@ -415,7 +529,7 @@ function parseType(c, colon) {
415
529
  );
416
530
  }
417
531
  function unexpected(token, line, kind, order) {
418
- const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
532
+ const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
419
533
  if (modifiers.includes(token.kind)) {
420
534
  return fail(
421
535
  "E_UNPARSABLE",
@@ -424,10 +538,10 @@ function unexpected(token, line, kind, order) {
424
538
  token
425
539
  );
426
540
  }
427
- if (token.text === "<") {
541
+ if (token.kind === "<") {
428
542
  return fail(
429
543
  "E_UNPARSABLE",
430
- "`<` is reserved for inheritance and not yet part of the language",
544
+ "`<` marks inheritance and belongs on the class line: `Child < Parent`",
431
545
  line,
432
546
  token
433
547
  );
@@ -456,10 +570,14 @@ function resolve(doc) {
456
570
  };
457
571
  const classes = out.classes;
458
572
  const declared = new Set(classes.map((c) => c.name.text));
459
- const warnings = [];
573
+ const found = [];
460
574
  const undeclared = /* @__PURE__ */ new Map();
575
+ const tree = inheritance(classes);
461
576
  const warn = (code, message, at) => {
462
- warnings.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
577
+ found.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
578
+ };
579
+ const error = (code, message, at) => {
580
+ found.push({ severity: "error", code, message, line: at.line, col: at.col, end: at.end });
463
581
  };
464
582
  const reference = (name2) => {
465
583
  if (declared.has(name2.text)) return true;
@@ -480,9 +598,25 @@ function resolve(doc) {
480
598
  cls.name
481
599
  );
482
600
  }
601
+ const parent = cls.parent;
602
+ if (parent !== void 0) {
603
+ reference(parent);
604
+ const circle = tree.circle(cls);
605
+ if (circle !== void 0) {
606
+ const through = circle.slice(1).map((member2) => `\`${member2.name.text}\``);
607
+ const path = through.length === 0 ? "" : ` through ${through.join(", ")}`;
608
+ error(
609
+ "E_INHERITANCE_CYCLE",
610
+ `class \`${cls.name.text}\` inherits from itself${path}; this \`<\` is not carried`,
611
+ parent
612
+ );
613
+ cls.parent = void 0;
614
+ }
615
+ }
483
616
  if (cls.similarTo !== void 0) reference(cls.similarTo);
484
617
  const firstField = /* @__PURE__ */ new Map();
485
- let identifier;
618
+ const inheritedIdentifier = tree.inheritedIdentifier(cls);
619
+ let identifier = inheritedIdentifier?.field;
486
620
  for (const field of cls.fields) {
487
621
  const earlierField = firstField.get(field.name.text);
488
622
  if (earlierField === void 0) firstField.set(field.name.text, field);
@@ -496,14 +630,23 @@ function resolve(doc) {
496
630
  if (field.identifier) {
497
631
  if (identifier === void 0) identifier = field;
498
632
  else {
633
+ const from = identifier === inheritedIdentifier?.field ? ` inherits the identifier \`${identifier.name.text}\` from class \`${inheritedIdentifier.from.name.text}\`, line` : ` already has the identifier \`${identifier.name.text}\` on line`;
499
634
  warn(
500
635
  "W_MULTIPLE_IDENTIFIERS",
501
- `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\` already has the identifier \`${identifier.name.text}\` on line ${identifier.line}`,
636
+ `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
502
637
  field.identifierAt ?? field.name
503
638
  );
504
639
  field.identifier = false;
505
640
  }
506
641
  }
642
+ const replaced = tree.inherited(cls, field.name.text);
643
+ if (replaced !== void 0 && sameField(field, replaced.field)) {
644
+ warn(
645
+ "W_REDUNDANT_OVERRIDE",
646
+ `field \`${field.name.text}\` is identical to the one inherited from class \`${replaced.from.name.text}\` on line ${replaced.field.line}`,
647
+ field.name
648
+ );
649
+ }
507
650
  if (field.type?.kind === "unknown") {
508
651
  const word = field.type.name.text;
509
652
  const suggestion = closestTypeWord(word);
@@ -541,8 +684,8 @@ function resolve(doc) {
541
684
  }
542
685
  }
543
686
  const present2 = new Set(out.diagnostics.map(key));
544
- for (const w of warnings) {
545
- if (!present2.has(key(w))) out.diagnostics.push(w);
687
+ for (const d of found) {
688
+ if (!present2.has(key(d))) out.diagnostics.push(d);
546
689
  }
547
690
  out.diagnostics.sort((a, b) => a.line - b.line);
548
691
  out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
@@ -573,6 +716,7 @@ function copyField(f) {
573
716
  }
574
717
  function copyClass(c) {
575
718
  const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
719
+ if (c.parent !== void 0) out.parent = copyName(c.parent);
576
720
  if (c.system !== void 0) out.system = copyName(c.system);
577
721
  if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
578
722
  return out;
@@ -751,6 +895,7 @@ function buildClass(cls, mappingPrefix, enums) {
751
895
  const { node, fields } = cls;
752
896
  const out = {};
753
897
  if (node.description !== void 0) out.description = node.description;
898
+ if (node.parent !== void 0) out.is_a = node.parent.text;
754
899
  const annotations = {};
755
900
  if (node.system !== void 0) annotations.system = node.system.text;
756
901
  if (node.note !== void 0) annotations.note = node.note;
@@ -833,6 +978,7 @@ function toMermaid(doc, opts) {
833
978
  }
834
979
  for (const cls of resolved.classes) {
835
980
  const from = cls.name.text;
981
+ if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
836
982
  if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
837
983
  for (const field of cls.fields) {
838
984
  const type = field.type;
@@ -939,7 +1085,7 @@ function toSkiss(doc) {
939
1085
  `;
940
1086
  }
941
1087
  function classBlock(cls) {
942
- const head = cls.name.text + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
1088
+ const head = cls.name.text + (cls.parent === void 0 ? "" : ` < ${cls.parent.text}`) + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
943
1089
  return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
944
1090
  }
945
1091
  function fieldLine(field) {
@@ -993,7 +1139,14 @@ var PRIMITIVE_RANGE = {
993
1139
  uri: "uri",
994
1140
  uriorcurie: "uri"
995
1141
  };
996
- var CLASS_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "close_mappings", "attributes", "slots"]);
1142
+ var CLASS_KEYS = /* @__PURE__ */ new Set([
1143
+ "description",
1144
+ "is_a",
1145
+ "annotations",
1146
+ "close_mappings",
1147
+ "attributes",
1148
+ "slots"
1149
+ ]);
997
1150
  var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
998
1151
  var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
999
1152
  var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
@@ -1036,7 +1189,7 @@ function fromLinkML(schema) {
1036
1189
  for (const key2 of Object.keys(classDefs)) {
1037
1190
  const names = /* @__PURE__ */ new Map();
1038
1191
  const taken = /* @__PURE__ */ new Set();
1039
- for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) {
1192
+ for (const slot of fieldKeysOf(key2)) {
1040
1193
  names.set(slot, unique(toFieldName(slot), taken));
1041
1194
  }
1042
1195
  fieldNames.set(key2, names);
@@ -1106,6 +1259,12 @@ function fromLinkML(schema) {
1106
1259
  const note = oneLine(rawNote);
1107
1260
  if (note !== void 0) node.note = note;
1108
1261
  } else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
1262
+ const parent = stringOf(get(def, "is_a"));
1263
+ if (parent !== void 0) {
1264
+ const parentName = Object.hasOwn(classDefs, parent) ? classNameOf(parent) : toClassName(parent);
1265
+ node.parent = at0(parentName);
1266
+ reference(name2, parent, parentName);
1267
+ }
1109
1268
  const mappings = stringList(get(def, "close_mappings"));
1110
1269
  const first = mappings[0];
1111
1270
  const target = first === void 0 ? "" : localName(first);
@@ -1128,6 +1287,9 @@ function fromLinkML(schema) {
1128
1287
  }
1129
1288
  }
1130
1289
  const block = get(def, other);
1290
+ if (other === "is_a" && present(block) && stringOf(block) === void 0) {
1291
+ report("is_a", name2, `${shown(block)} is not a class name`);
1292
+ }
1131
1293
  if (other === "attributes" && present(block) && asRecord(block) === void 0) {
1132
1294
  report("attributes", name2, `${shown(block)} is not a map`);
1133
1295
  }
@@ -1142,10 +1304,12 @@ function fromLinkML(schema) {
1142
1304
  report("slot_usage", name2);
1143
1305
  continue;
1144
1306
  }
1307
+ const written = slotKeysOf(def);
1308
+ const inherited = inheritedSlots(key2);
1145
1309
  for (const slot of Object.keys(usage)) {
1146
- if (fieldNames.get(key2)?.has(slot) === true) {
1310
+ if (written.includes(slot)) {
1147
1311
  report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
1148
- } else {
1312
+ } else if (!inherited.has(slot)) {
1149
1313
  report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
1150
1314
  }
1151
1315
  }
@@ -1153,9 +1317,10 @@ function fromLinkML(schema) {
1153
1317
  }
1154
1318
  report(other, name2);
1155
1319
  }
1156
- for (const slot of slotKeysOf(def)) {
1320
+ const own = slotKeysOf(def);
1321
+ for (const slot of fieldKeysOf(key2)) {
1157
1322
  const attributes = asRecord(get(def, "attributes")) ?? {};
1158
- const rawSlot = Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1323
+ const rawSlot = !own.includes(slot) ? get(asRecord(get(def, "slot_usage")) ?? {}, slot) : Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1159
1324
  const slotDef = asRecord(rawSlot);
1160
1325
  if (slotDef === void 0 && present(rawSlot)) {
1161
1326
  report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
@@ -1292,6 +1457,34 @@ function fromLinkML(schema) {
1292
1457
  report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
1293
1458
  return { kind: "unknown", name: at0(word), many };
1294
1459
  }
1460
+ function parentKeyOf(classKey) {
1461
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1462
+ const parent = stringOf(get(def, "is_a"));
1463
+ if (parent === void 0 || !Object.hasOwn(classDefs, parent)) return void 0;
1464
+ return parent;
1465
+ }
1466
+ function inheritedSlots(classKey) {
1467
+ const out = /* @__PURE__ */ new Set();
1468
+ const seen = /* @__PURE__ */ new Set([classKey]);
1469
+ let key2 = parentKeyOf(classKey);
1470
+ while (key2 !== void 0 && !seen.has(key2)) {
1471
+ seen.add(key2);
1472
+ for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) out.add(slot);
1473
+ key2 = parentKeyOf(key2);
1474
+ }
1475
+ return out;
1476
+ }
1477
+ function fieldKeysOf(classKey) {
1478
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1479
+ const keys = slotKeysOf(def);
1480
+ const usage = asRecord(get(def, "slot_usage"));
1481
+ if (usage === void 0) return keys;
1482
+ const inherited = inheritedSlots(classKey);
1483
+ for (const slot of Object.keys(usage)) {
1484
+ if (inherited.has(slot) && !keys.includes(slot)) keys.push(slot);
1485
+ }
1486
+ return keys;
1487
+ }
1295
1488
  function listedSomewhere(slot) {
1296
1489
  for (const key2 of Object.keys(classDefs)) {
1297
1490
  const def = asRecord(get(classDefs, key2)) ?? {};
@@ -1474,7 +1667,7 @@ function notYAML(error) {
1474
1667
  }
1475
1668
 
1476
1669
  // src/index.ts
1477
- var VERSION = "0.4.0";
1670
+ var VERSION = "0.5.0";
1478
1671
 
1479
1672
  // src/cli.ts
1480
1673
  var HELP = `Usage: skiss <command> [options]
@@ -1483,15 +1676,22 @@ Commands:
1483
1676
  diagram <file> Write a Mermaid class diagram for a .skiss file.
1484
1677
  compile <file> Write a LinkML schema for a .skiss file.
1485
1678
  import <file> Write Skiss for a LinkML schema file, YAML or JSON.
1679
+ render <file> Write an SVG or a PNG of the class diagram, through
1680
+ the Mermaid CLI (mmdc), which must be installed.
1486
1681
  <file> may be - to read standard input.
1487
1682
 
1488
1683
  Options:
1489
1684
  -o, --output <path> Write the output to <path> instead of standard output
1490
- --notes diagram: emit \`? text\` doubts as Mermaid notes
1685
+ --notes diagram, render: emit \`? text\` doubts as Mermaid notes
1491
1686
  --json compile: write the schema as JSON instead of YAML
1492
1687
  --name <name> compile: the schema name. Defaults to the file's
1493
1688
  basename without its extension, or \`sketch\` for
1494
1689
  standard input.
1690
+ --format <fmt> render: svg or png. Defaults to the extension of
1691
+ -o, and to svg when there is no -o. png needs an -o:
1692
+ only the SVG can go to standard output.
1693
+ --scale <n> render: the scale mmdc rasterises at. Defaults to 2
1694
+ for png, and to mmdc's own default for svg.
1495
1695
  --strict Exit 1 when the input has any diagnostic; for import,
1496
1696
  also when the projection dropped anything
1497
1697
  -h, --help Show this help and exit
@@ -1508,8 +1708,11 @@ Exit codes:
1508
1708
  0 the output was produced
1509
1709
  1 --strict and the input has diagnostics (the output is still written)
1510
1710
  2 usage error, missing file, or unreadable input: for import, a text no
1511
- schema could be read out of, which produces no sketch at all
1711
+ schema could be read out of, which produces no sketch at all; for
1712
+ render, the Mermaid CLI not being installed
1512
1713
  70 internal error, a bug in skiss
1714
+
1715
+ render exits with mmdc's own status when mmdc ran and failed.
1513
1716
  `;
1514
1717
  var EXIT_OK = 0;
1515
1718
  var EXIT_STRICT = 1;
@@ -1518,7 +1721,8 @@ var EXIT_INTERNAL = 70;
1518
1721
  var COMMANDS = {
1519
1722
  diagram: ["output", "notes", "strict"],
1520
1723
  compile: ["output", "json", "name", "strict"],
1521
- import: ["output", "strict"]
1724
+ import: ["output", "strict"],
1725
+ render: ["output", "notes", "format", "scale", "strict"]
1522
1726
  };
1523
1727
  var isCommand = (text) => Object.hasOwn(COMMANDS, text);
1524
1728
  var UsageError = class extends Error {
@@ -1540,6 +1744,8 @@ async function main(argv) {
1540
1744
  notes: { type: "boolean" },
1541
1745
  json: { type: "boolean" },
1542
1746
  name: { type: "string" },
1747
+ format: { type: "string" },
1748
+ scale: { type: "string" },
1543
1749
  strict: { type: "boolean" },
1544
1750
  help: { type: "boolean", short: "h" },
1545
1751
  version: { type: "boolean" }
@@ -1569,8 +1775,16 @@ async function main(argv) {
1569
1775
  }
1570
1776
  }
1571
1777
  const source = file === "-" ? await readStdin() : await readSource(file);
1572
- const { output, diagnostics, dropped } = runCommand(command, source, file, values);
1573
1778
  const label = file === "-" ? "<stdin>" : file;
1779
+ if (command === "render") {
1780
+ const picture = compile(source, { target: "mermaid", notes: values.notes });
1781
+ for (const d of picture.diagnostics) process.stderr.write(`${formatDiagnostic(d, label)}
1782
+ `);
1783
+ const status = await render(picture.output, values);
1784
+ if (status !== EXIT_OK) return status;
1785
+ return values.strict === true && picture.diagnostics.length > 0 ? EXIT_STRICT : EXIT_OK;
1786
+ }
1787
+ const { output, diagnostics, dropped } = runCommand(command, source, file, values);
1574
1788
  const report = formatDropped(dropped);
1575
1789
  if (report !== "") process.stderr.write(`${report}
1576
1790
  `);
@@ -1625,6 +1839,76 @@ async function writeOutput(path, text) {
1625
1839
  throw new UsageError(`cannot write \`${path}\`: ${reason(error)}`);
1626
1840
  }
1627
1841
  }
1842
+ var MMDC_MISSING = "skiss render needs the Mermaid CLI: npm install -g @mermaid-js/mermaid-cli";
1843
+ var isFormat = (text) => text === "svg" || text === "png";
1844
+ async function render(mermaid, values) {
1845
+ const format = renderFormat(values);
1846
+ const scale = renderScale(values.scale, format);
1847
+ const mmdc = await findMmdc();
1848
+ if (mmdc === void 0) {
1849
+ process.stderr.write(`${MMDC_MISSING}
1850
+ `);
1851
+ return EXIT_USAGE;
1852
+ }
1853
+ const dir = await mkdtemp(join(tmpdir(), "skiss-render-"));
1854
+ try {
1855
+ const input = join(dir, "diagram.mmd");
1856
+ await writeFile(input, mermaid, "utf8");
1857
+ const target = values.output ?? join(dir, `diagram.${format}`);
1858
+ const args = ["-i", input, "-o", target, "-e", format, "-b", "transparent"];
1859
+ if (scale !== void 0) args.push("--scale", scale);
1860
+ const run = spawnSync(mmdc, args, {
1861
+ stdio: ["ignore", "pipe", "inherit"],
1862
+ encoding: "utf8"
1863
+ });
1864
+ if (run.error !== void 0)
1865
+ throw new UsageError(`cannot run \`${mmdc}\`: ${reason(run.error)}`);
1866
+ if (run.stdout !== "") process.stderr.write(run.stdout);
1867
+ if (run.status !== EXIT_OK) return run.status ?? EXIT_INTERNAL;
1868
+ if (values.output === void 0) process.stdout.write(await readFile(target, "utf8"));
1869
+ return EXIT_OK;
1870
+ } finally {
1871
+ await rm(dir, { recursive: true, force: true });
1872
+ }
1873
+ }
1874
+ function renderFormat(values) {
1875
+ if (values.format !== void 0) {
1876
+ if (!isFormat(values.format)) {
1877
+ throw new UsageError(`unknown \`--format ${values.format}\`: svg or png`);
1878
+ }
1879
+ if (values.format === "png" && values.output === void 0) {
1880
+ throw new UsageError(
1881
+ "render --format png needs -o <path>: a PNG cannot go to standard output"
1882
+ );
1883
+ }
1884
+ return values.format;
1885
+ }
1886
+ if (values.output === void 0) return "svg";
1887
+ const extension = extname(values.output).slice(1).toLowerCase();
1888
+ if (isFormat(extension)) return extension;
1889
+ throw new UsageError(
1890
+ `cannot tell the format of \`${values.output}\`: give --format svg or --format png`
1891
+ );
1892
+ }
1893
+ function renderScale(scale, format) {
1894
+ if (scale === void 0) return format === "png" ? "2" : void 0;
1895
+ const value = Number(scale);
1896
+ if (!Number.isFinite(value) || value <= 0) {
1897
+ throw new UsageError(`\`--scale ${scale}\` is not a positive number`);
1898
+ }
1899
+ return scale;
1900
+ }
1901
+ async function findMmdc() {
1902
+ const onPath = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== "").map((dir) => join(dir, "mmdc"));
1903
+ for (const candidate of [...onPath, join(process.cwd(), "node_modules", ".bin", "mmdc")]) {
1904
+ try {
1905
+ await access(candidate, constants.X_OK);
1906
+ return candidate;
1907
+ } catch {
1908
+ }
1909
+ }
1910
+ return void 0;
1911
+ }
1628
1912
  function reason(error) {
1629
1913
  if (error instanceof Error) {
1630
1914
  const code = error.code;
package/dist/index.d.ts CHANGED
@@ -55,6 +55,12 @@ interface FieldNode {
55
55
  }
56
56
  interface ClassNode {
57
57
  name: Name;
58
+ /**
59
+ * The class after `<` (SPEC §3.10), set by `parse` whenever one is written.
60
+ * `resolve` clears it on the class whose `<` closes a circle, as it clears
61
+ * `identifier` on a second `*`, so no generator has to know about circles.
62
+ */
63
+ parent?: Name;
58
64
  system?: Name;
59
65
  similarTo?: Name;
60
66
  description?: string;
@@ -65,10 +71,11 @@ interface ClassNode {
65
71
  type Severity = 'error' | 'warning';
66
72
  /**
67
73
  * Codes from the tables in docs/ARCHITECTURE.md. `parse` adds the `E_` codes,
68
- * `resolve` the `W_` codes, and `importLinkML` the one code of its own,
74
+ * `resolve` the `W_` codes and `E_INHERITANCE_CYCLE`, the one error no single
75
+ * line can see (SPEC §7), and `importLinkML` the one code of its own,
69
76
  * `E_NOT_YAML`, for a text no schema can be read out of.
70
77
  */
71
- type DiagnosticCode = 'E_UNPARSABLE' | 'E_FIELD_WITHOUT_CLASS' | 'E_MISSING_TYPE' | 'E_UNCLOSED_MANY' | 'E_BAD_NAME' | 'W_UNKNOWN_TYPE' | 'W_UNDECLARED_CLASS' | 'W_UNDECLARED_FIELD' | 'W_DUPLICATE_CLASS' | 'W_DUPLICATE_FIELD' | 'W_DUPLICATE_ENUM_VALUE' | 'W_MULTIPLE_IDENTIFIERS' | 'E_NOT_YAML';
78
+ type DiagnosticCode = 'E_UNPARSABLE' | 'E_FIELD_WITHOUT_CLASS' | 'E_MISSING_TYPE' | 'E_UNCLOSED_MANY' | 'E_BAD_NAME' | 'E_INHERITANCE_CYCLE' | 'W_UNKNOWN_TYPE' | 'W_UNDECLARED_CLASS' | 'W_UNDECLARED_FIELD' | 'W_DUPLICATE_CLASS' | 'W_DUPLICATE_FIELD' | 'W_DUPLICATE_ENUM_VALUE' | 'W_MULTIPLE_IDENTIFIERS' | 'W_REDUNDANT_OVERRIDE' | 'E_NOT_YAML';
72
79
  interface Diagnostic {
73
80
  severity: Severity;
74
81
  code: DiagnosticCode;
@@ -106,9 +113,10 @@ interface LinkMLAttribute {
106
113
  identifier?: boolean;
107
114
  annotations?: LinkMLAnnotations;
108
115
  }
109
- /** One entry under `classes`. Key order is SPEC §5.3. */
116
+ /** One entry under `classes`. Key order is SPEC §5.1, Inheritance. */
110
117
  interface LinkMLClass {
111
118
  description?: string;
119
+ is_a?: string;
112
120
  annotations?: LinkMLAnnotations;
113
121
  close_mappings?: string[];
114
122
  attributes?: Record<string, LinkMLAttribute>;
@@ -215,6 +223,6 @@ interface ResolvedDocument extends Document {
215
223
  }
216
224
  declare function resolve(doc: Document): ResolvedDocument;
217
225
 
218
- declare const VERSION = "0.4.0";
226
+ declare const VERSION = "0.5.0";
219
227
 
220
228
  export { type ClassNode, type CompileOptions, type CompileResult, type Diagnostic, type DiagnosticCode, type Document, type Dropped, type FieldNode, type FromLinkMLResult, type ImportResult, type JoinRef, type LinkMLAnnotations, type LinkMLAttribute, type LinkMLClass, type LinkMLCompileOptions, type LinkMLEnum, type LinkMLOptions, type LinkMLSchema, type MermaidCompileOptions, type MermaidOptions, type Name, type Primitive, type ResolvedDocument, type SerializeFormat, type Severity, type TypeRef, VERSION, compile, formatDiagnostic, formatDropped, fromLinkML, importLinkML, parse, resolve, serialize, toLinkML, toMermaid, toSkiss };
package/dist/index.js CHANGED
@@ -1,3 +1,82 @@
1
+ // src/inheritance.ts
2
+ function inheritance(classes) {
3
+ const declared = /* @__PURE__ */ new Map();
4
+ for (const cls of classes) {
5
+ if (!declared.has(cls.name.text)) declared.set(cls.name.text, cls);
6
+ }
7
+ const circles = /* @__PURE__ */ new Map();
8
+ const parentOf = (cls) => {
9
+ if (cls.parent === void 0 || circles.has(cls)) return void 0;
10
+ return declared.get(cls.parent.text);
11
+ };
12
+ for (const cls of declared.values()) {
13
+ const path = [cls];
14
+ const seen = new Set(path);
15
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
16
+ if (seen.has(next)) {
17
+ const circle = path.slice(path.indexOf(next));
18
+ let closer = circle[0];
19
+ for (const member2 of circle) {
20
+ if (member2.line > closer.line) closer = member2;
21
+ }
22
+ const at = circle.indexOf(closer);
23
+ circles.set(closer, [...circle.slice(at), ...circle.slice(0, at)]);
24
+ break;
25
+ }
26
+ path.push(next);
27
+ seen.add(next);
28
+ }
29
+ }
30
+ const ancestors = (cls) => {
31
+ const out = [];
32
+ const seen = /* @__PURE__ */ new Set([cls]);
33
+ for (let next = parentOf(cls); next !== void 0; next = parentOf(next)) {
34
+ if (seen.has(next)) break;
35
+ out.push(next);
36
+ seen.add(next);
37
+ }
38
+ return out;
39
+ };
40
+ const find = (cls, matches) => {
41
+ for (const from of ancestors(cls)) {
42
+ const field = from.fields.find(matches);
43
+ if (field !== void 0) return { field, from };
44
+ }
45
+ return void 0;
46
+ };
47
+ return {
48
+ ancestors,
49
+ inherited: (cls, fieldName) => find(cls, (field) => field.name.text === fieldName),
50
+ inheritedIdentifier: (cls) => find(cls, (field) => field.identifier),
51
+ circle: (cls) => circles.get(cls)
52
+ };
53
+ }
54
+ function sameField(child, inheritedField) {
55
+ return meaning(child) === meaning(inheritedField);
56
+ }
57
+ var meaning = (field) => JSON.stringify([
58
+ field.name.text,
59
+ field.identifier,
60
+ typeMeaning(field),
61
+ field.system?.text,
62
+ field.joinsTo === void 0 ? void 0 : [field.joinsTo.className.text, field.joinsTo.fieldName.text],
63
+ field.description,
64
+ field.note
65
+ ]);
66
+ function typeMeaning(field) {
67
+ const type = field.type;
68
+ if (type === void 0) return void 0;
69
+ switch (type.kind) {
70
+ case "primitive":
71
+ return [type.kind, type.name, type.many];
72
+ case "class":
73
+ case "unknown":
74
+ return [type.kind, type.name.text, type.many];
75
+ case "enum":
76
+ return [type.kind, type.values.map((v) => v.text), type.many];
77
+ }
78
+ }
79
+
1
80
  // src/parse.ts
2
81
  var isWs = (c) => c === " " || c === " ";
3
82
  var trimWs = (s) => s.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
@@ -21,7 +100,7 @@ function primitiveFor(word) {
21
100
  return Object.hasOwn(PRIMITIVES, word) ? PRIMITIVES[word] : void 0;
22
101
  }
23
102
  var FIELD_ORDER = "name, `*`, `: type`, `@System`, `= Class.field`, then `#` and `?`";
24
- var CLASS_ORDER = "name, `@System`, `~ Class`, then `#` and `?`";
103
+ var CLASS_ORDER = "name, `< Parent`, `@System`, `~ Class`, then `#` and `?`";
25
104
  function parse(source) {
26
105
  const classes = [];
27
106
  const diagnostics = [];
@@ -108,7 +187,7 @@ function splitTrailer(text) {
108
187
  return trailer;
109
188
  }
110
189
  var WORD = /[\p{L}\p{N}_-]+/uy;
111
- var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "["];
190
+ var OPERATORS = ["*", ":", "@", "~", "=", ".", "|", "<", ",", "["];
112
191
  function tokenize(head) {
113
192
  const tokens = [];
114
193
  let i = 0;
@@ -170,6 +249,13 @@ function parseClassLine(text, line) {
170
249
  );
171
250
  }
172
251
  const node = { name: name(head, line), line };
252
+ const lt = peek(c);
253
+ if (lt?.kind === "<") {
254
+ take(c);
255
+ const parent = parseParent(c, lt);
256
+ if (!parent.ok) return parent;
257
+ node.parent = parent.value;
258
+ }
173
259
  const at = peek(c);
174
260
  if (at?.kind === "@") {
175
261
  take(c);
@@ -299,6 +385,31 @@ function parseFieldLine(text, line) {
299
385
  if (trailer.note !== void 0) node.note = trailer.note;
300
386
  return { ok: true, value: node };
301
387
  }
388
+ function parseParent(c, lt) {
389
+ const parent = take(c);
390
+ if (parent === void 0 || parent.kind !== "word") {
391
+ return fail(
392
+ "E_UNPARSABLE",
393
+ "Expected a class name after `<`, e.g. `Jedi < Character`",
394
+ c.line,
395
+ parent ?? afterSpan(lt)
396
+ );
397
+ }
398
+ if (!CLASS_NAME.test(parent.text)) {
399
+ const message = Object.hasOwn(PRIMITIVES, parent.text) ? `\`${parent.text}\` is a primitive, not a class; a parent is a class` : `The class after \`<\` must be UpperCamelCase: \`${parent.text}\``;
400
+ return fail("E_BAD_NAME", message, c.line, parent);
401
+ }
402
+ const next = peek(c);
403
+ if (next?.kind === "," || next?.kind === "<") {
404
+ return fail(
405
+ "E_UNPARSABLE",
406
+ "A class has one parent; `<` takes a single class name",
407
+ c.line,
408
+ next
409
+ );
410
+ }
411
+ return { ok: true, value: name(parent, c.line) };
412
+ }
302
413
  function parseSystem(c, at) {
303
414
  const system = take(c);
304
415
  if (system === void 0 || system.kind !== "word") {
@@ -408,7 +519,7 @@ function parseType(c, colon) {
408
519
  );
409
520
  }
410
521
  function unexpected(token, line, kind, order) {
411
- const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["@", "~"];
522
+ const modifiers = kind === "field" ? ["*", ":", "@", "="] : ["<", "@", "~"];
412
523
  if (modifiers.includes(token.kind)) {
413
524
  return fail(
414
525
  "E_UNPARSABLE",
@@ -417,10 +528,10 @@ function unexpected(token, line, kind, order) {
417
528
  token
418
529
  );
419
530
  }
420
- if (token.text === "<") {
531
+ if (token.kind === "<") {
421
532
  return fail(
422
533
  "E_UNPARSABLE",
423
- "`<` is reserved for inheritance and not yet part of the language",
534
+ "`<` marks inheritance and belongs on the class line: `Child < Parent`",
424
535
  line,
425
536
  token
426
537
  );
@@ -449,10 +560,14 @@ function resolve(doc) {
449
560
  };
450
561
  const classes = out.classes;
451
562
  const declared = new Set(classes.map((c) => c.name.text));
452
- const warnings = [];
563
+ const found = [];
453
564
  const undeclared = /* @__PURE__ */ new Map();
565
+ const tree = inheritance(classes);
454
566
  const warn = (code, message, at) => {
455
- warnings.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
567
+ found.push({ severity: "warning", code, message, line: at.line, col: at.col, end: at.end });
568
+ };
569
+ const error = (code, message, at) => {
570
+ found.push({ severity: "error", code, message, line: at.line, col: at.col, end: at.end });
456
571
  };
457
572
  const reference = (name2) => {
458
573
  if (declared.has(name2.text)) return true;
@@ -473,9 +588,25 @@ function resolve(doc) {
473
588
  cls.name
474
589
  );
475
590
  }
591
+ const parent = cls.parent;
592
+ if (parent !== void 0) {
593
+ reference(parent);
594
+ const circle = tree.circle(cls);
595
+ if (circle !== void 0) {
596
+ const through = circle.slice(1).map((member2) => `\`${member2.name.text}\``);
597
+ const path = through.length === 0 ? "" : ` through ${through.join(", ")}`;
598
+ error(
599
+ "E_INHERITANCE_CYCLE",
600
+ `class \`${cls.name.text}\` inherits from itself${path}; this \`<\` is not carried`,
601
+ parent
602
+ );
603
+ cls.parent = void 0;
604
+ }
605
+ }
476
606
  if (cls.similarTo !== void 0) reference(cls.similarTo);
477
607
  const firstField = /* @__PURE__ */ new Map();
478
- let identifier;
608
+ const inheritedIdentifier = tree.inheritedIdentifier(cls);
609
+ let identifier = inheritedIdentifier?.field;
479
610
  for (const field of cls.fields) {
480
611
  const earlierField = firstField.get(field.name.text);
481
612
  if (earlierField === void 0) firstField.set(field.name.text, field);
@@ -489,14 +620,23 @@ function resolve(doc) {
489
620
  if (field.identifier) {
490
621
  if (identifier === void 0) identifier = field;
491
622
  else {
623
+ const from = identifier === inheritedIdentifier?.field ? ` inherits the identifier \`${identifier.name.text}\` from class \`${inheritedIdentifier.from.name.text}\`, line` : ` already has the identifier \`${identifier.name.text}\` on line`;
492
624
  warn(
493
625
  "W_MULTIPLE_IDENTIFIERS",
494
- `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\` already has the identifier \`${identifier.name.text}\` on line ${identifier.line}`,
626
+ `\`*\` on \`${field.name.text}\` is ignored: class \`${cls.name.text}\`${from} ${identifier.line}`,
495
627
  field.identifierAt ?? field.name
496
628
  );
497
629
  field.identifier = false;
498
630
  }
499
631
  }
632
+ const replaced = tree.inherited(cls, field.name.text);
633
+ if (replaced !== void 0 && sameField(field, replaced.field)) {
634
+ warn(
635
+ "W_REDUNDANT_OVERRIDE",
636
+ `field \`${field.name.text}\` is identical to the one inherited from class \`${replaced.from.name.text}\` on line ${replaced.field.line}`,
637
+ field.name
638
+ );
639
+ }
500
640
  if (field.type?.kind === "unknown") {
501
641
  const word = field.type.name.text;
502
642
  const suggestion = closestTypeWord(word);
@@ -534,8 +674,8 @@ function resolve(doc) {
534
674
  }
535
675
  }
536
676
  const present2 = new Set(out.diagnostics.map(key));
537
- for (const w of warnings) {
538
- if (!present2.has(key(w))) out.diagnostics.push(w);
677
+ for (const d of found) {
678
+ if (!present2.has(key(d))) out.diagnostics.push(d);
539
679
  }
540
680
  out.diagnostics.sort((a, b) => a.line - b.line);
541
681
  out.undeclared = [...undeclared.values()].map((n) => ({ ...n }));
@@ -566,6 +706,7 @@ function copyField(f) {
566
706
  }
567
707
  function copyClass(c) {
568
708
  const out = { ...c, name: copyName(c.name), fields: c.fields.map(copyField) };
709
+ if (c.parent !== void 0) out.parent = copyName(c.parent);
569
710
  if (c.system !== void 0) out.system = copyName(c.system);
570
711
  if (c.similarTo !== void 0) out.similarTo = copyName(c.similarTo);
571
712
  return out;
@@ -744,6 +885,7 @@ function buildClass(cls, mappingPrefix, enums) {
744
885
  const { node, fields } = cls;
745
886
  const out = {};
746
887
  if (node.description !== void 0) out.description = node.description;
888
+ if (node.parent !== void 0) out.is_a = node.parent.text;
747
889
  const annotations = {};
748
890
  if (node.system !== void 0) annotations.system = node.system.text;
749
891
  if (node.note !== void 0) annotations.note = node.note;
@@ -826,6 +968,7 @@ function toMermaid(doc, opts) {
826
968
  }
827
969
  for (const cls of resolved.classes) {
828
970
  const from = cls.name.text;
971
+ if (cls.parent !== void 0) lines.push(` ${cls.parent.text} <|-- ${from}`);
829
972
  if (cls.similarTo !== void 0) lines.push(` ${from} ..> ${cls.similarTo.text} : similar`);
830
973
  for (const field of cls.fields) {
831
974
  const type = field.type;
@@ -932,7 +1075,7 @@ function toSkiss(doc) {
932
1075
  `;
933
1076
  }
934
1077
  function classBlock(cls) {
935
- const head = cls.name.text + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
1078
+ const head = cls.name.text + (cls.parent === void 0 ? "" : ` < ${cls.parent.text}`) + (cls.system === void 0 ? "" : ` @${cls.system.text}`) + (cls.similarTo === void 0 ? "" : ` ~ ${cls.similarTo.text}`);
936
1079
  return [withTrailer(head, cls), ...cls.fields.map(fieldLine)].join("\n");
937
1080
  }
938
1081
  function fieldLine(field) {
@@ -986,7 +1129,14 @@ var PRIMITIVE_RANGE = {
986
1129
  uri: "uri",
987
1130
  uriorcurie: "uri"
988
1131
  };
989
- var CLASS_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "close_mappings", "attributes", "slots"]);
1132
+ var CLASS_KEYS = /* @__PURE__ */ new Set([
1133
+ "description",
1134
+ "is_a",
1135
+ "annotations",
1136
+ "close_mappings",
1137
+ "attributes",
1138
+ "slots"
1139
+ ]);
990
1140
  var SLOT_KEYS = /* @__PURE__ */ new Set(["description", "annotations", "range", "multivalued", "identifier"]);
991
1141
  var CLASS_TAGS = /* @__PURE__ */ new Set(["system", "note", "undeclared"]);
992
1142
  var SLOT_TAGS = /* @__PURE__ */ new Set(["system", "note", "joins_to"]);
@@ -1029,7 +1179,7 @@ function fromLinkML(schema) {
1029
1179
  for (const key2 of Object.keys(classDefs)) {
1030
1180
  const names = /* @__PURE__ */ new Map();
1031
1181
  const taken = /* @__PURE__ */ new Set();
1032
- for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) {
1182
+ for (const slot of fieldKeysOf(key2)) {
1033
1183
  names.set(slot, unique(toFieldName(slot), taken));
1034
1184
  }
1035
1185
  fieldNames.set(key2, names);
@@ -1099,6 +1249,12 @@ function fromLinkML(schema) {
1099
1249
  const note = oneLine(rawNote);
1100
1250
  if (note !== void 0) node.note = note;
1101
1251
  } else if (rawNote !== void 0) report("annotation", name2, notText("note", rawNote));
1252
+ const parent = stringOf(get(def, "is_a"));
1253
+ if (parent !== void 0) {
1254
+ const parentName = Object.hasOwn(classDefs, parent) ? classNameOf(parent) : toClassName(parent);
1255
+ node.parent = at0(parentName);
1256
+ reference(name2, parent, parentName);
1257
+ }
1102
1258
  const mappings = stringList(get(def, "close_mappings"));
1103
1259
  const first = mappings[0];
1104
1260
  const target = first === void 0 ? "" : localName(first);
@@ -1121,6 +1277,9 @@ function fromLinkML(schema) {
1121
1277
  }
1122
1278
  }
1123
1279
  const block = get(def, other);
1280
+ if (other === "is_a" && present(block) && stringOf(block) === void 0) {
1281
+ report("is_a", name2, `${shown(block)} is not a class name`);
1282
+ }
1124
1283
  if (other === "attributes" && present(block) && asRecord(block) === void 0) {
1125
1284
  report("attributes", name2, `${shown(block)} is not a map`);
1126
1285
  }
@@ -1135,10 +1294,12 @@ function fromLinkML(schema) {
1135
1294
  report("slot_usage", name2);
1136
1295
  continue;
1137
1296
  }
1297
+ const written = slotKeysOf(def);
1298
+ const inherited = inheritedSlots(key2);
1138
1299
  for (const slot of Object.keys(usage)) {
1139
- if (fieldNames.get(key2)?.has(slot) === true) {
1300
+ if (written.includes(slot)) {
1140
1301
  report("slot_usage", `${name2}.${fieldNameOf(key2, slot)}`);
1141
- } else {
1302
+ } else if (!inherited.has(slot)) {
1142
1303
  report("slot_usage", name2, `\`${slot}\` is inherited and not in the sketch`);
1143
1304
  }
1144
1305
  }
@@ -1146,9 +1307,10 @@ function fromLinkML(schema) {
1146
1307
  }
1147
1308
  report(other, name2);
1148
1309
  }
1149
- for (const slot of slotKeysOf(def)) {
1310
+ const own = slotKeysOf(def);
1311
+ for (const slot of fieldKeysOf(key2)) {
1150
1312
  const attributes = asRecord(get(def, "attributes")) ?? {};
1151
- const rawSlot = Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1313
+ const rawSlot = !own.includes(slot) ? get(asRecord(get(def, "slot_usage")) ?? {}, slot) : Object.hasOwn(attributes, slot) ? get(attributes, slot) : get(slotDefs, slot);
1152
1314
  const slotDef = asRecord(rawSlot);
1153
1315
  if (slotDef === void 0 && present(rawSlot)) {
1154
1316
  report("slot", `${name2}.${fieldNameOf(key2, slot)}`, "is not a slot definition");
@@ -1285,6 +1447,34 @@ function fromLinkML(schema) {
1285
1447
  report("narrowed", element, `\`${range}\` is not a Skiss type; it falls back to string`);
1286
1448
  return { kind: "unknown", name: at0(word), many };
1287
1449
  }
1450
+ function parentKeyOf(classKey) {
1451
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1452
+ const parent = stringOf(get(def, "is_a"));
1453
+ if (parent === void 0 || !Object.hasOwn(classDefs, parent)) return void 0;
1454
+ return parent;
1455
+ }
1456
+ function inheritedSlots(classKey) {
1457
+ const out = /* @__PURE__ */ new Set();
1458
+ const seen = /* @__PURE__ */ new Set([classKey]);
1459
+ let key2 = parentKeyOf(classKey);
1460
+ while (key2 !== void 0 && !seen.has(key2)) {
1461
+ seen.add(key2);
1462
+ for (const slot of slotKeysOf(asRecord(get(classDefs, key2)) ?? {})) out.add(slot);
1463
+ key2 = parentKeyOf(key2);
1464
+ }
1465
+ return out;
1466
+ }
1467
+ function fieldKeysOf(classKey) {
1468
+ const def = asRecord(get(classDefs, classKey)) ?? {};
1469
+ const keys = slotKeysOf(def);
1470
+ const usage = asRecord(get(def, "slot_usage"));
1471
+ if (usage === void 0) return keys;
1472
+ const inherited = inheritedSlots(classKey);
1473
+ for (const slot of Object.keys(usage)) {
1474
+ if (inherited.has(slot) && !keys.includes(slot)) keys.push(slot);
1475
+ }
1476
+ return keys;
1477
+ }
1288
1478
  function listedSomewhere(slot) {
1289
1479
  for (const key2 of Object.keys(classDefs)) {
1290
1480
  const def = asRecord(get(classDefs, key2)) ?? {};
@@ -1467,7 +1657,7 @@ function notYAML(error) {
1467
1657
  }
1468
1658
 
1469
1659
  // src/index.ts
1470
- var VERSION = "0.4.0";
1660
+ var VERSION = "0.5.0";
1471
1661
  export {
1472
1662
  VERSION,
1473
1663
  compile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eriknaslund/skiss",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A text notation for sketching data models, compiled to LinkML",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,7 +41,8 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@biomejs/biome": "^2.5.13",
44
- "@types/node": "^24.0.0",
44
+ "@mermaid-js/mermaid-cli": "^11.17.0",
45
+ "@types/node": "^26.6.1",
45
46
  "jsdom": "^30.0.1",
46
47
  "mermaid": "^12.0.0",
47
48
  "tsup": "^8.5.1",