@peter.prib/renderengine 0.2.10 → 0.4.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.
Files changed (39) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +123 -2
  3. package/dist/renderengine-workspace.umd.js +3015 -1221
  4. package/dist/renderengine.umd.js +1220 -176
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelChecker.js +1 -5
  14. package/src/charting/datamodel/DataModelCompared.js +1 -31
  15. package/src/charting/datamodel/LevelSchema.js +23 -0
  16. package/src/charting/datamodel/SqlDdl.js +18 -13
  17. package/src/charting/diagramModel/ListCompared.js +35 -0
  18. package/src/charting/model/Benchmark.js +59 -0
  19. package/src/charting/model/EditableModel.js +104 -0
  20. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerExport.js +273 -0
  28. package/src/charting/requirements/index.js +7 -0
  29. package/src/index.js +6 -0
  30. package/src/workspace/Workspace.js +329 -321
  31. package/src/workspace/index.js +2 -0
  32. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  33. package/src/workspace/parts/DataModelPart.js +668 -735
  34. package/src/workspace/parts/EditorPart.js +1 -1
  35. package/src/workspace/parts/ModelFile.js +3 -1
  36. package/src/workspace/parts/ModelHosted.js +176 -0
  37. package/src/workspace/parts/ModelsPart.js +146 -7
  38. package/src/workspace/parts/RequirementsPart.js +465 -0
  39. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -19756,14 +19756,19 @@
19756
19756
 
19757
19757
  /**
19758
19758
  * Fruchterman–Reingold: lines pull their ends together, every pair of boxes
19759
- * pushes apart, cooling over the iterations. Deterministic: it starts from
19760
- * where the boxes are (boxes on one spot are spread round it first).
19759
+ * pushes apart, cooling over the iterations, all within a frame — a square
19760
+ * round where the boxes were, room for them a grid's worth — so a box with no
19761
+ * lines is not pushed away for ever. Deterministic: it starts from where the
19762
+ * boxes are (boxes on one spot are spread round it first).
19761
19763
  */
19762
19764
  function forceDirected(boxes, edges, gap) {
19763
19765
  const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
19764
19766
  const k = size * 1.2;
19765
19767
  const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
19766
19768
  const index = new Map(boxes.map((b, i) => [b.id, i]));
19769
+ const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
19770
+ const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
19771
+ const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
19767
19772
  let temperature = size * 2;
19768
19773
  for (let iteration = 0; iteration < 120; iteration++) {
19769
19774
  const shift = pos.map(() => ({ x: 0, y: 0 }));
@@ -19788,8 +19793,8 @@
19788
19793
  for (let i = 0; i < pos.length; i++) {
19789
19794
  const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
19790
19795
  const step = Math.min(d, temperature);
19791
- pos[i].x += (shift[i].x / d) * step;
19792
- pos[i].y += (shift[i].y / d) * step;
19796
+ pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
19797
+ pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
19793
19798
  }
19794
19799
  temperature *= 0.95;
19795
19800
  }
@@ -24639,6 +24644,8 @@
24639
24644
  this.instance = instance;
24640
24645
  this.model = model;
24641
24646
  this.nodeOptions = nodeOptions;
24647
+ /** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
24648
+ this.status = null;
24642
24649
  }
24643
24650
 
24644
24651
  /** State lives on the instance and the model element, not here. @protected */
@@ -24754,6 +24761,27 @@
24754
24761
  * gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
24755
24762
  */
24756
24763
  decorations(ctx) {
24764
+ return [...this._badge(ctx), ...this.statusPill(ctx)];
24765
+ }
24766
+
24767
+ /**
24768
+ * The host's status (`status`), as a small pill inside the box's bottom
24769
+ * right corner: its text, on its colour. Nothing without one.
24770
+ */
24771
+ statusPill({ w, h }) {
24772
+ const text = this.status?.text;
24773
+ if (!text) return [];
24774
+ const label = String(text);
24775
+ const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
24776
+ const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
24777
+ return [Glyph.group([
24778
+ Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
24779
+ Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
24780
+ ], { 'pointer-events': 'none', class: 'element-status' })];
24781
+ }
24782
+
24783
+ /** The type badge (decorations). @private */
24784
+ _badge(ctx) {
24757
24785
  const node = this.node;
24758
24786
  const { w, h } = ctx;
24759
24787
  const fixed = ArchimateElementTemplate.badgeSize(w, h);
@@ -28223,7 +28251,9 @@
28223
28251
  * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
28224
28252
  * each a button (⚠ a warning, ℹ for a look) that shows what it names
28225
28253
  * (`onReveal`), with what can be done about it beside (`actionsFor`), or
28226
- * "nothing found". Any editor or part that checks a model shows it this way.
28254
+ * "nothing found". Any editor or part that checks a model shows it this way;
28255
+ * anything else listed to be opened (search results) too, with its own
28256
+ * `title` and `empty` text.
28227
28257
  */
28228
28258
  class ModelCheckDialog {
28229
28259
  /**
@@ -28232,9 +28262,13 @@
28232
28262
  * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
28233
28263
  * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
28234
28264
  * @param {Document} [options.document]
28265
+ * @param {string} [options.title] - In place of "Model check: n findings"
28266
+ * @param {string} [options.empty] - What an empty list says
28235
28267
  */
28236
- constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
28268
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
28237
28269
  this.findings = findings;
28270
+ this._title = title;
28271
+ this.empty = empty;
28238
28272
  this.onReveal = onReveal;
28239
28273
  this.actionsFor = actionsFor;
28240
28274
  this.document = doc;
@@ -28242,6 +28276,7 @@
28242
28276
 
28243
28277
  /** The dialog's title: how many findings. */
28244
28278
  get title() {
28279
+ if (this._title) return this._title;
28245
28280
  const n = this.findings.length;
28246
28281
  return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
28247
28282
  }
@@ -28267,7 +28302,7 @@
28267
28302
  on: { click: () => { dialog?.close(); act.action(); } },
28268
28303
  })),
28269
28304
  ],
28270
- })) : [{ text: 'Nothing found: the model passes every check.' }],
28305
+ })) : [{ text: this.empty }],
28271
28306
  }, this.document);
28272
28307
  dialog = new FormDialog({
28273
28308
  title: this.title,
@@ -33184,8 +33219,12 @@
33184
33219
  * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
33185
33220
  * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
33186
33221
  * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
33222
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
33223
+ * live status for an element (a component's lifecycle state), drawn on its boxes
33224
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
33225
+ * element (its transitions), at the end of a box's right-click menu
33187
33226
  */
33188
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
33227
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
33189
33228
  super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
33190
33229
  this.view = view;
33191
33230
  this.model = model;
@@ -33198,6 +33237,9 @@
33198
33237
  this.settingsManager = settingsManager;
33199
33238
  /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
33200
33239
  this.templateMenu = templateMenu;
33240
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
33241
+ this.elementStatus = elementStatus;
33242
+ this.elementMenuExtras = elementMenuExtras;
33201
33243
  this.addDropHandler({
33202
33244
  accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
33203
33245
  drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
@@ -33235,6 +33277,7 @@
33235
33277
  ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
33236
33278
  ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
33237
33279
  ...(items.length ? [{ label: 'Save as template', items }] : []),
33280
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
33238
33281
  ];
33239
33282
  }
33240
33283
 
@@ -33459,7 +33502,11 @@
33459
33502
  templates.set(inst.id, existing?.instance === inst ? existing
33460
33503
  : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
33461
33504
  }
33462
- for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
33505
+ for (const t of templates.values()) {
33506
+ t.nodeOptions = this.nodeOptions;
33507
+ // Asked afresh at every sync: a host's status is live, not part of the model.
33508
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
33509
+ }
33463
33510
  // The view's groups (Archi groups: model folders with bounds on this view).
33464
33511
  for (const folder of view.placedFolders) {
33465
33512
  const existing = this.templates.get(folder.id);
@@ -33958,6 +34005,9 @@
33958
34005
  /** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
33959
34006
  get imports() { return true; }
33960
34007
 
34008
+ /** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
34009
+ get exports() { return true; }
34010
+
33961
34011
  /** True when import takes the file's bytes (a zip archive), else its text. */
33962
34012
  get readsBytes() { return false; }
33963
34013
 
@@ -34017,7 +34067,7 @@
34017
34067
  const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
34018
34068
  const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
34019
34069
  const groups = formats.map(format => [
34020
- only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
34070
+ only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
34021
34071
  only !== 'export' && format.imports && enabled(format, 'import') && {
34022
34072
  label: only ? `${format.label}…` : `Import ${format.label}…`,
34023
34073
  action: () => importFile(format, { choose, onImport, report: tell }),
@@ -41279,6 +41329,29 @@
41279
41329
  return this.entities.find(e => e.id === id) ?? null;
41280
41330
  }
41281
41331
 
41332
+ /** An entity by id, else by name (any case), or null. */
41333
+ named(text) {
41334
+ const s = String(text ?? '').trim();
41335
+ return this.entity(s) ?? this.entities.find(e => String(e.name ?? '').toLowerCase() === s.toLowerCase()) ?? null;
41336
+ }
41337
+
41338
+ /**
41339
+ * What a foreign key's `references` names: `{ entity, column }` (column
41340
+ * null when it names the table alone), or null. `table` or `table.column`,
41341
+ * the table by id or name; a table's own name may hold dots (a
41342
+ * schema-qualified `sales.customer`), so the whole text is tried as a
41343
+ * table first, then what is before its last dot, the rest its column.
41344
+ */
41345
+ resolveReference(ref) {
41346
+ const text = String(ref ?? '').trim();
41347
+ if (!text) return null;
41348
+ const whole = this.named(text);
41349
+ if (whole) return { entity: whole, column: null };
41350
+ const dot = text.lastIndexOf('.');
41351
+ const entity = dot > 0 ? this.named(text.slice(0, dot)) : null;
41352
+ return entity ? { entity, column: text.slice(dot + 1).trim() || null } : null;
41353
+ }
41354
+
41282
41355
  /** Add an entity (its id unique at this level). */
41283
41356
  addEntity(entity) {
41284
41357
  if (!entity?.id) throw new TypeError('An entity needs an id');
@@ -41719,17 +41792,13 @@
41719
41792
  foreignKeys() {
41720
41793
  return ATTRIBUTED.flatMap(level => {
41721
41794
  const schema = this.model.level(level);
41722
- const find = (ref) => {
41723
- const [name] = String(ref).split('.');
41724
- return schema.entity(name) ?? schema.entities.find(e => e.name.toLowerCase() === name.trim().toLowerCase()) ?? null;
41725
- };
41726
41795
  const related = (a, b) => schema.relationships.some(r => (r.source === a && r.target === b) || (r.source === b && r.target === a));
41727
41796
  return schema.entities.flatMap(e => (e.attributes ?? []).filter(a => a.isFK).flatMap(a => {
41728
41797
  if (!a.references) {
41729
41798
  return schema.relationships.some(r => r.source === e.id || r.target === e.id) ? []
41730
41799
  : [this._entity(level, e, 'foreign-key-unrelated', 'info', `"${e.name}.${a.name}" is a foreign key, but "${e.name}" has no relationship`)];
41731
41800
  }
41732
- const target = find(a.references);
41801
+ const target = schema.resolveReference(a.references)?.entity;
41733
41802
  if (!target) return [this._entity(level, e, 'reference-missing', 'warning', `"${e.name}.${a.name}" references "${a.references}", which is not at the ${level} level`)];
41734
41803
  return related(e.id, target.id) ? []
41735
41804
  : [this._entity(level, e, 'foreign-key-without-relationship', 'warning', `"${e.name}.${a.name}" references "${target.name}", but they have no relationship`)];
@@ -42003,50 +42072,211 @@
42003
42072
  }
42004
42073
  }
42005
42074
 
42006
- /**
42007
- * DataModelEditCommand — one edit of a DataModel (an entity, an attribute, a
42008
- * relationship, a delete) as one undo step: run, it makes the change and keeps
42009
- * the model as it was before and after (DataModel#toJSON); undone or redone, it
42010
- * puts the model back as it was (DataModel#restore). Any change to a model is
42011
- * undoable this way, whatever it touches.
42012
- */
42013
- class DataModelEditCommand extends Command {
42014
- /** A model edit is no edit on one view: undoing it shows no other. */
42015
- viewId = null;
42016
-
42017
- /**
42018
- * @param {DataModel} model
42019
- * @param {string} label - What the undo menu calls it ('Edit Customer')
42020
- * @param {(model: DataModel) => *} change - Makes the change
42021
- */
42022
- constructor(model, label, change) {
42023
- super(label);
42024
- this.model = model;
42025
- this.change = change;
42026
- this.before = null;
42027
- this.after = null;
42028
- }
42029
-
42030
- execute() {
42031
- if (this.after) {
42032
- this.model.restore(this.after);
42033
- } else {
42034
- this.before = DataModelEditCommand.snapshot(this.model);
42035
- this.result = this.change(this.model);
42036
- this.after = DataModelEditCommand.snapshot(this.model);
42037
- }
42038
- this.model.edited();
42039
- }
42040
-
42041
- undo() {
42042
- this.model.restore(this.before);
42043
- this.model.edited();
42044
- }
42045
-
42046
- /** The model as it is, a copy nothing later changes (its toJSON shares some arrays). */
42047
- static snapshot(model) {
42048
- return JSON.parse(JSON.stringify(model.toJSON()));
42049
- }
42075
+ /**
42076
+ * ModelEditCommand — one edit of an EditableModel (a data model's entity, a
42077
+ * requirements model's story, a delete) as one undo step: run, it makes the
42078
+ * change and keeps the model as it was before and after (its toJSON); undone
42079
+ * or redone, it puts the model back as it was (its restore). Any change to a
42080
+ * model is undoable this way, whatever it touches.
42081
+ */
42082
+ class ModelEditCommand extends Command {
42083
+ /** A model edit is no edit on one view: undoing it shows no other. */
42084
+ viewId = null;
42085
+
42086
+ /**
42087
+ * @param {EditableModel} model
42088
+ * @param {string} label - What the undo menu calls it ('Edit Customer')
42089
+ * @param {(model: EditableModel) => *} change - Makes the change
42090
+ */
42091
+ constructor(model, label, change) {
42092
+ super(label);
42093
+ this.model = model;
42094
+ this.change = change;
42095
+ this.before = null;
42096
+ this.after = null;
42097
+ }
42098
+
42099
+ execute() {
42100
+ if (this.after) {
42101
+ this.model.restore(this.after);
42102
+ } else {
42103
+ this.before = ModelEditCommand.snapshot(this.model);
42104
+ this.result = this.change(this.model);
42105
+ this.after = ModelEditCommand.snapshot(this.model);
42106
+ }
42107
+ this.model.edited();
42108
+ }
42109
+
42110
+ undo() {
42111
+ this.model.restore(this.before);
42112
+ this.model.edited();
42113
+ }
42114
+
42115
+ /** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
42116
+ static snapshot(model) {
42117
+ return JSON.parse(JSON.stringify(model.toJSON()));
42118
+ }
42119
+ }
42120
+
42121
+ /**
42122
+ * EditableModel — base of a model edited as a whole rather than view by view
42123
+ * (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
42124
+ * its own way). What a part needs of any model it edits, keeps and merges:
42125
+ *
42126
+ * - a name;
42127
+ * - whether it changed since it was saved (`edited()`, `markSaved()`,
42128
+ * `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
42129
+ * it is lost;
42130
+ * - an undo history, every edit one step on it (`editCommand(label, change)`:
42131
+ * the model before and after, as its toJSON, put back by `restore`);
42132
+ * - how it differs from another of its kind (`compareWith`, over the
42133
+ * subclass's `comparedKinds()`), which merges the other's into it.
42134
+ *
42135
+ * A subclass gives `toJSON()`, its constructor reading that back, and
42136
+ * `static STATE`: the fields `restore` puts back.
42137
+ */
42138
+ class EditableModel {
42139
+ /** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
42140
+ static STATE = Object.freeze(['name']);
42141
+
42142
+ /** @param {{name?: string}} [spec] */
42143
+ constructor(spec = {}) {
42144
+ if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
42145
+ /** What the model is called. */
42146
+ this.name = spec.name ?? '';
42147
+ this._version = 0;
42148
+ this._saved = 0;
42149
+ this._editListeners = [];
42150
+ }
42151
+
42152
+ /** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
42153
+ comparedKinds() {
42154
+ return [];
42155
+ }
42156
+
42157
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
42158
+ compareWith(other) {
42159
+ return new ModelComparison(this, other);
42160
+ }
42161
+
42162
+ /** Its undo history: a merge is one step on it. */
42163
+ get history() {
42164
+ return (this._history ??= new CommandHistory());
42165
+ }
42166
+
42167
+ /** A change made as one undo step (ModelEditCommand), not yet run. */
42168
+ editCommand(label, change) {
42169
+ return new ModelEditCommand(this, label, change);
42170
+ }
42171
+
42172
+ /** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
42173
+ restore(json) {
42174
+ const back = new this.constructor(JSON.parse(JSON.stringify(json)));
42175
+ for (const key of this.constructor.STATE) this[key] = back[key];
42176
+ }
42177
+
42178
+ // ===== Changed since saved =====
42179
+
42180
+ /** Something changed: listeners hear of it. */
42181
+ edited() {
42182
+ this._version++;
42183
+ this._emitEdit();
42184
+ }
42185
+
42186
+ /** This state is the saved one (a file opened or saved). */
42187
+ markSaved() {
42188
+ this._saved = this._version;
42189
+ this._emitEdit();
42190
+ }
42191
+
42192
+ /** Whether it changed since markSaved. */
42193
+ isDirty() {
42194
+ return this._version !== this._saved;
42195
+ }
42196
+
42197
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
42198
+ onEdit(fn) {
42199
+ this._editListeners.push(fn);
42200
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
42201
+ }
42202
+
42203
+ /** @private */
42204
+ _emitEdit() {
42205
+ for (const fn of this._editListeners) fn(this);
42206
+ }
42207
+
42208
+ /** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
42209
+ _freshId(name, taken) {
42210
+ const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
42211
+ let id = base;
42212
+ for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
42213
+ return id;
42214
+ }
42215
+
42216
+ /** The model as plain JSON, read back by its constructor. @abstract */
42217
+ toJSON() {
42218
+ throw new Error(`${this.constructor.name} must implement toJSON()`);
42219
+ }
42220
+ }
42221
+
42222
+ /**
42223
+ * Benchmark — what a model is measured by, so a change to it (a proposal, a
42224
+ * merge) says whether it made the model better. Each measure:
42225
+ * `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
42226
+ * (a share is 0..1; a value of null is not measured yet).
42227
+ *
42228
+ * Every model with a checker is measured by its findings — warnings counted
42229
+ * apart (`Benchmark.findings`); a model with more to say gives its own
42230
+ * `benchmark()` (a data model its Scorecard, a requirements model how many
42231
+ * stories say when they are done and trace to something), starting from those.
42232
+ */
42233
+ class Benchmark {
42234
+ /** A model's measures: its own benchmark(), else its checker's findings, else none. */
42235
+ static of(model) {
42236
+ if (typeof model?.benchmark === 'function') return model.benchmark();
42237
+ return Benchmark.findings(model);
42238
+ }
42239
+
42240
+ /** The check's warnings and all its findings, each fewer the better. */
42241
+ static findings(model) {
42242
+ if (typeof model?.checker !== 'function') return [];
42243
+ const found = model.checker().run();
42244
+ return [
42245
+ { key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
42246
+ { key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
42247
+ ];
42248
+ }
42249
+
42250
+ /**
42251
+ * Two models' measures side by side, matched by key: `{key, label, format,
42252
+ * before, after, change}` — change 'better', 'worse', 'same', or null when
42253
+ * either is not measured.
42254
+ */
42255
+ static compare(before, after) {
42256
+ const then = new Map(before.map(m => [m.key, m]));
42257
+ return after.filter(m => then.has(m.key)).map(m => {
42258
+ const was = then.get(m.key).value, now = m.value;
42259
+ const change = was == null || now == null ? null
42260
+ : was === now ? 'same'
42261
+ : (now < was) === (m.better === 'lower') ? 'better' : 'worse';
42262
+ return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
42263
+ });
42264
+ }
42265
+
42266
+ /** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
42267
+ static format(value, format = 'count') {
42268
+ if (value == null) return '—';
42269
+ if (format === 'share') return `${Math.round(value * 100)}%`;
42270
+ if (format === 'score') return `${value} of 100`;
42271
+ return String(value);
42272
+ }
42273
+
42274
+ /** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
42275
+ static text(rows) {
42276
+ const moved = rows.filter(r => r.change !== 'same');
42277
+ const shown = moved.length ? moved : rows;
42278
+ return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
42279
+ }
42050
42280
  }
42051
42281
 
42052
42282
  /** Words that stay small inside a title. */
@@ -42700,8 +42930,42 @@
42700
42930
  }
42701
42931
  }
42702
42932
 
42933
+ /** A plain copy of JSON-shaped data. */
42934
+ const copy$2 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42935
+
42936
+ /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
42937
+ class ListCompared extends ComparedKind {
42938
+ constructor() {
42939
+ super();
42940
+ if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
42941
+ }
42942
+
42943
+ /** The array the model holds them in. @abstract */
42944
+ list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
42945
+
42946
+ items(model) { return this.list(model); }
42947
+ get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
42948
+
42949
+ /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
42950
+ bring(model, item) {
42951
+ const list = this.list(model);
42952
+ const twin = this.copyOf(item);
42953
+ const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
42954
+ if (at < 0) {
42955
+ list.push(twin);
42956
+ return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
42957
+ }
42958
+ const old = list[at];
42959
+ list[at] = twin;
42960
+ return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
42961
+ }
42962
+
42963
+ /** A copy to put in the other model. */
42964
+ copyOf(item) { return copy$2(item); }
42965
+ }
42966
+
42703
42967
  /** A plain copy of JSON-shaped data. */
42704
- const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42968
+ const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42705
42969
 
42706
42970
  /**
42707
42971
  * The kinds two DataModels are compared on and merged by (ModelComparison,
@@ -42711,37 +42975,6 @@
42711
42975
  * two models alike but for where their boxes are, are alike.
42712
42976
  */
42713
42977
 
42714
- /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
42715
- class ListCompared extends ComparedKind {
42716
- constructor() {
42717
- super();
42718
- if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
42719
- }
42720
-
42721
- /** The array the model holds them in. @abstract */
42722
- list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
42723
-
42724
- items(model) { return this.list(model); }
42725
- get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
42726
-
42727
- /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
42728
- bring(model, item) {
42729
- const list = this.list(model);
42730
- const twin = this.copyOf(item);
42731
- const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
42732
- if (at < 0) {
42733
- list.push(twin);
42734
- return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
42735
- }
42736
- const old = list[at];
42737
- list[at] = twin;
42738
- return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
42739
- }
42740
-
42741
- /** A copy to put in the other model. */
42742
- copyOf(item) { return copy(item); }
42743
- }
42744
-
42745
42978
  /** One level's entities: compared on all but where they are drawn. */
42746
42979
  class LevelEntitiesCompared extends ListCompared {
42747
42980
  constructor(level) {
@@ -42755,7 +42988,7 @@
42755
42988
 
42756
42989
  content(entity) {
42757
42990
  const { x, y, ...rest } = entity;
42758
- return copy(rest);
42991
+ return copy$1(rest);
42759
42992
  }
42760
42993
  }
42761
42994
 
@@ -42774,7 +43007,7 @@
42774
43007
  list(model) { return model.level(this.level).relationships; }
42775
43008
  idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
42776
43009
  nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
42777
- content(r) { return copy(r); }
43010
+ content(r) { return copy$1(r); }
42778
43011
  requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
42779
43012
  }
42780
43013
 
@@ -42801,8 +43034,8 @@
42801
43034
 
42802
43035
  /** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
42803
43036
  class ModelPartsCompared extends ListCompared {
42804
- content(item) { return copy(item.toJSON()); }
42805
- copyOf(item) { return new item.constructor(copy(item.toJSON())); }
43037
+ content(item) { return copy$1(item.toJSON()); }
43038
+ copyOf(item) { return new item.constructor(copy$1(item.toJSON())); }
42806
43039
  }
42807
43040
 
42808
43041
  class ContextsCompared extends ModelPartsCompared {
@@ -42844,11 +43077,13 @@
42844
43077
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
42845
43078
  * three levels mapped one to one.
42846
43079
  *
42847
- * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
42848
- * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
42849
- * keep it and ask before it is lost.
43080
+ * What any model a part edits has — whether it changed since saved, its undo
43081
+ * history, compare and merge — is EditableModel's.
42850
43082
  */
42851
- class DataModel {
43083
+ class DataModel extends EditableModel {
43084
+ /** What a snapshot puts back (EditableModel#restore). */
43085
+ static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
43086
+
42852
43087
  /** The levels, outermost first. */
42853
43088
  static LEVELS = LEVELS;
42854
43089
 
@@ -42932,11 +43167,7 @@
42932
43167
  * @param {string} [spec.name] - What the model is called
42933
43168
  */
42934
43169
  constructor(spec = {}) {
42935
- /** What the model is called. */
42936
- this.name = spec.name ?? '';
42937
- this._version = 0;
42938
- this._saved = 0;
42939
- this._editListeners = [];
43170
+ super(spec);
42940
43171
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
42941
43172
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
42942
43173
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -42963,6 +43194,14 @@
42963
43194
  return new ModelScorecard(this);
42964
43195
  }
42965
43196
 
43197
+ /** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
43198
+ benchmark() {
43199
+ return [
43200
+ ...Benchmark.findings(this),
43201
+ { key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
43202
+ ];
43203
+ }
43204
+
42966
43205
  /** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
42967
43206
  scorecardCommand(spec) {
42968
43207
  return this.editCommand('Score the model', (model) => {
@@ -43010,46 +43249,6 @@
43010
43249
  ];
43011
43250
  }
43012
43251
 
43013
- /** How this model differs from another (ModelComparison), which can merge the other's into it. */
43014
- compareWith(other) {
43015
- return new ModelComparison(this, other);
43016
- }
43017
-
43018
- /** Its undo history: a merge is one step on it. */
43019
- get history() {
43020
- return (this._history ??= new CommandHistory());
43021
- }
43022
-
43023
- // ===== Changed since saved =====
43024
-
43025
- /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
43026
- edited() {
43027
- this._version++;
43028
- this._emitEdit();
43029
- }
43030
-
43031
- /** This state is the saved one (a file opened or saved). */
43032
- markSaved() {
43033
- this._saved = this._version;
43034
- this._emitEdit();
43035
- }
43036
-
43037
- /** Whether it changed since markSaved. */
43038
- isDirty() {
43039
- return this._version !== this._saved;
43040
- }
43041
-
43042
- /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
43043
- onEdit(fn) {
43044
- this._editListeners.push(fn);
43045
- return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
43046
- }
43047
-
43048
- /** @private */
43049
- _emitEdit() {
43050
- for (const fn of this._editListeners) fn(this);
43051
- }
43052
-
43053
43252
  /** A level's schema (LevelSchema). */
43054
43253
  level(name) {
43055
43254
  const schema = this.levels[name];
@@ -43265,13 +43464,7 @@
43265
43464
 
43266
43465
  // ===== Perspectives and contexts of one's own =====
43267
43466
 
43268
- // ===== Editing what a level holds (each a DataModelEditCommand: one undo step) =====
43269
-
43270
- /** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
43271
- restore(json) {
43272
- const back = new DataModel(JSON.parse(JSON.stringify(json)));
43273
- for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
43274
- }
43467
+ // ===== Editing what a level holds (each an editCommand: one undo step) =====
43275
43468
 
43276
43469
  /** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
43277
43470
  classificationSchemeCommand(spec) {
@@ -43287,11 +43480,6 @@
43287
43480
  });
43288
43481
  }
43289
43482
 
43290
- /** A change made as one undo step. */
43291
- editCommand(label, change) {
43292
- return new DataModelEditCommand(this, label, change);
43293
- }
43294
-
43295
43483
  /**
43296
43484
  * Add an entity at a level (`id` null: one made from its name) or change
43297
43485
  * one (what the spec leaves out, where it is drawn among it, kept).
@@ -43643,13 +43831,6 @@
43643
43831
  }
43644
43832
 
43645
43833
  /** An id from a name, not yet among `taken`. @private */
43646
- _freshId(name, taken) {
43647
- const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
43648
- let id = base;
43649
- for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
43650
- return id;
43651
- }
43652
-
43653
43834
  toJSON() {
43654
43835
  return {
43655
43836
  name: this.name,
@@ -44671,6 +44852,15 @@
44671
44852
  return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
44672
44853
  }
44673
44854
 
44855
+ /**
44856
+ * A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
44857
+ * each part as name() has it, so it names the table in its schema rather
44858
+ * than one table called "sales.customer".
44859
+ */
44860
+ static tableName(text) {
44861
+ return String(text).split('.').map(SqlDdl.name).join('.');
44862
+ }
44863
+
44674
44864
  /** A text as an SQL string literal. */
44675
44865
  static literal(text) {
44676
44866
  return `'${String(text).replace(/'/g, "''")}'`;
@@ -44680,11 +44870,7 @@
44680
44870
  write(model) {
44681
44871
  const schema = model.level('physical');
44682
44872
  const tables = schema.entities;
44683
- const find = (ref) => {
44684
- const [table, column] = String(ref).split('.').map(s => s.trim());
44685
- const target = schema.entity(table) ?? tables.find(t => t.name.toLowerCase() === table.toLowerCase());
44686
- return target ? { target, column } : null;
44687
- };
44873
+ const table = SqlDdl.tableName;
44688
44874
  const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
44689
44875
  for (const t of tables) {
44690
44876
  const columns = t.attributes ?? [];
@@ -44694,28 +44880,28 @@
44694
44880
  ...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
44695
44881
  ...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
44696
44882
  ];
44697
- out.push(`CREATE TABLE ${SqlDdl.name(t.name)} (`, lines.join(',\n'), ');', '');
44883
+ out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
44698
44884
  }
44699
44885
  for (const t of tables) {
44700
44886
  for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
44701
- const ref = find(c.references);
44887
+ const ref = schema.resolveReference(c.references);
44702
44888
  if (!ref) {
44703
44889
  out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
44704
44890
  continue;
44705
44891
  }
44706
- const theirs = ref.column ? [ref.column] : (ref.target.attributes ?? []).filter(a => a.isPK).map(a => a.name);
44892
+ const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
44707
44893
  if (!theirs.length) {
44708
- out.push(`-- ${t.name}.${c.name} references ${ref.target.name}, which has no primary key`);
44894
+ out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
44709
44895
  continue;
44710
44896
  }
44711
- out.push(`ALTER TABLE ${SqlDdl.name(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
44712
- + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${SqlDdl.name(ref.target.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
44897
+ out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
44898
+ + ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
44713
44899
  }
44714
44900
  }
44715
44901
  if (this.comments) {
44716
44902
  const notes = tables.flatMap(t => [
44717
- ...(t.description ? [`COMMENT ON TABLE ${SqlDdl.name(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
44718
- ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${SqlDdl.name(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
44903
+ ...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
44904
+ ...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
44719
44905
  ]);
44720
44906
  if (notes.length) out.push('', ...notes);
44721
44907
  }
@@ -44744,6 +44930,853 @@
44744
44930
  }
44745
44931
  }
44746
44932
 
44933
+ /**
44934
+ * The kinds two RequirementsModels are compared on and merged by
44935
+ * (ModelComparison): epics, releases, then stories, in the order a merge
44936
+ * brings them — a story needs its epic and its release.
44937
+ */
44938
+
44939
+ class EpicsCompared extends ListCompared {
44940
+ get key() { return 'epic'; }
44941
+ get label() { return 'Epics'; }
44942
+ list(model) { return model.epics; }
44943
+ content(epic) { return { ...epic }; }
44944
+ }
44945
+
44946
+ class ReleasesCompared extends ListCompared {
44947
+ get key() { return 'release'; }
44948
+ get label() { return 'Releases'; }
44949
+ list(model) { return model.releases; }
44950
+ content(release) { return { ...release }; }
44951
+ }
44952
+
44953
+ /** Stories: compared on everything, where they sit in the list aside. */
44954
+ class StoriesCompared extends ListCompared {
44955
+ get key() { return 'story'; }
44956
+ get label() { return 'Stories'; }
44957
+ list(model) { return model.stories; }
44958
+ content(story) { return JSON.parse(JSON.stringify(story)); }
44959
+
44960
+ requires(story) {
44961
+ return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
44962
+ }
44963
+ }
44964
+
44965
+ /**
44966
+ * RequirementsChecker — what a requirements model is missing, as a team
44967
+ * reviewing a backlog would say it: a story with no acceptance criteria
44968
+ * (nothing to say when it is done), one that says neither who wants it nor
44969
+ * what (no "As a … I want …"), one that traces to nothing (nothing realises
44970
+ * it), one under no epic, an epic with no stories, two stories of one name.
44971
+ *
44972
+ * With `resolve(trace)` — the host's way to find what a trace names in its
44973
+ * other models (true, false, or undefined when it cannot tell) — a trace to
44974
+ * something that is not there is found too.
44975
+ *
44976
+ * Findings name a story or an epic (`kind`), as ModelChecker's do.
44977
+ */
44978
+ class RequirementsChecker extends ModelChecker {
44979
+ /**
44980
+ * @param {RequirementsModel} model
44981
+ * @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
44982
+ */
44983
+ constructor(model, { resolve = null } = {}) {
44984
+ super(model);
44985
+ this.resolve = resolve;
44986
+ }
44987
+
44988
+ checks() {
44989
+ return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
44990
+ }
44991
+
44992
+ /** @private */
44993
+ _story(story, code, severity, message) {
44994
+ return { code, severity, kind: 'story', ids: [story.id], message };
44995
+ }
44996
+
44997
+ noCriteria() {
44998
+ return this.model.stories.filter(s => !s.criteria.length)
44999
+ .map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
45000
+ }
45001
+
45002
+ noNarrative() {
45003
+ return this.model.stories.filter(s => !s.want && !s.description)
45004
+ .map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
45005
+ }
45006
+
45007
+ untraced() {
45008
+ return this.model.stories.filter(s => !s.traces.length)
45009
+ .map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
45010
+ }
45011
+
45012
+ brokenTraces() {
45013
+ if (!this.resolve) return [];
45014
+ return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
45015
+ .map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
45016
+ }
45017
+
45018
+ noEpic() {
45019
+ if (!this.model.epics.length) return [];
45020
+ return this.model.stories.filter(s => !this.model.epic(s.epic))
45021
+ .map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
45022
+ }
45023
+
45024
+ emptyEpics() {
45025
+ return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
45026
+ .map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
45027
+ }
45028
+
45029
+ sameNames() {
45030
+ const byName = new Map();
45031
+ for (const s of this.model.stories) {
45032
+ const key = s.name.trim().toLowerCase();
45033
+ if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
45034
+ }
45035
+ return [...byName.values()].filter(list => list.length > 1)
45036
+ .map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
45037
+ }
45038
+ }
45039
+
45040
+ /** A plain copy of JSON-shaped data. */
45041
+ const copy = (value) => JSON.parse(JSON.stringify(value));
45042
+
45043
+ /**
45044
+ * What a story can trace to, and what its trace is called: ArchiMate's
45045
+ * motivation and implementation elements, any other element of an
45046
+ * architecture, and a data model's concepts, entities and tables — so a trace
45047
+ * runs epic → story → requirement → component → entity → table. A trace's
45048
+ * `kind` is any string; these are the ones offered.
45049
+ */
45050
+ const TRACE_KINDS = Object.freeze([
45051
+ ['requirement', 'Requirement (ArchiMate)'],
45052
+ ['work-package', 'Work package (ArchiMate)'],
45053
+ ['element', 'Architecture element'],
45054
+ ['concept', 'Data concept'],
45055
+ ['entity', 'Data entity'],
45056
+ ['table', 'Table'],
45057
+ ]);
45058
+
45059
+ /** Where a story is in its life, in order; a status is any string, these are offered. */
45060
+ const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
45061
+
45062
+ /**
45063
+ * RequirementsModel — what a system is to do, as an agile team writes it:
45064
+ * **epics** (large outcomes), the **stories** that deliver them ("As a … I
45065
+ * want … so that …"), each with its **acceptance criteria**, sliced into
45066
+ * **releases**; and **traces** from a story to what realises it in the other
45067
+ * models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
45068
+ * a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
45069
+ * model's id).
45070
+ *
45071
+ * Read as a **story map** (`storyMap()`): epics across in order, releases
45072
+ * down, each cell the stories of that epic in that release; stories with no
45073
+ * epic or no release have a column and a row of their own. `coverage()` and
45074
+ * the checker (RequirementsChecker) say what is missing — a story with no
45075
+ * acceptance criteria, nothing realising it — the numbers a bench measures.
45076
+ *
45077
+ * Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
45078
+ * [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
45079
+ * description?, criteria: [{id, text}], status?, priority?, points?, labels?,
45080
+ * source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
45081
+ * Ids are made from names when not given. Every edit is a command (one undo
45082
+ * step); compare and merge, and changed-since-saved, are EditableModel's.
45083
+ */
45084
+ class RequirementsModel extends EditableModel {
45085
+ static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
45086
+
45087
+ static TRACE_KINDS = TRACE_KINDS;
45088
+
45089
+ static STATUSES = STATUSES;
45090
+
45091
+ /** A story map's column for stories with no epic, and its row for those in no release. */
45092
+ static NO_EPIC = '';
45093
+ static NO_RELEASE = '';
45094
+
45095
+ constructor(spec = {}) {
45096
+ super(spec);
45097
+ this.epics = [];
45098
+ this.releases = [];
45099
+ this.stories = [];
45100
+ for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
45101
+ for (const r of spec.releases ?? []) this.releases.push(this._release(r));
45102
+ for (const s of spec.stories ?? []) this.stories.push(this._story(s));
45103
+ }
45104
+
45105
+ // ===== Reading =====
45106
+
45107
+ epic(id) { return this.epics.find(e => e.id === id) ?? null; }
45108
+ release(id) { return this.releases.find(r => r.id === id) ?? null; }
45109
+ story(id) { return this.stories.find(s => s.id === id) ?? null; }
45110
+
45111
+ /** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
45112
+ storiesOf(epicId, release) {
45113
+ return this.stories.filter(s => (s.epic || '') === (epicId || '')
45114
+ && (release === undefined || (s.release || '') === (release || '')));
45115
+ }
45116
+
45117
+ /** The story's sentence: "As a … I want … so that …", from what it has. */
45118
+ static narrative(story) {
45119
+ return [story.as && `As ${/^(a|an|the)\b/i.test(story.as) ? '' : 'a '}${story.as}`, story.want && `I want ${story.want}`, story.soThat && `so that ${story.soThat}`]
45120
+ .filter(Boolean).join(', ');
45121
+ }
45122
+
45123
+ /**
45124
+ * The story map: `columns` the epics in order (then NO_EPIC, if any story
45125
+ * has none), `rows` the releases in order (then NO_RELEASE, if any story is
45126
+ * in none, or there are no releases), and `cell(epicId, releaseId)` the
45127
+ * stories there, in order.
45128
+ */
45129
+ storyMap() {
45130
+ const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
45131
+ if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
45132
+ const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
45133
+ if (!rows.length || this.stories.some(s => !this.release(s.release))) rows.push({ id: RequirementsModel.NO_RELEASE, name: this.releases.length ? 'Not planned' : 'Stories', release: null });
45134
+ const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
45135
+ const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
45136
+ return {
45137
+ columns, rows,
45138
+ cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
45139
+ };
45140
+ }
45141
+
45142
+ /** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
45143
+ tracingTo(ref, kind) {
45144
+ return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
45145
+ }
45146
+
45147
+ /**
45148
+ * How complete it is, as numbers to measure by: stories, how many have
45149
+ * acceptance criteria, how many trace to something, epics with no stories,
45150
+ * and each as a share (0..1; 1 with nothing to count).
45151
+ */
45152
+ coverage() {
45153
+ const stories = this.stories.length;
45154
+ const withCriteria = this.stories.filter(s => s.criteria.length).length;
45155
+ const traced = this.stories.filter(s => s.traces.length).length;
45156
+ const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
45157
+ const share = (n, of) => (of ? n / of : 1);
45158
+ return {
45159
+ stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
45160
+ criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
45161
+ };
45162
+ }
45163
+
45164
+ /** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
45165
+ benchmark() {
45166
+ const c = this.coverage();
45167
+ return [
45168
+ ...Benchmark.findings(this),
45169
+ { key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
45170
+ { key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
45171
+ ];
45172
+ }
45173
+
45174
+ /** Its checks (RequirementsChecker). */
45175
+ checker(options) {
45176
+ return new RequirementsChecker(this, options);
45177
+ }
45178
+
45179
+ comparedKinds() {
45180
+ return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
45181
+ }
45182
+
45183
+ // ===== Editing (each an editCommand: one undo step) =====
45184
+
45185
+ /** Add an epic (`id` null) or change one. The command's result is the epic. */
45186
+ saveEpicCommand(id, spec) {
45187
+ return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
45188
+ if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
45189
+ const epic = model.epic(id);
45190
+ if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
45191
+ return epic;
45192
+ });
45193
+ }
45194
+
45195
+ /** Remove an epic; its stories stay, with no epic. */
45196
+ removeEpicCommand(id) {
45197
+ return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
45198
+ model.epics = model.epics.filter(e => e.id !== id);
45199
+ for (const s of model.stories) if (s.epic === id) s.epic = '';
45200
+ });
45201
+ }
45202
+
45203
+ /** Add a release (`id` null) or rename one. */
45204
+ saveReleaseCommand(id, spec) {
45205
+ return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
45206
+ if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
45207
+ const release = model.release(id);
45208
+ if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
45209
+ return release;
45210
+ });
45211
+ }
45212
+
45213
+ /** Remove a release; its stories stay, planned in none. */
45214
+ removeReleaseCommand(id) {
45215
+ return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
45216
+ model.releases = model.releases.filter(r => r.id !== id);
45217
+ for (const s of model.stories) if (s.release === id) s.release = '';
45218
+ });
45219
+ }
45220
+
45221
+ /** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
45222
+ saveStoryCommand(id, spec) {
45223
+ return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
45224
+ if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
45225
+ const at = model.stories.findIndex(s => s.id === id);
45226
+ if (at < 0) return null;
45227
+ model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
45228
+ return model.stories[at];
45229
+ });
45230
+ }
45231
+
45232
+ removeStoryCommand(id) {
45233
+ return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
45234
+ model.stories = model.stories.filter(s => s.id !== id);
45235
+ });
45236
+ }
45237
+
45238
+ /**
45239
+ * Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
45240
+ * none), before another story there (`before`; none: last).
45241
+ */
45242
+ moveStoryCommand(id, { epic, release, before = null }) {
45243
+ return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
45244
+ const story = model.story(id);
45245
+ if (!story || before === id) return;
45246
+ if (epic !== undefined) story.epic = epic || '';
45247
+ if (release !== undefined) story.release = release || '';
45248
+ model.stories.splice(model.stories.indexOf(story), 1);
45249
+ const at = before ? model.stories.findIndex(s => s.id === before) : -1;
45250
+ model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
45251
+ });
45252
+ }
45253
+
45254
+ /** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
45255
+ traceCommand(storyId, trace) {
45256
+ return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
45257
+ const story = model.story(storyId);
45258
+ if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
45259
+ story.traces.push(RequirementsModel._trace(trace));
45260
+ });
45261
+ }
45262
+
45263
+ /** Remove a story's trace to `ref` (of `kind`, if given). */
45264
+ untraceCommand(storyId, ref, kind) {
45265
+ return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
45266
+ const story = model.story(storyId);
45267
+ if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
45268
+ });
45269
+ }
45270
+
45271
+ /**
45272
+ * Bring epics, releases and stories in (an import): one undo step. One with
45273
+ * the id of one there replaces it; the rest are added.
45274
+ */
45275
+ bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
45276
+ return this.editCommand(label, (model) => {
45277
+ const put = (list, item) => {
45278
+ const at = list.findIndex(x => x.id === item.id);
45279
+ if (at < 0) list.push(item); else list[at] = item;
45280
+ };
45281
+ for (const e of epics) put(model.epics, model._epic(e));
45282
+ for (const r of releases) put(model.releases, model._release(r));
45283
+ for (const s of stories) put(model.stories, model._story(s));
45284
+ });
45285
+ }
45286
+
45287
+ // ===== Its parts, read from a spec =====
45288
+
45289
+ /** @private */
45290
+ _epic({ id, name = '', description = '', source } = {}) {
45291
+ return {
45292
+ id: id ?? this._freshId(name || 'epic', this.epics), name, description,
45293
+ ...(source && { source: copy(source) }),
45294
+ };
45295
+ }
45296
+
45297
+ /** @private */
45298
+ _release({ id, name = '' } = {}) {
45299
+ return { id: id ?? this._freshId(name || 'release', this.releases), name };
45300
+ }
45301
+
45302
+ /** @private */
45303
+ _story(spec = {}) {
45304
+ const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
45305
+ const story = {
45306
+ id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
45307
+ as, want, soThat, description, status, priority,
45308
+ points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
45309
+ labels: [...labels],
45310
+ criteria: [],
45311
+ traces: traces.map(t => RequirementsModel._trace(t)),
45312
+ ...(source && { source: copy(source) }),
45313
+ };
45314
+ // A new criterion is numbered past every id the story's criteria have, so none is given twice.
45315
+ const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
45316
+ let n = 0;
45317
+ const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
45318
+ for (const c of criteria) {
45319
+ const text = typeof c === 'string' ? c : c?.text;
45320
+ if (!String(text ?? '').trim()) continue;
45321
+ story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
45322
+ }
45323
+ return story;
45324
+ }
45325
+
45326
+ /** @private */
45327
+ static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
45328
+ return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
45329
+ }
45330
+
45331
+ toJSON() {
45332
+ return {
45333
+ name: this.name,
45334
+ epics: copy(this.epics),
45335
+ releases: copy(this.releases),
45336
+ stories: copy(this.stories),
45337
+ };
45338
+ }
45339
+ }
45340
+
45341
+ /**
45342
+ * RequirementsFileFormat — a requirements model as a file
45343
+ * (`.requirements.json`): its epics, releases and stories
45344
+ * (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
45345
+ */
45346
+ class RequirementsFileFormat extends ModelFileFormat {
45347
+ get label() { return '.requirements.json'; }
45348
+ get accept() { return '.requirements.json,.json'; }
45349
+ get fileName() { return 'model.requirements.json'; }
45350
+ get featureName() { return 'Requirements'; }
45351
+
45352
+ /** JSON with a requirements model's shape: stories, or epics. */
45353
+ recognizes(text) {
45354
+ try {
45355
+ const json = JSON.parse(text);
45356
+ return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
45357
+ } catch {
45358
+ return false;
45359
+ }
45360
+ }
45361
+
45362
+ export(model) {
45363
+ return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
45364
+ }
45365
+
45366
+ import(text) {
45367
+ return new RequirementsModel(JSON.parse(text));
45368
+ }
45369
+ }
45370
+
45371
+ /**
45372
+ * Where each tracker's export keeps what a story needs: per tracker, the
45373
+ * column names (CSV) each field may be under, first found wins; which issue
45374
+ * types are epics; how its list fields are separated. One definition per
45375
+ * tracker — the CSV and the JSON readers both map onto these fields. A host
45376
+ * gives its own (a custom field's column) through `new TrackerExport({ columns })`.
45377
+ */
45378
+ const TRACKERS = Object.freeze({
45379
+ jira: {
45380
+ label: 'Jira',
45381
+ // Columns only its exports have, to tell it by.
45382
+ marks: ['Issue key', 'Issue Type'],
45383
+ columns: {
45384
+ key: ['Issue key', 'Key'], id: ['Issue id'], type: ['Issue Type'], title: ['Summary'],
45385
+ description: ['Description'], parent: ['Parent', 'Parent id', 'Parent key', 'Custom field (Epic Link)', 'Epic Link'],
45386
+ status: ['Status'], priority: ['Priority'],
45387
+ points: ['Custom field (Story Points)', 'Story Points', 'Custom field (Story point estimate)', 'Story point estimate'],
45388
+ release: ['Fix Version/s', 'Fix versions', 'Sprint'], labels: ['Labels'],
45389
+ criteria: ['Custom field (Acceptance Criteria)', 'Acceptance Criteria'], url: [],
45390
+ },
45391
+ epicTypes: ['Epic'], skipTypes: ['Sub-task', 'Subtask'], labelSeparator: /[\s,]+/,
45392
+ },
45393
+ azure: {
45394
+ label: 'Azure DevOps',
45395
+ marks: ['Work Item Type'],
45396
+ columns: {
45397
+ key: ['ID'], id: ['ID'], type: ['Work Item Type'], title: ['Title', 'Title 1', 'Title 2', 'Title 3'],
45398
+ description: ['Description'], parent: ['Parent'], status: ['State'], priority: ['Priority'],
45399
+ points: ['Story Points', 'Effort', 'Size'], release: ['Iteration Path'], labels: ['Tags'],
45400
+ criteria: ['Acceptance Criteria'], url: [],
45401
+ },
45402
+ epicTypes: ['Epic', 'Feature'], skipTypes: ['Task'], labelSeparator: /\s*;\s*/,
45403
+ },
45404
+ gitlab: {
45405
+ label: 'GitLab',
45406
+ marks: ['Issue ID', 'URL'],
45407
+ columns: {
45408
+ key: ['Issue ID', 'IID'], id: ['Issue ID', 'IID'], type: [], title: ['Title'], description: ['Description'],
45409
+ parent: ['Epic ID'], parentName: ['Epic Title'], status: ['State'], priority: [], points: ['Weight'],
45410
+ release: ['Milestone'], labels: ['Labels'], criteria: [], url: ['URL'],
45411
+ },
45412
+ epicTypes: [], skipTypes: [], labelSeparator: /\s*,\s*/,
45413
+ },
45414
+ });
45415
+
45416
+ /**
45417
+ * Where Jira's JSON keeps what has no field of its own: custom fields, by key.
45418
+ * Jira Cloud's "Story point estimate" is customfield_10016 unless a site
45419
+ * changed it; a host names its own (`columns: { points: ['customfield_10026'] }`).
45420
+ */
45421
+ const JIRA_FIELDS = Object.freeze({
45422
+ points: ['story_points', 'storyPoints', 'customfield_10016'],
45423
+ criteria: ['acceptanceCriteria'],
45424
+ });
45425
+
45426
+ /** Text with HTML in it (Azure DevOps keeps descriptions as HTML) as lines of plain text. */
45427
+ function plainText(value) {
45428
+ if (value == null) return '';
45429
+ if (typeof value === 'object') return documentText(value);
45430
+ return String(value)
45431
+ .replace(/<\s*(br|\/p|\/div|\/li|\/h\d)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ')
45432
+ .replace(/<[^>]+>/g, '')
45433
+ .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
45434
+ .replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
45435
+ }
45436
+
45437
+ /** Jira Cloud's description, a document of nodes, as text: each paragraph and list item a line. */
45438
+ function documentText(node) {
45439
+ if (!node || typeof node !== 'object') return '';
45440
+ if (node.type === 'text') return node.text ?? '';
45441
+ const inner = (node.content ?? []).map(documentText).join('');
45442
+ return ['paragraph', 'heading', 'listItem', 'codeBlock'].includes(node.type) ? `${node.type === 'listItem' ? '- ' : ''}${inner.trim()}\n` : inner;
45443
+ }
45444
+
45445
+ /** The lines of a list: "- x", "* x", "1. x", "• x", "[ ] x" — each item a line, its marker gone. */
45446
+ function listItems(text) {
45447
+ return String(text ?? '').split('\n')
45448
+ .map(line => line.replace(/^\s*(?:[-*•+]|\d+[.)]|\[[ xX]?\])\s+/, '').trim())
45449
+ .filter(Boolean);
45450
+ }
45451
+
45452
+ /**
45453
+ * Acceptance criteria in a story's text: the lines under an "Acceptance
45454
+ * criteria" heading, else its Given/When/Then scenarios (one each), else none.
45455
+ * @returns {{criteria: Array<string>, rest: string}} - and the text without them
45456
+ */
45457
+ function criteriaFrom(text) {
45458
+ const s = plainText(text);
45459
+ const heading = /^\s*(?:#+\s*|\*\*)?acceptance criteria\b[^\n]*\n/im.exec(s);
45460
+ if (heading) {
45461
+ const after = s.slice(heading.index + heading[0].length);
45462
+ const end = after.search(/\n\s*\n\s*(?:#+\s|\*\*|[A-Z][^\n:]{0,40}:\s*\n)/);
45463
+ const block = end < 0 ? after : after.slice(0, end);
45464
+ return { criteria: listItems(block), rest: (s.slice(0, heading.index) + (end < 0 ? '' : after.slice(end))).replace(/\n{3,}/g, '\n\n').trim() };
45465
+ }
45466
+ const scenarios = [];
45467
+ let current = null;
45468
+ for (const line of s.split('\n')) {
45469
+ const t = line.replace(/^\s*(?:[-*•]\s+)?/, '').trim();
45470
+ if (/^given\b/i.test(t)) { current = [t]; scenarios.push(current); } else if (current && /^(when|then|and|but)\b/i.test(t)) current.push(t); else current = null;
45471
+ }
45472
+ if (scenarios.length) return { criteria: scenarios.map(lines => lines.join(' ')), rest: s.split('\n').filter(l => !/^\s*(?:[-*•]\s+)?(given|when|then|and|but)\b/i.test(l)).join('\n').trim() };
45473
+ return { criteria: [], rest: s };
45474
+ }
45475
+
45476
+ /** A field that holds only acceptance criteria: as criteriaFrom reads them, else each of its lines one. */
45477
+ function criteriaField(text) {
45478
+ const found = criteriaFrom(text).criteria;
45479
+ return found.length ? found : listItems(plainText(text));
45480
+ }
45481
+
45482
+ /** "As a <who>, I want <what> so that <why>" in a text: its three parts (empty where it says nothing). */
45483
+ function narrativeFrom(text) {
45484
+ const m = /\bas an?\s+([^,\n]+?)\s*,?\s+i\s+(?:want|need|would like)\s+([^\n]+?)(?:\s*,?\s+so\s+that\s+([^\n]+?))?\s*(?:[.\n]|$)/i.exec(plainText(text));
45485
+ return m ? { as: m[1].trim(), want: m[2].trim(), soThat: (m[3] ?? '').trim() } : { as: '', want: '', soThat: '' };
45486
+ }
45487
+
45488
+ /** A text without its "As a … I want …" line: what is left to describe the story, once its sentence is read (narrativeFrom). */
45489
+ function withoutNarrative(text) {
45490
+ return String(text ?? '').replace(/^\s*\**\s*as an?\s[^\n]*\bi\s+(want|need|would like)\b[^\n]*\n?/im, '').trim();
45491
+ }
45492
+
45493
+ /**
45494
+ * TrackerExport — epics and stories from what an issue tracker exports: Jira,
45495
+ * Azure DevOps or GitLab (TRACKERS), as CSV or as their API's JSON. An issue
45496
+ * of an epic type becomes an epic (GitLab's epics are named by their issues'
45497
+ * Epic ID and title); the rest become stories under their parent epic, with
45498
+ * their status, priority, points, labels and release (fix version, iteration
45499
+ * or milestone). Acceptance criteria come from their own field, else from
45500
+ * the description (criteriaFrom); "As a … I want … so that …" from the
45501
+ * description or the title (narrativeFrom). Each keeps where it came from
45502
+ * (`source: {system, key, url?}`), and its id is the tracker's key, so
45503
+ * reading a newer export over a model replaces what it had.
45504
+ *
45505
+ * `read(text)` gives a RequirementsModel spec (`{epics, releases, stories}`).
45506
+ */
45507
+ class TrackerExport {
45508
+ /**
45509
+ * @param {Object} [options]
45510
+ * @param {string} [options.tracker] - 'jira', 'azure', 'gitlab' (default: told from the export)
45511
+ * @param {Object<string, Array<string>>} [options.columns] - Column names to look in first, by field (in Jira's JSON, field keys)
45512
+ */
45513
+ constructor({ tracker = null, columns = {} } = {}) {
45514
+ this.tracker = tracker;
45515
+ this.columns = columns;
45516
+ }
45517
+
45518
+ /** What a tracker is called ('Jira'). */
45519
+ static label(tracker) {
45520
+ return TRACKERS[tracker]?.label ?? 'Tracker';
45521
+ }
45522
+
45523
+ /** Which tracker an export's columns (or JSON) are from, or null. */
45524
+ static detect(text) {
45525
+ const json = TrackerExport._json(text);
45526
+ if (json) {
45527
+ if (Array.isArray(json.issues) && json.issues[0]?.fields) return 'jira';
45528
+ if (Array.isArray(json.value ?? json.workItems) && (json.value ?? json.workItems)[0]?.fields) return 'azure';
45529
+ if (Array.isArray(json) && json[0] && 'iid' in json[0]) return 'gitlab';
45530
+ return null;
45531
+ }
45532
+ const { columns } = parseCsvTable(text);
45533
+ const has = new Set(columns.map(c => c.toLowerCase()));
45534
+ const found = Object.entries(TRACKERS).find(([, t]) => t.marks.every(m => has.has(m.toLowerCase())));
45535
+ return found?.[0] ?? null;
45536
+ }
45537
+
45538
+ /** @private */
45539
+ static _json(text) {
45540
+ const s = String(text ?? '').trim();
45541
+ if (!s.startsWith('{') && !s.startsWith('[')) return null;
45542
+ try { return JSON.parse(s); } catch { return null; }
45543
+ }
45544
+
45545
+ /**
45546
+ * The export as a requirements model's spec.
45547
+ * @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
45548
+ */
45549
+ read(text) {
45550
+ const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
45551
+ const def = TRACKERS[tracker];
45552
+ if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
45553
+ const json = TrackerExport._json(text);
45554
+ const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
45555
+ return { ...this._model(tracker, def, issues), tracker };
45556
+ }
45557
+
45558
+ /** CSV rows as issues: each field from the first of its columns there. @private */
45559
+ _fromCsv(def, text) {
45560
+ const { columns, rows } = parseCsvTable(text);
45561
+ const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
45562
+ const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
45563
+ const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
45564
+ const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
45565
+ return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
45566
+ }
45567
+
45568
+ /** An API's JSON as issues, in the CSV's fields. @private */
45569
+ _fromJson(tracker, json) {
45570
+ if (tracker === 'jira') {
45571
+ return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
45572
+ const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
45573
+ // A field the host named (its custom field's key), else Jira Cloud's usual one.
45574
+ const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
45575
+ return {
45576
+ key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
45577
+ parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
45578
+ points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
45579
+ release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
45580
+ criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
45581
+ };
45582
+ });
45583
+ }
45584
+ if (tracker === 'azure') {
45585
+ return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
45586
+ key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
45587
+ description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
45588
+ priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
45589
+ points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
45590
+ release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
45591
+ criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
45592
+ }));
45593
+ }
45594
+ return (Array.isArray(json) ? json : []).map(i => ({
45595
+ key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
45596
+ parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
45597
+ points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
45598
+ criteria: '', url: i.web_url ?? '',
45599
+ }));
45600
+ }
45601
+
45602
+ /** Issues as epics, releases and stories. @private */
45603
+ _model(tracker, def, issues) {
45604
+ const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
45605
+ const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
45606
+ for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
45607
+ const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
45608
+
45609
+ const epics = new Map();
45610
+ for (const i of issues) {
45611
+ if (!is(def.epicTypes, i.type)) continue;
45612
+ const key = String(i.key || i.id);
45613
+ epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
45614
+ }
45615
+ const releases = new Map();
45616
+ const stories = [];
45617
+ for (const i of issues) {
45618
+ if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
45619
+ const key = String(i.key || i.id);
45620
+ const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
45621
+ if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
45622
+ const release = plainText(i.release);
45623
+ if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
45624
+ const own = criteriaField(i.criteria);
45625
+ const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
45626
+ const said = narrativeFrom(i.description);
45627
+ const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
45628
+ stories.push({
45629
+ id: key, name: plainText(i.title), epic: parent, release,
45630
+ as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
45631
+ criteria: own.length ? own : inText.criteria,
45632
+ status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
45633
+ labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
45634
+ source: source(i, key), traces: [],
45635
+ });
45636
+ }
45637
+ // A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
45638
+ for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
45639
+ return { epics: [...epics.values()], releases: [...releases.values()], stories };
45640
+ }
45641
+ }
45642
+
45643
+ /**
45644
+ * Storyboard — epics and stories from a storyboard or requirements document
45645
+ * written in Markdown (or plain text with Markdown headings), as teams keep
45646
+ * them beside or before a tracker:
45647
+ *
45648
+ * - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
45649
+ * "Story" (`### Story: Pay by card`) a story; otherwise the document's one
45650
+ * title (`#`) is its name, the next level down are epics and the level
45651
+ * below that stories;
45652
+ * - under an epic, a list item that reads "As a … I want …" is a story too;
45653
+ * - under a story, "As a … I want … so that …" is its narrative, the list
45654
+ * under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
45655
+ * and the rest its description — a picture (`![caption](file)`) kept there
45656
+ * by its caption.
45657
+ *
45658
+ * `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
45659
+ * are made from names (prefix: `prefix`, so two storyboards read into one
45660
+ * model stay apart).
45661
+ */
45662
+ class Storyboard {
45663
+ /** @param {{prefix?: string}} [options] */
45664
+ constructor({ prefix = '' } = {}) {
45665
+ this.prefix = prefix;
45666
+ }
45667
+
45668
+ read(text) {
45669
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
45670
+ const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
45671
+ .map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
45672
+ const kindOf = (h) => (/^epic\b/i.test(h.text) ? 'epic' : /^(user\s+)?story\b/i.test(h.text) || /\bas an?\s+\S/i.test(h.text) ? 'story' : null);
45673
+ const top = Math.min(...headings.map(h => h.level));
45674
+ const tops = headings.filter(h => h.level === top);
45675
+ // One top heading, a title (#), names the document; its children are the epics.
45676
+ const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
45677
+ const epicLevel = named ? top + 1 : top;
45678
+ const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
45679
+
45680
+ const epics = [], stories = [];
45681
+ const ids = new Set();
45682
+ const idFor = (name, kind) => {
45683
+ const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
45684
+ let id = base;
45685
+ for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
45686
+ ids.add(id);
45687
+ return id;
45688
+ };
45689
+ const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
45690
+ let epic = null;
45691
+ headings.forEach((h, i) => {
45692
+ if (named && h === tops[0]) return;
45693
+ const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
45694
+ const body = bodyOf(h, i);
45695
+ if (kind === 'epic') {
45696
+ epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
45697
+ epics.push(epic);
45698
+ const listed = this._listedStories(body);
45699
+ epic.description = listed.rest;
45700
+ for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
45701
+ } else if (kind === 'story') {
45702
+ stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
45703
+ }
45704
+ });
45705
+ if (!headings.length || (!epics.length && !stories.length)) {
45706
+ for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
45707
+ }
45708
+ return { name: named ? tops[0].text : '', epics, stories };
45709
+ }
45710
+
45711
+ /** A story from its heading and what is under it. @private */
45712
+ _story(name, body) {
45713
+ const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
45714
+ const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
45715
+ const said = narrativeFrom(text);
45716
+ const { as, want, soThat } = said.want ? said : narrativeFrom(name);
45717
+ const { criteria, rest } = criteriaFrom(text);
45718
+ const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
45719
+ return { name, as, want, soThat, description, criteria };
45720
+ }
45721
+
45722
+ /** "As a … I want …" list items: stories; the rest of the text, kept. @private */
45723
+ _listedStories(body) {
45724
+ const stories = [];
45725
+ const rest = [];
45726
+ for (const line of body.split('\n')) {
45727
+ const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
45728
+ const said = item && narrativeFrom(item[1]);
45729
+ if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
45730
+ else rest.push(line);
45731
+ }
45732
+ return { stories, rest: rest.join('\n').trim() };
45733
+ }
45734
+ }
45735
+
45736
+ /**
45737
+ * The files a requirements model is read from that another tool wrote — they
45738
+ * import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
45739
+ * GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
45740
+ * the file as a new model; `spec(text)` is what it holds, for a part that
45741
+ * brings it into the model open (RequirementsModel#bringCommand).
45742
+ */
45743
+ class RequirementsImportFormat extends ModelFileFormat {
45744
+ get exports() { return false; }
45745
+
45746
+ /** What the file holds, as a RequirementsModel spec. @abstract */
45747
+ spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
45748
+
45749
+ import(text) {
45750
+ return new RequirementsModel(this.spec(text));
45751
+ }
45752
+ }
45753
+
45754
+ class TrackerExportFileFormat extends RequirementsImportFormat {
45755
+ get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
45756
+ get accept() { return '.csv,.json'; }
45757
+ get featureName() { return 'TrackerExport'; }
45758
+
45759
+ /** A CSV or JSON export one of the trackers wrote. */
45760
+ recognizes(text) {
45761
+ return !!TrackerExport.detect(text);
45762
+ }
45763
+
45764
+ spec(text) {
45765
+ const { tracker, ...spec } = new TrackerExport().read(text);
45766
+ return { name: `${TrackerExport.label(tracker)} export`, ...spec };
45767
+ }
45768
+ }
45769
+
45770
+ class StoryboardFileFormat extends RequirementsImportFormat {
45771
+ get label() { return 'Storyboard (Markdown)'; }
45772
+ get accept() { return '.md,.markdown,.txt'; }
45773
+ get featureName() { return 'Storyboard'; }
45774
+
45775
+ spec(text) {
45776
+ return new Storyboard().read(text);
45777
+ }
45778
+ }
45779
+
44747
45780
  /**
44748
45781
  * propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
44749
45782
  *
@@ -47655,6 +48688,7 @@
47655
48688
  exports.AttributedLevel = AttributedLevel;
47656
48689
  exports.BackgroundCommand = BackgroundCommand;
47657
48690
  exports.BackgroundGroup = BackgroundGroup;
48691
+ exports.Benchmark = Benchmark;
47658
48692
  exports.BianActionTerm = BianActionTerm;
47659
48693
  exports.BianFunctionalPattern = BianFunctionalPattern;
47660
48694
  exports.BianLandscapeReader = BianLandscapeReader;
@@ -47732,6 +48766,7 @@
47732
48766
  exports.EditHintCommand = EditHintCommand;
47733
48767
  exports.EditPropertyCommand = EditPropertyCommand;
47734
48768
  exports.EditViewItemCommand = EditViewItemCommand;
48769
+ exports.EditableModel = EditableModel;
47735
48770
  exports.EditorShell = EditorShell;
47736
48771
  exports.ElementFilterRule = ElementFilterRule;
47737
48772
  exports.ElementInstance = ElementInstance;
@@ -47776,6 +48811,7 @@
47776
48811
  exports.Mat4 = Mat4;
47777
48812
  exports.MeasuredCategory = MeasuredCategory;
47778
48813
  exports.MenuBar = MenuBar;
48814
+ exports.ModelEditCommand = ModelEditCommand;
47779
48815
  exports.ModelFileFormat = ModelFileFormat;
47780
48816
  exports.ModelScorecard = ModelScorecard;
47781
48817
  exports.ModelTree = ModelTree;
@@ -47810,6 +48846,9 @@
47810
48846
  exports.RemoveLineCommand = RemoveLineCommand;
47811
48847
  exports.RenderEngine = RenderEngine;
47812
48848
  exports.ReorderCommand = ReorderCommand;
48849
+ exports.RequirementsChecker = RequirementsChecker;
48850
+ exports.RequirementsFileFormat = RequirementsFileFormat;
48851
+ exports.RequirementsModel = RequirementsModel;
47813
48852
  exports.ResizeCommand = ResizeCommand;
47814
48853
  exports.RotateCommand = RotateCommand;
47815
48854
  exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
@@ -47826,8 +48865,11 @@
47826
48865
  exports.SqlDdlFileFormat = SqlDdlFileFormat;
47827
48866
  exports.StadiumTemplate = StadiumTemplate;
47828
48867
  exports.StarTemplate = StarTemplate;
48868
+ exports.Storyboard = Storyboard;
48869
+ exports.StoryboardFileFormat = StoryboardFileFormat;
47829
48870
  exports.StructuredType = StructuredType;
47830
48871
  exports.SwimlaneTemplate = SwimlaneTemplate;
48872
+ exports.TRACKERS = TRACKERS;
47831
48873
  exports.TREE_LAYERS = TREE_LAYERS;
47832
48874
  exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
47833
48875
  exports.Template = Template;
@@ -47841,6 +48883,8 @@
47841
48883
  exports.ToolbarSelect = ToolbarSelect;
47842
48884
  exports.ToolbarSeparator = ToolbarSeparator;
47843
48885
  exports.ToolbarText = ToolbarText;
48886
+ exports.TrackerExport = TrackerExport;
48887
+ exports.TrackerExportFileFormat = TrackerExportFileFormat;
47844
48888
  exports.TrapezoidTemplate = TrapezoidTemplate;
47845
48889
  exports.TreeModeRegistry = TreeModeRegistry;
47846
48890
  exports.TriangleTemplate = TriangleTemplate;