yarramate 1.4.1 → 1.6.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.
@@ -0,0 +1,293 @@
1
+ import { writeXlsx } from './workbook-xlsx.js';
2
+ /**
3
+ * The model as a workbook an architect can work in (#355).
4
+ *
5
+ * The shape is borrowed from a real consulting workbook rather than invented:
6
+ * identity is columnar and readability is derived. A stable id in column A,
7
+ * foreign keys inline, and an adjacent `(auto)` column carrying the referenced
8
+ * subject's NAME so a sheet reads without hunting across tabs. The id is what
9
+ * round-trips; the `(auto)` column is a convenience and is ignored on import.
10
+ *
11
+ * Losslessness does not depend on this file enumerating every predicate
12
+ * correctly. Well-known predicates become columns because a person needs them
13
+ * to be columns; **everything else lands in `07 Other Facts` verbatim**, so a
14
+ * predicate added to the compiler after this was written still survives a
15
+ * round trip. A mapping that silently dropped what it did not recognise is the
16
+ * failure this design exists to avoid.
17
+ */
18
+ export const WORKBOOK_FORMAT = 'yarramate/workbook/v1';
19
+ /** Consumed into a column on `01 Concepts`, in this order. */
20
+ const CONCEPT_COLUMNS = [
21
+ ['yarramate/concept/kind', 'Kind'],
22
+ ['yarramate/concept/name', 'Name'],
23
+ ['yarramate/concept/description', 'Description'],
24
+ ['yarramate/lifecycle/status', 'Status'],
25
+ ['yarramate/ownership/owner', 'Owner'],
26
+ ['yarramate/organisation/folder', 'Folder'],
27
+ ];
28
+ /** Consumed into a column on `02 Relationships`, in this order. */
29
+ const RELATIONSHIP_COLUMNS = [
30
+ ['yarramate/relationship/name', 'Name'],
31
+ ['yarramate/relationship/description', 'Description'],
32
+ ['yarramate/access/mode', 'Mode'],
33
+ ['yarramate/flow/content', 'Content'],
34
+ ['yarramate/lifecycle/status', 'Status'],
35
+ ];
36
+ /**
37
+ * Consumed into a column on `03 States`.
38
+ *
39
+ * `concept/kind` is here as well as on `01 Concepts` because a state IS a
40
+ * concept: it carries an ordinary kind (`plateau`) alongside its state type
41
+ * (`baseline`, `transition`, `target`). Omitting it dropped four kind claims
42
+ * into the overflow sheet, which round-tripped correctly and read as though
43
+ * the mapping had not recognised them.
44
+ */
45
+ const STATE_COLUMNS = [
46
+ ['yarramate/concept/kind', 'Kind'],
47
+ ['yarramate/state/type', 'State kind'],
48
+ ['yarramate/concept/name', 'Name'],
49
+ ['yarramate/concept/description', 'Description'],
50
+ ];
51
+ /** Ref-valued predicates that get their own sheet rather than the overflow. */
52
+ const REFERENCE_PREDICATES = new Set([
53
+ 'yarramate/reference/refers-to',
54
+ 'yarramate/lineage/supersedes',
55
+ 'yarramate/lineage/supersedes-respect',
56
+ 'yarramate/identity/distinct-from',
57
+ 'yarramate/constraint/requires',
58
+ 'yarramate/constraint/expects',
59
+ ]);
60
+ const ALIAS_PREDICATE = 'yarramate/concept/alias';
61
+ const PRESENT_IN_PREDICATE = 'yarramate/state/present-in';
62
+ const STATE_TYPE_PREDICATE = 'yarramate/state/type';
63
+ const STATE_AFTER_PREDICATE = 'yarramate/state/after';
64
+ const KIND_PREDICATE = 'yarramate/concept/kind';
65
+ const NAME_PREDICATE = 'yarramate/concept/name';
66
+ const valueOf = (claim) => 'value' in claim.object ? claim.object.value : claim.object.ref;
67
+ const isRef = (claim) => 'ref' in claim.object;
68
+ /**
69
+ * A relationship IS a claim: its `id` is the relationship id, its `subject` is
70
+ * the source, its `predicate` is the kind and its object refs the target.
71
+ * Claims ABOUT a relationship carry that id as their own subject.
72
+ */
73
+ const relationshipClaims = (result) => {
74
+ const relationshipIds = new Set(result.subjects
75
+ .filter(({ type }) => type === 'relationship')
76
+ .map(({ id }) => id));
77
+ return result.claims.filter((claim) => relationshipIds.has(claim.id));
78
+ };
79
+ export const buildWorkbookSheets = (result, provenance) => {
80
+ const conceptIds = new Set(result.subjects.filter(({ type }) => type === 'concept').map(({ id }) => id));
81
+ const relationshipIds = new Set(result.subjects
82
+ .filter(({ type }) => type === 'relationship')
83
+ .map(({ id }) => id));
84
+ const bySubject = new Map();
85
+ for (const claim of result.claims) {
86
+ const held = bySubject.get(claim.subject);
87
+ if (held === undefined)
88
+ bySubject.set(claim.subject, [claim]);
89
+ else
90
+ held.push(claim);
91
+ }
92
+ const nameOf = (id) => {
93
+ const claim = bySubject
94
+ .get(id)
95
+ ?.find(({ predicate }) => predicate === NAME_PREDICATE);
96
+ return claim === undefined ? '' : valueOf(claim);
97
+ };
98
+ const first = (id, predicate) => {
99
+ const claim = bySubject.get(id)?.find((held) => held.predicate === predicate);
100
+ return claim === undefined ? '' : valueOf(claim);
101
+ };
102
+ /**
103
+ * The document a subject is declared in, as a PATH.
104
+ *
105
+ * Carried because an `apply` operation targets a document path, so without
106
+ * it a workbook can say what a subject is and not where it lives, and
107
+ * nothing can be written back. Every claim knows its own source, so this is
108
+ * recovered rather than guessed.
109
+ */
110
+ const documentOf = (id) => bySubject.get(id)?.[0]?.source.path ?? '';
111
+ const documentOfClaim = (claim) => claim.source.path;
112
+ // A state is an ordinary concept carrying state predicates, so it is lifted
113
+ // onto its own sheet rather than being a second kind of thing.
114
+ const stateIds = new Set([...conceptIds].filter((id) => bySubject
115
+ .get(id)
116
+ ?.some(({ predicate }) => predicate === STATE_TYPE_PREDICATE) === true));
117
+ /** Every claim a column or a named sheet has already carried. */
118
+ const consumed = new Set();
119
+ const consume = (id, predicate) => {
120
+ const claim = bySubject.get(id)?.find((held) => held.predicate === predicate);
121
+ if (claim !== undefined)
122
+ consumed.add(claim.id);
123
+ };
124
+ const conceptRows = [
125
+ ['Concept ID', ...CONCEPT_COLUMNS.map(([, label]) => label), 'Document'],
126
+ ];
127
+ for (const id of [...conceptIds].filter((one) => !stateIds.has(one)).sort()) {
128
+ for (const [predicate] of CONCEPT_COLUMNS)
129
+ consume(id, predicate);
130
+ conceptRows.push([
131
+ id,
132
+ ...CONCEPT_COLUMNS.map(([predicate]) => first(id, predicate)),
133
+ documentOf(id),
134
+ ]);
135
+ }
136
+ const relationshipRows = [
137
+ [
138
+ 'Relationship ID',
139
+ 'Kind',
140
+ 'From',
141
+ '↳ From name (auto)',
142
+ 'To',
143
+ '↳ To name (auto)',
144
+ ...RELATIONSHIP_COLUMNS.map(([, label]) => label),
145
+ 'Document',
146
+ ],
147
+ ];
148
+ for (const claim of [...relationshipClaims(result)].sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0)) {
149
+ consumed.add(claim.id);
150
+ for (const [predicate] of RELATIONSHIP_COLUMNS)
151
+ consume(claim.id, predicate);
152
+ const target = 'ref' in claim.object ? claim.object.ref : '';
153
+ relationshipRows.push([
154
+ claim.id,
155
+ claim.predicate,
156
+ claim.subject,
157
+ nameOf(claim.subject),
158
+ target,
159
+ nameOf(target),
160
+ ...RELATIONSHIP_COLUMNS.map(([predicate]) => first(claim.id, predicate)),
161
+ documentOfClaim(claim),
162
+ ]);
163
+ }
164
+ const stateRows = [
165
+ [
166
+ 'State ID',
167
+ ...STATE_COLUMNS.map(([, label]) => label),
168
+ 'After',
169
+ '↳ After name (auto)',
170
+ 'Document',
171
+ ],
172
+ ];
173
+ for (const id of [...stateIds].sort()) {
174
+ for (const [predicate] of STATE_COLUMNS)
175
+ consume(id, predicate);
176
+ consume(id, STATE_AFTER_PREDICATE);
177
+ const after = first(id, STATE_AFTER_PREDICATE);
178
+ stateRows.push([
179
+ id,
180
+ ...STATE_COLUMNS.map(([predicate]) => first(id, predicate)),
181
+ after,
182
+ nameOf(after),
183
+ documentOf(id),
184
+ ]);
185
+ }
186
+ const presentInRows = [
187
+ ['Subject ID', '↳ Subject name (auto)', 'State ID', '↳ State name (auto)'],
188
+ ];
189
+ const referenceRows = [
190
+ ['Subject ID', '↳ Subject name (auto)', 'Predicate', 'Target ID', '↳ Target name (auto)'],
191
+ ];
192
+ const aliasRows = [['Subject ID', '↳ Subject name (auto)', 'Also known as']];
193
+ const otherRows = [['Subject ID', 'Predicate', 'Value', 'Target ID']];
194
+ for (const claim of result.claims) {
195
+ if (consumed.has(claim.id))
196
+ continue;
197
+ if (relationshipIds.has(claim.id))
198
+ continue;
199
+ if (claim.predicate === PRESENT_IN_PREDICATE) {
200
+ const state = valueOf(claim);
201
+ presentInRows.push([claim.subject, nameOf(claim.subject), state, nameOf(state)]);
202
+ continue;
203
+ }
204
+ if (claim.predicate === ALIAS_PREDICATE) {
205
+ aliasRows.push([claim.subject, nameOf(claim.subject), valueOf(claim)]);
206
+ continue;
207
+ }
208
+ if (REFERENCE_PREDICATES.has(claim.predicate)) {
209
+ const target = valueOf(claim);
210
+ referenceRows.push([
211
+ claim.subject,
212
+ nameOf(claim.subject),
213
+ claim.predicate,
214
+ target,
215
+ nameOf(target),
216
+ ]);
217
+ continue;
218
+ }
219
+ // The overflow, and the reason a round trip stays lossless when the
220
+ // compiler grows a predicate this file has never heard of.
221
+ otherRows.push([
222
+ claim.subject,
223
+ claim.predicate,
224
+ isRef(claim) ? '' : valueOf(claim),
225
+ isRef(claim) ? valueOf(claim) : '',
226
+ ]);
227
+ }
228
+ const readMe = [
229
+ ['YarraMate workbook'],
230
+ [],
231
+ ['Workspace', provenance.workspace],
232
+ ['Projection', result.projection],
233
+ ['Built by', `yarramate ${provenance.yarramateVersion}`],
234
+ [],
235
+ ['How to use this'],
236
+ ['1.', 'Edit the numbered sheets. Column A is the identity of the row and is what the model is keyed on.'],
237
+ ['2.', 'A column marked (auto) is derived for readability. Edits to it are ignored.'],
238
+ ['3.', 'Add a row with a new ID to add a subject. Kind and Status are dropdowns, and Document says which file it belongs to.'],
239
+ ['4.', 'Deleting a row does NOT delete anything. Removals are reported, never applied.'],
240
+ ['5.', 'Send the file back and import it. Your edits are merged; only a field changed on both sides is refused.'],
241
+ [],
242
+ ['Sheets beginning ~ are machinery. Do not edit them.'],
243
+ ];
244
+ const listColumns = [
245
+ ['Concept kind', ...provenance.conceptKinds],
246
+ ['Relationship kind', ...provenance.relationshipKinds],
247
+ ['Status', ...provenance.statuses],
248
+ ];
249
+ const listHeight = Math.max(...listColumns.map((column) => column.length));
250
+ const listRows = [];
251
+ for (let row = 0; row < listHeight; row += 1) {
252
+ listRows.push(listColumns.map((column) => column[row] ?? ''));
253
+ }
254
+ const metaRows = [
255
+ ['Key', 'Value'],
256
+ ['Format', WORKBOOK_FORMAT],
257
+ ['Workspace', provenance.workspace],
258
+ ['Projection', result.projection],
259
+ ['YarraMate version', provenance.yarramateVersion],
260
+ ...Object.entries(provenance.sourceDigests)
261
+ .sort(([left], [right]) => (left < right ? -1 : 1))
262
+ .map(([path, digest]) => [`Digest ${path}`, digest]),
263
+ ];
264
+ const working = [
265
+ { name: '00 Read Me', rows: readMe },
266
+ { name: '01 Concepts', rows: conceptRows, frozenRows: 1 },
267
+ { name: '02 Relationships', rows: relationshipRows, frozenRows: 1 },
268
+ { name: '03 States', rows: stateRows, frozenRows: 1 },
269
+ { name: '04 Present In', rows: presentInRows, frozenRows: 1 },
270
+ { name: '05 References', rows: referenceRows, frozenRows: 1 },
271
+ { name: '06 Aliases', rows: aliasRows, frozenRows: 1 },
272
+ { name: '07 Other Facts', rows: otherRows, frozenRows: 1 },
273
+ ];
274
+ // The merge ancestor. Never edited, never updated: it records what the model
275
+ // looked like when this workbook was made, so import can tell an FDE's edit
276
+ // from a change the repository made underneath since. Each row carries its
277
+ // sheet name so one sheet can hold them all.
278
+ const baselineRows = [['Sheet', 'Cells...']];
279
+ for (const sheet of working) {
280
+ if (sheet.name === '00 Read Me')
281
+ continue;
282
+ for (const row of sheet.rows)
283
+ baselineRows.push([sheet.name, ...row]);
284
+ }
285
+ return [
286
+ ...working,
287
+ { name: '~Lists', state: 'hidden', rows: listRows },
288
+ { name: '~Meta', state: 'hidden', rows: metaRows },
289
+ { name: '~Baseline', state: 'veryHidden', rows: baselineRows },
290
+ ];
291
+ };
292
+ /** The workbook, as bytes. Synchronous and deterministic. */
293
+ export const workbookFrom = (result, provenance) => writeXlsx(buildWorkbookSheets(result, provenance));
@@ -408,6 +408,70 @@ socket/session host.
408
408
 
409
409
  [...]
410
410
 
411
+ ## The model as an Excel workbook
412
+
413
+ `yarramate export xlsx <projection.yaml> <workspace.yaml> --out <file>` writes
414
+ a workbook an architect or FDE can work in, and `yarramate/workbook` publishes
415
+ the writer for a host that has no Node:
416
+
417
+ ```ts
418
+ import { workbookFrom } from 'yarramate/workbook'
419
+
420
+ const bytes = workbookFrom(projectionResult, {
421
+ workspace: 'acme',
422
+ yarramateVersion: '1.5.0',
423
+ sourceDigests,
424
+ conceptKinds,
425
+ relationshipKinds,
426
+ statuses: ['planned', 'current', 'retired'],
427
+ })
428
+ ```
429
+
430
+ Synchronous, dependency-free and deterministic: the same input always produces
431
+ the same bytes. The import graph is held free of Node builtins and of the
432
+ compiler at runtime, so it fits a Cloudflare Worker or a Durable Object. A
433
+ caller hands over an already-evaluated `ProjectionResult`, which is what keeps
434
+ schema validation out of that graph.
435
+
436
+ **It takes a projection**, so it inherits every facet a projection query has.
437
+ That is how a caller chooses which *version* to export: a query naming
438
+ `states` produces a workbook of that architecture state, with no separate
439
+ flag.
440
+
441
+ ```yaml
442
+ query:
443
+ states:
444
+ - target-state
445
+ ```
446
+
447
+ **Reading the sheets.** Column A is always the id and is what the model is
448
+ keyed on. A column headed `↳ … (auto)` is derived for readability and is
449
+ ignored on the way back. Kind and status columns are drawn from the compiled
450
+ profile, so they carry the vocabulary that workspace actually has.
451
+
452
+ Sheets beginning `~` are machinery. `~Meta` records the format, the versions
453
+ and the source digests. `~Baseline` is a hidden copy of the working rows
454
+ exactly as exported, and exists so a later import can tell an author's edit
455
+ from a change the repository made underneath. Neither is edited.
456
+
457
+ Anything the mapping does not recognise is carried verbatim on `07 Other
458
+ Facts` rather than dropped, so a workbook stays lossless across a compiler
459
+ that grows new predicates.
460
+
461
+ **Reading one back.** `yarramate import xlsx <workbook.xlsx> <workspace.yaml>`
462
+ merges an edited workbook into the model. An unedited round trip changes
463
+ nothing, byte for byte.
464
+
465
+ Edits land as `yarramate/operations/v1` through `apply`, so untouched YAML
466
+ keeps its comments, key order and formatting, and the import passes the same
467
+ atomic gate every other write does.
468
+
469
+ It is a three-way merge. `~Baseline` is the ancestor, so an edit the workspace
470
+ did not touch merges even if the repository moved on; only a field changed on
471
+ **both** sides is refused, and a refusal writes nothing anywhere. A missing row
472
+ is reported and never treated as a deletion, and a new row needs its `Document`
473
+ column filled in.
474
+
411
475
  ## MCP server for agent harnesses
412
476
 
413
477
  Harnesses that load MCP servers can connect the bundled read-only adapter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yarramate",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Tool-neutral semantic architecture engine and guided methodology",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,6 +58,10 @@
58
58
  "types": "./dist/interrogation-entry.d.ts",
59
59
  "import": "./dist/interrogation-entry.js"
60
60
  },
61
+ "./workbook": {
62
+ "types": "./dist/workbook-entry.d.ts",
63
+ "import": "./dist/workbook-entry.js"
64
+ },
61
65
  "./visual-app": {
62
66
  "types": "./dist/visual-app-lib/editor.d.ts",
63
67
  "import": "./dist/visual-app-lib/editor.js"