@formicoidea/labre-framework-wardley 0.32.0 → 0.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/import.js ADDED
@@ -0,0 +1,905 @@
1
+ import { ConnectorMode, FontFamily, PointStyle, ShapeStyle, StrokeStyle, TextAlign, } from '@formicoidea/labre-core/model';
2
+ import { WARDLEY_BACKGROUND } from './background.js';
3
+ import { OWM_DEFAULT_MAP_HEIGHT, OWM_DEFAULT_MAP_WIDTH, OWM_LABEL_HEIGHT, OWM_LABEL_WIDTH, OWM_SCOPE, OWM_TAIL_ATTR, OWM_KEYWORDS, OWM_TITLE_ATTR, owmDefaultPlot, owmPointOf, WARDLEY_OWM_FORMAT_ID, } from './export.js';
4
+ import { HANDLE_SIZE, LABEL_GAP, LABEL_FONT_SIZE, LINK_GREY, LINK_STROKE_WIDTH, MARKET_DOT_RING, MARKET_DOT_SIZE, MARKET_DOT_STROKE_WIDTH, MARKET_LINK_COLOR, MARKET_LINK_WIDTH, MARKET_SIZE, ECOSYSTEM_SIZE, NODE_FILL, NODE_SIZE, NODE_STROKE, NODE_STROKE_WIDTH, PIPELINE_FILL, PIPELINE_HEIGHT, WARDLEY_RED, } from './node/consts.js';
5
+ import { WARDLEY_ROLE } from './roles.js';
6
+ /**
7
+ * An OnlineWardleyMaps (OWM) DSL document, read as a Wardley map — the inverse
8
+ * of `export.ts` on the vocabulary Labre draws, and an honest accounting of
9
+ * everything else (`docs/adr/0012`, D1–D6).
10
+ *
11
+ * ADR 0012 calls this row **the reference Wardley import**, and says why: the
12
+ * OWM DSL is the one Wardley vocabulary that is settled, so it is what a user
13
+ * should be pointed at while mermaid's Wardley diagram type is still
14
+ * experimental upstream.
15
+ *
16
+ * ## Pure by construction, like its mirror
17
+ *
18
+ * A string in, element PROPS out. No `BlockStdScope`, no surface, no DOM, no
19
+ * clock, no randomness — and, unlike `.bpmn`, no parser either: the DSL is
20
+ * line-based, so this reads lines. The caller does the writing (P3).
21
+ *
22
+ * ## What the caller owes, and it is one thing
23
+ *
24
+ * **OWM has no ids: the NAME is the identity.** So every element below carries
25
+ * the name it was declared under, verbatim, in `interchange.owm.id`, and a link
26
+ * arrives with `source` / `target` naming those — which is exactly the map
27
+ * `materializeInterchangeImport` folds the returned array into (D3). Nothing
28
+ * else is needed to finish the import, and the same fold is what makes
29
+ * `boardFromProps` in the spec suite a bridge rather than a mock.
30
+ *
31
+ * A name declared twice is imported twice — nothing is dropped — and every link
32
+ * naming it means the FIRST, which is both the materializer's rule and OWM's
33
+ * own. The report says so by name.
34
+ *
35
+ * ## Where the coordinates come from, and where they do not (D4)
36
+ *
37
+ * A `[visibility, evolution]` pair IS the authoritative position — that is D4's
38
+ * "a format that carries coordinates but no pixels" — so the reader projects it
39
+ * onto the plot of the map it lays down and re-layouts nothing. A statement
40
+ * that carries NO pair inverts the same rule rather than contradicting it:
41
+ * there is nothing to be authoritative, so the reader lays one out and SAYS SO,
42
+ * with an `invented-layout` note naming the artefact. **An invented axis is
43
+ * never presented as read from the file.** That is the whole of D4 for a
44
+ * coordinate format, and it is the case `anchor Client` — one line of the
45
+ * tea-shop corpus, and the shape half the maps in the wild are written in.
46
+ *
47
+ * ## Three states, and the middle one is where the file survives
48
+ *
49
+ * **Mapped** is what the pack draws: `component`, `anchor`, `market`,
50
+ * `ecosystem`, `pipeline`, `note`, `evolve`, `title`, and the `->` links.
51
+ * **Carried** is every other statement — `style`, `annotation`, `attitudes`
52
+ * (`pioneers` / `settlers` / `townplanners`), `submap`, `url`, `size`,
53
+ * `accelerator`, the axis-label overrides, the flow links (`+>`, `+<`, `+<>`,
54
+ * `+'…'>`), a link carrying a `;` context, and every `//` comment — kept
55
+ * verbatim in `interchange.owm.children['@document']` on the map's background
56
+ * element (D6) and written back, in order, at the end of the next export.
57
+ * **Quarantined** is empty, and that is a finding rather than an omission: see
58
+ * the loss table.
59
+ *
60
+ * A mapped line keeps its own tail too — `label [12, -8]`, `(build)`,
61
+ * `inertia`, a trailing comment — under `attrs['@self'].tail` on the artefact
62
+ * it belongs to, so the modifiers this pack does not draw come back on the
63
+ * line they were written on rather than at the bottom of the file.
64
+ *
65
+ * ## The loss table
66
+ *
67
+ * Every semantic capability owes one (ADR 0012), and this is the OWM DSL's.
68
+ * INVISIBLE is not LOST, and the distinction is the deliverable.
69
+ *
70
+ * | what | state | after a round trip |
71
+ * | ---------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- |
72
+ * | `component` / `anchor` / `market` / `ecosystem` and their coordinates | mapped | drawn, and written back from the drawing, to two decimals |
73
+ * | the NAME, which in this format is the identity | mapped | given back verbatim, quoted exactly as it needs to be — the fixed point |
74
+ * | `pipeline Name [m1, m2]` | mapped | drawn as the body + handle composite; `[m1, m2]` written back from the body's two edges |
75
+ * | `note Text [v, e]` | mapped | a free text on the map, written back from its centre |
76
+ * | `evolve X [-> Y] m` | mapped | an evolved twin at the same height plus a change arrow, written back from the arrow |
77
+ * | `A->B` links | mapped | a `wardley:dependency` connector, consumer → what it needs, never inverted |
78
+ * | `title` | mapped | consumed as the board's own name; written back from the board's name (see the two rows below) |
79
+ * | a mapped line's TAIL — `label [x, y]`, `(build)`, `inertia`, comments | carried | invisible on the canvas, re-appended to the very line it came off |
80
+ * | `style`, `annotation(s)`, `attitudes`, `submap`, `url`, `size`, `accelerator`, the axis-label overrides | carried | invisible on the canvas, re-emitted verbatim at the end of the file |
81
+ * | flow links (`+>`, `+<`, `+<>`, `+'…'>`) and a link with a `;` context | carried | not drawn — a flow is not a dependency — and re-emitted verbatim |
82
+ * | a `pipeline` with a `{ … }` body | carried | the block is kept whole and written back whole; the pack draws no pipeline children |
83
+ * | `//` comments | carried | re-emitted verbatim, at the end |
84
+ * | anything else the reader does not recognise | carried | re-emitted verbatim, at the end |
85
+ * | **nothing** | quarantined | every statement this format writes is a standalone sentence, so nothing carried can contradict the drawing (D5) |
86
+ * | a statement with NO coordinates (`anchor Client`) | **lost** | the reader places it at the reference default (0.9, 0.1) and says so; the export then writes the coordinates we invented, not the silence the file had |
87
+ * | a statement with MALFORMED coordinates | **lost** | same, and the report carries a `warning` as well as the `invented-layout` note |
88
+ * | a statement positioned on ONE axis (`[0.5]`, `[nonsense, 0.5]`) | **half mapped, half lost** | the axis the file gave is read and kept exactly; the other takes the reference default and gets its own `invented-layout` note naming WHICH axis was invented. The statement counts as `mapped` — it was positioned — and the export writes a full pair, so the axis the file left silent comes back as a number |
89
+ * | the file's own line ORDER and its blank lines | **lost** | the writer groups statements into sections. Nothing semantic depends on it, and carrying blank lines would make an untouched Labre file report a carried count |
90
+ * | a `pipeline`'s visibility | **lost** | OWM derives it from the component of the same name; the reader places the body under that component and the writer does not write it back |
91
+ * | an `evolve` twin drawn at a different height | **lost** | `evolve` moves along the evolution axis only; the export warns |
92
+ * | a Labre `method` node (build / buy / outsource) | **lost** | written as a plain component — OWM says a method with a decorator this writer cannot tell apart. The export warns |
93
+ * | the file's `title` | **carried**, and it WINS | kept under `attrs['@document'].title` and written back in preference to the name the caller passes — D3's precedence, the same one `interchange.<fmt>.id` has on every element. A board renamed in Labre therefore still exports under the title its file carried, and the export warns that it did; the caller's name is used only when the file carried none (and is still what the DOWNLOAD is called either way) |
94
+ * | surface identity across a re-import | **lost** | a new map beside the old one, never a merge |
95
+ * | a name whose whitespace matters (`Foo&nbsp;&nbsp;&nbsp;Bar`) | **round-trips here, at risk elsewhere** | this reader keeps a name VERBATIM and this writer gives it back verbatim, so Labre → Labre is exact. onlinewardleymaps does not: `normalizeComponentName` collapses whitespace runs before it matches a link end to a component, so a map that goes Labre → OWM → Labre may come back with its links dangling. Not sanitized here, because sanitizing would lose the author's name to protect another tool's matcher |
96
+ * | a top-level `market` / `ecosystem` statement | **round-trips here, refused elsewhere** | Labre reads and writes both. The reference `Converter` registers no strategy for either keyword, so such a line reaches `LinksExtractionStrategy`, has no `->` in it and is recorded as a PARSE ERROR — onlinewardleymaps does not merely fail to draw it. The interoperable spelling is a component carrying the decorator (`component Suppliers [0.3, 0.7] (market)`), which this pair already round-trips through the carried tail |
97
+ *
98
+ * `sourceVersion` reports the DIALECT, because the DSL declares no version: the
99
+ * OnlineWardleyMaps frontend's own extraction strategies, which is what this
100
+ * reader was written against. It reads `DSL (Labre)` for a file holding nothing
101
+ * this library does not itself write — the honest form of "a file we wrote",
102
+ * since the format has no marker to claim one with.
103
+ */
104
+ /* ── The format, re-exported so both halves agree ─────────────────────── */
105
+ export { WARDLEY_OWM_FORMAT_ID, OWM_SCOPE };
106
+ /** The dialect this reader implements, and how a Labre-shaped file reads. */
107
+ const OWM_DIALECT = 'DSL';
108
+ const OWM_DIALECT_LABRE = 'DSL (Labre)';
109
+ /* ── Reading one line ─────────────────────────────────────────────────── */
110
+ /** A quoted value and what follows it. */
111
+ function readQuoted(raw) {
112
+ let index = 1;
113
+ let value = '';
114
+ while (index < raw.length) {
115
+ const char = raw[index];
116
+ if (char === '\\' && index + 1 < raw.length) {
117
+ value += raw[index] + raw[index + 1];
118
+ index += 2;
119
+ continue;
120
+ }
121
+ if (char === '"')
122
+ return { value: unescapeName(value), rest: raw.slice(index + 1) };
123
+ value += char;
124
+ index += 1;
125
+ }
126
+ // No closing quote: OWM recovers rather than refusing, and so does this.
127
+ return { value: unescapeName(value), rest: '' };
128
+ }
129
+ /** OWM's `unescapeComponentNameFromMapText`, minus the quote stripping. */
130
+ function unescapeName(raw) {
131
+ return raw
132
+ .replaceAll('\\n', '\n')
133
+ .replaceAll('\\r', '\r')
134
+ .replaceAll('\\t', '\t')
135
+ .replaceAll('\\"', '"')
136
+ .replaceAll('\\]', ']')
137
+ .replaceAll('\\[', '[')
138
+ .replaceAll('\\\\', '\\');
139
+ }
140
+ /** A name (quoted or bare) and what follows it, in a keyword statement. */
141
+ function readName(raw) {
142
+ const trimmed = raw.trimStart();
143
+ if (trimmed.startsWith('"')) {
144
+ const { value, rest } = readQuoted(trimmed);
145
+ return { name: value, rest };
146
+ }
147
+ // Bare: the name runs up to the coordinate bracket, exactly as OWM's own
148
+ // `setName` splits it. A statement that opens straight on `[` has no name.
149
+ if (trimmed.startsWith('['))
150
+ return { name: '', rest: trimmed };
151
+ const spaced = trimmed.indexOf(' [');
152
+ if (spaced !== -1) {
153
+ return {
154
+ name: trimmed.slice(0, spaced).trim(),
155
+ rest: trimmed.slice(spaced),
156
+ };
157
+ }
158
+ // `component Kettle[0.1, 0.2]`, with no space before the bracket. OWM's own
159
+ // `setName` splits on `' ['` and therefore keeps `Kettle[0.1, 0.2]` whole as
160
+ // the name, while `extractLocation` splits on a bare `[` and reads the pair
161
+ // anyway — so the file IS positioned, and only the name comes out wrong.
162
+ // Splitting on the bare bracket maps the statement the way the coordinates
163
+ // say it was meant, which beats both inventing a layout and keeping OWM's
164
+ // own mangled name.
165
+ const tight = trimmed.indexOf('[');
166
+ if (tight !== -1) {
167
+ return { name: trimmed.slice(0, tight).trim(), rest: trimmed.slice(tight) };
168
+ }
169
+ return { name: trimmed.trim(), rest: '' };
170
+ }
171
+ /**
172
+ * OWM's own defaults for an axis a statement did not give
173
+ * (`extractLocation`, `constants/extractionFunctions.ts`). Used rather than a
174
+ * layout of our own, so an artefact this reader places without coordinates
175
+ * lands where the tool that wrote the file would have drawn it.
176
+ */
177
+ export const OWM_DEFAULT_VISIBILITY = 0.9;
178
+ export const OWM_DEFAULT_EVOLUTION = 0.1;
179
+ /**
180
+ * Read `[v, e]` the way `extractLocation` reads one — PER AXIS.
181
+ *
182
+ * The per-axis reading is not pedantry, it is what the reference parser does:
183
+ * `[0.5]` is a legal pair whose second member falls back to the default, and so
184
+ * is `[nonsense, 0.5]`. Reading the bracket as all-or-nothing would invent a
185
+ * layout for a statement the file had positioned on one axis, and D4 forbids
186
+ * presenting an invented axis as read from the file — so the two axes are
187
+ * tracked separately all the way to the note.
188
+ */
189
+ function readBracket(raw) {
190
+ const match = /^\s*\[([^\]]*)\]/.exec(raw);
191
+ if (!match)
192
+ return { present: false, tail: raw };
193
+ const tail = raw.slice(match[0].length);
194
+ const [visibility, evolution] = match[1]
195
+ .split(',')
196
+ .map(part => Number.parseFloat(part.trim()))
197
+ .map(value => (Number.isFinite(value) ? value : undefined));
198
+ return { present: true, visibility, evolution, tail };
199
+ }
200
+ /**
201
+ * A maturity, the way OWM spells one — and it always has a decimal point.
202
+ *
203
+ * The reference regex is `/\s[0-9]?\.[0-9]+[0-9]?/` (`setNameWithMaturity`), so
204
+ * a bare integer is NOT a maturity there. Accepting `0` and `1` here looked
205
+ * harmless and was not: `evolve Tier 1 0.75` would take `1` as the maturity and
206
+ * `Tier` as the name, so the component the line is about is one nobody
207
+ * declared — the twin and its arrow then vanish on the next export. Names
208
+ * ending in a digit are ordinary (`Tier 1`, `Wave 0`, `Region 1`).
209
+ */
210
+ const MATURITY = String.raw `[0-9]*\.[0-9]+`;
211
+ /**
212
+ * `<anything> <maturity>[ <tail>]`. The `.*?` is lazy, so the FIRST
213
+ * maturity-shaped token ends the name and everything after it is the tail —
214
+ * which is the reference reader's own behaviour (`element.match(...)` returns
215
+ * the first hit).
216
+ */
217
+ const BEFORE_MATURITY = new RegExp(String.raw `^(.*?)(\s+${MATURITY})(\s.*)?$`);
218
+ const LEADING_MATURITY = new RegExp(String.raw `^\s*(${MATURITY})`);
219
+ /* ── The statements a document is made of ─────────────────────────────── */
220
+ /** The keywords that declare a positioned artefact, and the kind each is. */
221
+ const NODE_KEYWORDS = {
222
+ component: 'component',
223
+ anchor: 'anchor',
224
+ market: 'market',
225
+ ecosystem: 'ecosystem',
226
+ };
227
+ /* ── The reader ───────────────────────────────────────────────────────── */
228
+ /**
229
+ * An OWM document as element props, plus what became of every line.
230
+ *
231
+ * @param source the file, verbatim.
232
+ * @param context the caller's name for it, used only if the file names nothing.
233
+ */
234
+ export function importWardleyOwm(source, context = {}) {
235
+ const lines = source.split(/\r?\n/);
236
+ const notes = [];
237
+ const carriedLines = [];
238
+ /** How many lines of each construct were carried — one note per kind. */
239
+ const carriedKinds = new Map();
240
+ const nodeStatements = [];
241
+ const pipelines = [];
242
+ const noteStatements = [];
243
+ const evolutions = [];
244
+ const links = [];
245
+ let title;
246
+ /** Whether a LINE of the file put it there — only that one is `mapped`. */
247
+ let titledByFile = false;
248
+ const carry = (line, kind) => {
249
+ carriedLines.push(line);
250
+ carriedKinds.set(kind, (carriedKinds.get(kind) ?? 0) + 1);
251
+ };
252
+ const inventedNote = (element, name, why) => {
253
+ notes.push({
254
+ kind: 'invented-layout',
255
+ sourceId: name,
256
+ element,
257
+ message: why,
258
+ });
259
+ };
260
+ /**
261
+ * Where one positioned statement goes, and what the report owes for it (D4).
262
+ *
263
+ * Three outcomes, and the middle one is the reason this is per-axis. A
264
+ * statement with NO bracket is laid out whole and declared. One whose bracket
265
+ * this reader cannot make a single number of is the same, plus a `warning`,
266
+ * because the file said something and we could not read it. And one that
267
+ * positioned ONE axis — `[0.5]`, or `[nonsense, 0.5]`, both of which the
268
+ * reference parser accepts and defaults the rest of — is MAPPED at the
269
+ * coordinate it gave, with a note naming the axis it did not. An invented
270
+ * axis is never presented as read from the file, and that is true of half a
271
+ * pair as much as of a whole one.
272
+ */
273
+ const placeOf = (bracket, element, name, lineIndex) => {
274
+ const { visibility, evolution } = bracket;
275
+ const place = {
276
+ visibility: visibility ?? OWM_DEFAULT_VISIBILITY,
277
+ evolution: evolution ?? OWM_DEFAULT_EVOLUTION,
278
+ };
279
+ if (visibility !== undefined && evolution !== undefined) {
280
+ return { ...place, invented: false };
281
+ }
282
+ if (visibility === undefined && evolution === undefined) {
283
+ if (bracket.present) {
284
+ notes.push({
285
+ kind: 'warning',
286
+ sourceId: name,
287
+ element,
288
+ message: `line ${lineIndex + 1} declares coordinates this reader cannot make a number of, so they were not used.`,
289
+ });
290
+ }
291
+ inventedNote(element, name, bracket.present
292
+ ? 'its coordinates were unreadable, so it was placed where a map with no coordinates places things. The file did not say where it goes.'
293
+ : 'the file gives it no coordinates, so it was placed where a map with no coordinates places things. The file did not say where it goes.');
294
+ return { ...place, invented: true };
295
+ }
296
+ inventedNote(element, name, visibility === undefined
297
+ ? 'the file positions it on the evolution axis only, so its place on the value chain is this reader’s and not the file’s.'
298
+ : 'the file positions it on the value chain only, so its place on the evolution axis is this reader’s and not the file’s.');
299
+ return { ...place, invented: false };
300
+ };
301
+ /**
302
+ * Inside a `/* … *​/` block, where every line is a comment.
303
+ *
304
+ * The reference reader strips these in `Converter.stripComments` BEFORE any
305
+ * strategy sees the text, so a commented-out `component` is not a component
306
+ * there. Tracking the state here is what makes that true of this reader too:
307
+ * carrying only the opening line and then parsing the body would put an
308
+ * artefact on the canvas that the file's author had switched off, which is a
309
+ * worse failure than losing it — it is a drawing nobody made.
310
+ */
311
+ let insideBlockComment = false;
312
+ for (let index = 0; index < lines.length; index += 1) {
313
+ const raw = lines[index];
314
+ const line = raw.trim();
315
+ if (insideBlockComment) {
316
+ carry(raw, 'comment');
317
+ if (line.includes('*/'))
318
+ insideBlockComment = false;
319
+ continue;
320
+ }
321
+ if (line.length === 0)
322
+ continue;
323
+ if (line.startsWith('//')) {
324
+ carry(raw, 'comment');
325
+ continue;
326
+ }
327
+ if (line.startsWith('/*')) {
328
+ carry(raw, 'comment');
329
+ // A one-line block (`/* … */`) opens and closes on the same line.
330
+ if (!line.includes('*/', 2))
331
+ insideBlockComment = true;
332
+ continue;
333
+ }
334
+ /* `title` — consumed as the board's own name. */
335
+ if (line.startsWith('title ')) {
336
+ // FIRST wins, matching OWM's own reader, which returns on the first hit.
337
+ if (title === undefined) {
338
+ title = line.slice('title '.length).trim();
339
+ titledByFile = true;
340
+ }
341
+ else
342
+ carry(raw, 'title');
343
+ continue;
344
+ }
345
+ /* The four positioned artefacts. */
346
+ const keyword = Object.keys(NODE_KEYWORDS).find(word => line.startsWith(`${word} `));
347
+ if (keyword !== undefined) {
348
+ const { name, rest } = readName(line.slice(keyword.length + 1));
349
+ const bracket = readBracket(rest);
350
+ const named = name || `${keyword} ${index + 1}`;
351
+ const place = placeOf(bracket, keyword, named, index);
352
+ nodeStatements.push({
353
+ keyword,
354
+ name: named,
355
+ visibility: place.visibility,
356
+ evolution: place.evolution,
357
+ tail: bracket.tail,
358
+ invented: place.invented,
359
+ });
360
+ continue;
361
+ }
362
+ /* `pipeline` — the plain form is drawn; a `{ … }` body is carried whole. */
363
+ if (line.startsWith('pipeline ')) {
364
+ const block = blockAfter(lines, index);
365
+ if (block !== undefined) {
366
+ for (let at = index; at <= block; at += 1)
367
+ carriedLines.push(lines[at]);
368
+ carriedKinds.set('pipeline{}', (carriedKinds.get('pipeline{}') ?? 0) + (block - index + 1));
369
+ index = block;
370
+ continue;
371
+ }
372
+ const { name, rest } = readName(line.slice('pipeline '.length));
373
+ const bracket = readBracket(rest);
374
+ // A pipeline's bracket is a SPAN, not a position, so it defaults as a
375
+ // span does (`setPipelineMaturity`) and only a whole one is usable: half
376
+ // a span is not a narrower pipeline.
377
+ const usable = bracket.visibility !== undefined && bracket.evolution !== undefined;
378
+ const named = name || `pipeline ${index + 1}`;
379
+ pipelines.push({
380
+ name: named,
381
+ from: usable ? bracket.visibility : 0.2,
382
+ to: usable ? bracket.evolution : 0.8,
383
+ tail: bracket.tail,
384
+ invented: !usable,
385
+ });
386
+ if (!usable) {
387
+ inventedNote('pipeline', named, 'the file gives it no span, so it was drawn across the default one. The file did not say how wide it is.');
388
+ }
389
+ continue;
390
+ }
391
+ /* `note` — a free text at a position. */
392
+ if (line.startsWith('note ')) {
393
+ const { name, rest } = readName(line.slice('note '.length));
394
+ const bracket = readBracket(rest);
395
+ const place = placeOf(bracket, 'note', name, index);
396
+ noteStatements.push({
397
+ text: name,
398
+ visibility: place.visibility,
399
+ evolution: place.evolution,
400
+ tail: bracket.tail,
401
+ invented: place.invented,
402
+ });
403
+ continue;
404
+ }
405
+ /* `evolve` — a twin, and the arrow that says it is moving. */
406
+ if (line.startsWith('evolve ')) {
407
+ const parsed = readEvolve(line.slice('evolve '.length));
408
+ evolutions.push(parsed);
409
+ if (parsed.invented) {
410
+ inventedNote('evolve', parsed.name, 'the line names no maturity to evolve to, so the default was used. The file did not say where it is going.');
411
+ }
412
+ continue;
413
+ }
414
+ /* A link, or something this reader has no sentence for. */
415
+ const opener = firstWord(line);
416
+ // A statement is never a link, whatever arrows it holds. `evolution
417
+ // Genesis->Custom->Product->Commodity` renames the evolution axis and would
418
+ // otherwise arrive as two dependencies between components nobody declared —
419
+ // which is why OWM's own `LinksExtractionStrategy` carries the same refusal
420
+ // list. The `{` / `}` of a pipeline body get the same treatment.
421
+ const reserved = OWM_KEYWORDS.has(opener) || line.startsWith('{') || line.startsWith('}');
422
+ const flow = ['+>', "+'", '+<'].find(marker => line.includes(marker));
423
+ if (!reserved && flow !== undefined) {
424
+ carry(raw, 'flow link');
425
+ continue;
426
+ }
427
+ const arrow = reserved ? -1 : line.indexOf('->');
428
+ if (arrow >= 0) {
429
+ if (line.includes(';')) {
430
+ // A link with a context. The context is a sentence on the edge and this
431
+ // pack draws none, so the whole statement is kept rather than half of it.
432
+ carry(raw, 'link with a context');
433
+ continue;
434
+ }
435
+ links.push({
436
+ from: readLinkEnd(line.slice(0, arrow)),
437
+ to: readLinkEnd(line.slice(arrow + 2)),
438
+ });
439
+ continue;
440
+ }
441
+ carry(raw, opener);
442
+ }
443
+ // The caller's name for what it handed over, used ONLY when the file names
444
+ // nothing itself — a file that carries its own title always wins, because an
445
+ // import states what the file says and never what the caller wished it said.
446
+ // The extension goes: `tea-shop.owm` is what the file is CALLED, and the map
447
+ // it holds is called `tea-shop`.
448
+ if (title === undefined && context.name !== undefined) {
449
+ const named = context.name.replace(/\.(owm|wm)$/i, '').trim();
450
+ if (named.length > 0)
451
+ title = named;
452
+ }
453
+ /* ── One note per carried CONSTRUCT, never one per line ───────────── */
454
+ for (const [kind, count] of carriedKinds) {
455
+ notes.push({
456
+ kind: 'carried',
457
+ element: kind,
458
+ message: `${count} \`${kind}\` line${count === 1 ? '' : 's'} kept verbatim on the map and written back on the next export. Nothing on this canvas draws ${count === 1 ? 'it' : 'them'}.`,
459
+ });
460
+ }
461
+ /* ── A name declared twice ────────────────────────────────────────── */
462
+ const declared = new Set();
463
+ for (const statement of nodeStatements) {
464
+ if (declared.has(statement.name)) {
465
+ notes.push({
466
+ kind: 'warning',
467
+ sourceId: statement.name,
468
+ element: statement.keyword,
469
+ message: 'this name is declared more than once. Both artefacts are on the map, and every link naming it means the first — OWM identifies a component by its name.',
470
+ });
471
+ }
472
+ declared.add(statement.name);
473
+ }
474
+ /* ── A link naming something nobody declared ──────────────────────── */
475
+ /**
476
+ * D1's third state, applied to the one construct that can dangle.
477
+ *
478
+ * A link is the only statement in this DSL whose meaning depends on OTHER
479
+ * statements: it names two components by name, and a name nothing declares
480
+ * resolves to nothing. `materializeInterchangeImport` leaves such an endpoint
481
+ * exactly as the file wrote it, which keeps the DOCUMENT honest — but the
482
+ * connector then routes to an empty path and is INVISIBLE on the canvas. So a
483
+ * file with a typo in it imported as `mapped: 2, notes: []`: two artefacts
484
+ * drawn, one arrow silently missing, and a report claiming nothing was lost.
485
+ *
486
+ * One note per dangling END rather than per link, because a link with two of
487
+ * them has two problems and an architect fixing the file needs both names.
488
+ */
489
+ for (const link of links) {
490
+ for (const end of [link.from, link.to]) {
491
+ if (end.length === 0) {
492
+ notes.push({
493
+ kind: 'warning',
494
+ element: 'link',
495
+ message: 'a link names nothing on one of its ends, so the arrow it asks for runs to no artefact and is invisible on the canvas.',
496
+ });
497
+ continue;
498
+ }
499
+ if (declared.has(end))
500
+ continue;
501
+ notes.push({
502
+ kind: 'warning',
503
+ sourceId: end,
504
+ element: 'link',
505
+ message: 'a link names this, and no statement in the file declares it. The arrow is in the document and runs to no artefact, so it is invisible on the canvas.',
506
+ });
507
+ }
508
+ }
509
+ /* ── Laying it out ────────────────────────────────────────────────── */
510
+ const plot = owmDefaultPlot();
511
+ const elements = [];
512
+ /** The place a name was declared at, for the statements that reference one. */
513
+ const declaredAt = new Map();
514
+ for (const statement of nodeStatements) {
515
+ // FIRST wins, matching the materializer's own rule for a duplicated name
516
+ // and OWM's: a pipeline or an `evolve` naming it means the first one.
517
+ if (!declaredAt.has(statement.name)) {
518
+ declaredAt.set(statement.name, {
519
+ visibility: statement.visibility,
520
+ evolution: statement.evolution,
521
+ });
522
+ }
523
+ }
524
+ /** Handles the reader mints so a composite's own wiring resolves (D3). */
525
+ const minted = new Set();
526
+ const mintHandle = (stem) => {
527
+ let candidate = stem;
528
+ let suffix = 1;
529
+ while (declared.has(candidate) || minted.has(candidate)) {
530
+ suffix += 1;
531
+ candidate = `${stem} #${suffix}`;
532
+ }
533
+ minted.add(candidate);
534
+ return candidate;
535
+ };
536
+ /* The map itself, always first, and where the document's residue rides. */
537
+ const mapPayload = {};
538
+ if (title !== undefined) {
539
+ mapPayload.attrs = { [OWM_SCOPE.document]: { [OWM_TITLE_ATTR]: title } };
540
+ }
541
+ if (carriedLines.length > 0) {
542
+ mapPayload.children = { [OWM_SCOPE.document]: carriedLines };
543
+ }
544
+ elements.push({
545
+ type: WARDLEY_BACKGROUND.type,
546
+ role: WARDLEY_BACKGROUND.role,
547
+ resizeEnabled: WARDLEY_BACKGROUND.geometry.resizable,
548
+ variant: 'classic',
549
+ xywh: `[0,0,${OWM_DEFAULT_MAP_WIDTH},${OWM_DEFAULT_MAP_HEIGHT}]`,
550
+ ...(Object.keys(mapPayload).length > 0
551
+ ? { interchange: { [WARDLEY_OWM_FORMAT_ID]: mapPayload } }
552
+ : {}),
553
+ });
554
+ for (const statement of nodeStatements) {
555
+ const [cx, cy] = owmPointOf(plot, statement.visibility, statement.evolution);
556
+ elements.push(...artefact(statement.keyword, statement.name, cx, cy, statement.tail, mintHandle));
557
+ }
558
+ for (const pipeline of pipelines) {
559
+ const at = declaredAt.get(pipeline.name);
560
+ if (at === undefined) {
561
+ inventedNote('pipeline', pipeline.name, 'no component of this name is declared, so the pipeline was drawn halfway up the value chain. OWM takes a pipeline’s height from the component it belongs to.');
562
+ }
563
+ const visibility = at?.visibility ?? 0.5;
564
+ const [left] = owmPointOf(plot, visibility, pipeline.from);
565
+ const [right, y] = owmPointOf(plot, visibility, pipeline.to);
566
+ // Under the component it belongs to, the way OWM draws it.
567
+ const top = y + NODE_SIZE;
568
+ const width = Math.max(right - left, 1);
569
+ const centre = left + width / 2;
570
+ elements.push({
571
+ type: 'wardleyNode',
572
+ kind: 'pipeline',
573
+ role: WARDLEY_ROLE.pipeline,
574
+ shapeType: 'rect',
575
+ filled: true,
576
+ fillColor: PIPELINE_FILL,
577
+ strokeColor: NODE_STROKE,
578
+ strokeWidth: NODE_STROKE_WIDTH,
579
+ shapeStyle: ShapeStyle.General,
580
+ roughness: 0,
581
+ radius: 0,
582
+ xywh: `[${left},${top},${width},${PIPELINE_HEIGHT}]`,
583
+ interchange: payload({ id: pipeline.name, tail: pipeline.tail }),
584
+ });
585
+ elements.push({
586
+ type: 'wardleyNode',
587
+ kind: 'handle',
588
+ role: WARDLEY_ROLE.handle,
589
+ shapeType: 'rect',
590
+ filled: true,
591
+ fillColor: NODE_FILL,
592
+ strokeColor: NODE_STROKE,
593
+ strokeWidth: NODE_STROKE_WIDTH,
594
+ shapeStyle: ShapeStyle.General,
595
+ roughness: 0,
596
+ radius: 0,
597
+ xywh: `[${centre - HANDLE_SIZE / 2},${top - HANDLE_SIZE / 2},${HANDLE_SIZE},${HANDLE_SIZE}]`,
598
+ interchange: payload({
599
+ id: mintHandle(`${pipeline.name} handle`),
600
+ element: 'pipeline',
601
+ }),
602
+ });
603
+ elements.push(label(pipeline.name, centre - OWM_LABEL_WIDTH / 2, top - LABEL_GAP - OWM_LABEL_HEIGHT, TextAlign.Center));
604
+ }
605
+ for (const note of noteStatements) {
606
+ const [cx, cy] = owmPointOf(plot, note.visibility, note.evolution);
607
+ elements.push({
608
+ type: 'text',
609
+ text: note.text,
610
+ fontFamily: FontFamily.Inter,
611
+ fontSize: LABEL_FONT_SIZE,
612
+ color: NODE_STROKE,
613
+ textAlign: TextAlign.Left,
614
+ xywh: `[${cx - OWM_LABEL_WIDTH / 2},${cy - OWM_LABEL_HEIGHT / 2},${OWM_LABEL_WIDTH},${OWM_LABEL_HEIGHT}]`,
615
+ ...(note.tail.trim().length > 0
616
+ ? { interchange: payload({ tail: note.tail }) }
617
+ : {}),
618
+ });
619
+ }
620
+ /** The twin an `evolve` line draws, and the handle its arrow points at. */
621
+ const twinHandles = [];
622
+ for (const evolution of evolutions) {
623
+ const at = declaredAt.get(evolution.name);
624
+ if (at === undefined) {
625
+ inventedNote('evolve', evolution.name, 'no component of this name is declared, so its evolved twin was laid out halfway up the value chain. The file did not say where it sits.');
626
+ }
627
+ const visibility = at?.visibility ?? 0.5;
628
+ const [cx, cy] = owmPointOf(plot, visibility, evolution.evolution);
629
+ const handle = mintHandle(`evolve ${evolution.name}`);
630
+ twinHandles.push(handle);
631
+ elements.push({
632
+ type: 'wardleyNode',
633
+ kind: 'component',
634
+ role: WARDLEY_ROLE.component,
635
+ shapeType: 'ellipse',
636
+ filled: true,
637
+ fillColor: NODE_FILL,
638
+ strokeColor: WARDLEY_RED,
639
+ strokeWidth: 2,
640
+ shapeStyle: ShapeStyle.General,
641
+ roughness: 0,
642
+ xywh: `[${cx - NODE_SIZE / 2},${cy - NODE_SIZE / 2},${NODE_SIZE},${NODE_SIZE}]`,
643
+ // `element: 'evolve'` is what says this id is a HANDLE the reader minted
644
+ // so the arrow below can find its end, and not something the file said —
645
+ // OWM has no id for a twin, and D3 forbids inventing one that pretends
646
+ // otherwise. The writer never reads it: an `evolve` line is written from
647
+ // the arrow and the twin's own name.
648
+ interchange: payload({
649
+ id: handle,
650
+ element: 'evolve',
651
+ tail: evolution.tail,
652
+ }),
653
+ });
654
+ elements.push(label(evolution.becomes, cx + NODE_SIZE / 2 + LABEL_GAP, cy - OWM_LABEL_HEIGHT / 2, TextAlign.Left, WARDLEY_RED));
655
+ }
656
+ evolutions.forEach((evolution, index) => {
657
+ elements.push({
658
+ type: 'connector',
659
+ mode: ConnectorMode.Straight,
660
+ role: WARDLEY_ROLE.changeArrow,
661
+ stroke: WARDLEY_RED,
662
+ strokeStyle: StrokeStyle.Dash,
663
+ strokeWidth: LINK_STROKE_WIDTH,
664
+ frontEndpointStyle: PointStyle.None,
665
+ rearEndpointStyle: PointStyle.Triangle,
666
+ source: { id: evolution.name },
667
+ target: { id: twinHandles[index] },
668
+ });
669
+ });
670
+ for (const link of links) {
671
+ elements.push({
672
+ type: 'connector',
673
+ mode: ConnectorMode.Straight,
674
+ // The value-chain link. `source` is the CONSUMER and `target` is what it
675
+ // needs — the verb of `wardley:dependency` (ADR 0010), which is exactly
676
+ // what `A->B` says in this DSL. Never inverted.
677
+ role: WARDLEY_ROLE.dependency,
678
+ stroke: LINK_GREY,
679
+ strokeStyle: StrokeStyle.Solid,
680
+ strokeWidth: LINK_STROKE_WIDTH,
681
+ frontEndpointStyle: PointStyle.None,
682
+ rearEndpointStyle: PointStyle.None,
683
+ source: { id: link.from },
684
+ target: { id: link.to },
685
+ });
686
+ }
687
+ /* ── The report ───────────────────────────────────────────────────── */
688
+ const mapped = (titledByFile ? 1 : 0) +
689
+ nodeStatements.length +
690
+ pipelines.length +
691
+ noteStatements.length +
692
+ evolutions.length +
693
+ links.length;
694
+ return {
695
+ elements,
696
+ report: {
697
+ mapped,
698
+ carried: carriedLines.length,
699
+ // Nothing. Every statement this format writes is a standalone sentence,
700
+ // so a carried one cannot contradict the drawing — which is what D5's
701
+ // quarantine is FOR. Stated here rather than left to be inferred from a
702
+ // zero: a format with no quarantine case is a finding about the format.
703
+ quarantined: 0,
704
+ notes,
705
+ sourceVersion: carriedLines.length === 0 && notes.length === 0
706
+ ? OWM_DIALECT_LABRE
707
+ : OWM_DIALECT,
708
+ },
709
+ };
710
+ }
711
+ /* ── The pieces ───────────────────────────────────────────────────────── */
712
+ /** The `interchange` value for an element, or `undefined` when it carries none. */
713
+ function payload(parts) {
714
+ const carried = {};
715
+ if (parts.id !== undefined)
716
+ carried.id = parts.id;
717
+ if (parts.element !== undefined)
718
+ carried.element = parts.element;
719
+ if (parts.tail !== undefined && parts.tail.trim().length > 0) {
720
+ carried.attrs = { [OWM_SCOPE.self]: { [OWM_TAIL_ATTR]: parts.tail } };
721
+ }
722
+ if (Object.keys(carried).length === 0)
723
+ return undefined;
724
+ return { [WARDLEY_OWM_FORMAT_ID]: carried };
725
+ }
726
+ /** A name, as the free text element this canvas writes one as. */
727
+ function label(text, x, y, textAlign, color = NODE_STROKE) {
728
+ return {
729
+ type: 'text',
730
+ text,
731
+ // The role is the whole of what tells W3 this text is a NAME and not a
732
+ // remark somebody wrote on the map — and it is what the writer matches on.
733
+ role: WARDLEY_ROLE.label,
734
+ color,
735
+ fontFamily: FontFamily.Inter,
736
+ fontSize: LABEL_FONT_SIZE,
737
+ textAlign,
738
+ xywh: `[${x},${y},${OWM_LABEL_WIDTH},${OWM_LABEL_HEIGHT}]`,
739
+ };
740
+ }
741
+ /** The circle (or composite) one positioned statement draws, plus its name. */
742
+ function artefact(keyword, name, cx, cy, tail, mintHandle) {
743
+ const diameter = keyword === 'anchor'
744
+ ? OWM_ANCHOR_SIZE
745
+ : keyword === 'market'
746
+ ? MARKET_SIZE
747
+ : keyword === 'ecosystem'
748
+ ? ECOSYSTEM_SIZE
749
+ : NODE_SIZE;
750
+ const circle = {
751
+ type: 'wardleyNode',
752
+ kind: NODE_KEYWORDS[keyword],
753
+ role: WARDLEY_ROLE[NODE_KEYWORDS[keyword]],
754
+ shapeType: 'ellipse',
755
+ filled: true,
756
+ fillColor: NODE_FILL,
757
+ strokeColor: NODE_STROKE,
758
+ strokeWidth: NODE_STROKE_WIDTH,
759
+ shapeStyle: ShapeStyle.General,
760
+ roughness: 0,
761
+ xywh: `[${cx - diameter / 2},${cy - diameter / 2},${diameter},${diameter}]`,
762
+ interchange: payload({ id: name, tail }),
763
+ };
764
+ const named = label(name, cx + diameter / 2 + LABEL_GAP, cy - OWM_LABEL_HEIGHT / 2, TextAlign.Left);
765
+ if (keyword !== 'market')
766
+ return [circle, named];
767
+ // A market is a COMPOSITE on this canvas: the outer circle plus three inner
768
+ // dots wired into a triangle (`createWardleyMarket`). The dots and their
769
+ // connectors are the glyph's own wiring — they carry no role, so no rule
770
+ // measures them and the writer ignores them — but the connectors still have
771
+ // to find their ends, so each dot gets a minted handle (see `mintHandle`).
772
+ const ring = MARKET_DOT_RING;
773
+ const sin60 = Math.sqrt(3) / 2;
774
+ const vertices = [
775
+ [0, -ring],
776
+ [ring * sin60, ring / 2],
777
+ [-ring * sin60, ring / 2],
778
+ ];
779
+ const handles = vertices.map((_, index) => mintHandle(`${name} market ${index + 1}`));
780
+ const dots = vertices.map(([dx, dy], index) => ({
781
+ type: 'wardleyNode',
782
+ kind: 'component',
783
+ shapeType: 'ellipse',
784
+ filled: true,
785
+ fillColor: NODE_FILL,
786
+ strokeColor: NODE_STROKE,
787
+ strokeWidth: MARKET_DOT_STROKE_WIDTH,
788
+ shapeStyle: ShapeStyle.General,
789
+ roughness: 0,
790
+ xywh: `[${cx + dx - MARKET_DOT_SIZE / 2},${cy + dy - MARKET_DOT_SIZE / 2},${MARKET_DOT_SIZE},${MARKET_DOT_SIZE}]`,
791
+ interchange: payload({ id: handles[index], element: 'market' }),
792
+ }));
793
+ const triangle = [
794
+ [0, 1],
795
+ [1, 2],
796
+ [2, 0],
797
+ ].map(([from, to]) => ({
798
+ type: 'connector',
799
+ mode: ConnectorMode.Straight,
800
+ source: { id: handles[from] },
801
+ target: { id: handles[to] },
802
+ stroke: MARKET_LINK_COLOR,
803
+ strokeStyle: StrokeStyle.Solid,
804
+ strokeWidth: MARKET_LINK_WIDTH,
805
+ frontEndpointStyle: PointStyle.None,
806
+ rearEndpointStyle: PointStyle.None,
807
+ }));
808
+ return [circle, ...dots, ...triangle, named];
809
+ }
810
+ /** The anchor circle `templates/maps.ts` draws — a person, and larger for it. */
811
+ const OWM_ANCHOR_SIZE = 24;
812
+ /** One end of a link, unquoted the way OWM unquotes one. */
813
+ function readLinkEnd(raw) {
814
+ const trimmed = raw.trim();
815
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length > 1) {
816
+ return unescapeName(trimmed.slice(1, -1));
817
+ }
818
+ return trimmed;
819
+ }
820
+ /** The first word of a line, which is what names the construct it is. */
821
+ function firstWord(line) {
822
+ const match = /^[A-Za-z_][\w-]*/.exec(line);
823
+ return match ? match[0] : 'unknown';
824
+ }
825
+ /**
826
+ * The index of the `}` closing a `{ … }` block that opens after `from`, or
827
+ * `undefined` when the statement has no block.
828
+ *
829
+ * OWM's own scan is this one: from the statement, forward, until either a `{`
830
+ * (there is a block) or the next statement of the same keyword (there is not).
831
+ */
832
+ function blockAfter(lines, from) {
833
+ for (let index = from + 1; index < lines.length; index += 1) {
834
+ const line = lines[index].trim();
835
+ if (line.length === 0)
836
+ continue;
837
+ if (!line.startsWith('{'))
838
+ return undefined;
839
+ for (let close = index; close < lines.length; close += 1) {
840
+ if (lines[close].trim().includes('}'))
841
+ return close;
842
+ }
843
+ // An unterminated block: everything after it belongs to it.
844
+ return lines.length - 1;
845
+ }
846
+ return undefined;
847
+ }
848
+ /** `evolve X [-> Y] 0.85 [tail]`, as OWM's `setNameWithMaturity` reads one. */
849
+ function readEvolve(raw) {
850
+ let rest = raw.trimStart();
851
+ let name;
852
+ if (rest.startsWith('"')) {
853
+ const quoted = readQuoted(rest);
854
+ name = quoted.value;
855
+ rest = quoted.rest;
856
+ }
857
+ else {
858
+ const arrow = rest.indexOf('->');
859
+ if (arrow >= 0) {
860
+ name = rest.slice(0, arrow).trim();
861
+ rest = rest.slice(arrow);
862
+ }
863
+ else {
864
+ const match = BEFORE_MATURITY.exec(rest);
865
+ if (match) {
866
+ name = match[1].trim();
867
+ rest = (match[2] ?? '') + (match[3] ?? '');
868
+ }
869
+ else {
870
+ name = rest.trim();
871
+ rest = '';
872
+ }
873
+ }
874
+ }
875
+ let becomes = name;
876
+ rest = rest.trimStart();
877
+ if (rest.startsWith('->')) {
878
+ rest = rest.slice(2).trimStart();
879
+ if (rest.startsWith('"')) {
880
+ const quoted = readQuoted(rest);
881
+ becomes = quoted.value;
882
+ rest = quoted.rest;
883
+ }
884
+ else {
885
+ const match = BEFORE_MATURITY.exec(rest);
886
+ if (match) {
887
+ becomes = match[1].trim();
888
+ rest = (match[2] ?? '') + (match[3] ?? '');
889
+ }
890
+ else {
891
+ becomes = rest.trim();
892
+ rest = '';
893
+ }
894
+ }
895
+ }
896
+ const match = LEADING_MATURITY.exec(rest);
897
+ return {
898
+ name,
899
+ becomes: becomes || name,
900
+ // OWM's own default for an `evolve` that names no maturity.
901
+ evolution: match ? Number.parseFloat(match[1]) : 0.85,
902
+ tail: match ? rest.slice(match[0].length) : rest,
903
+ invented: !match,
904
+ };
905
+ }