mellos-mapping 0.19.0 → 0.20.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,120 @@
1
+ /**
2
+ * Layer 1c — medium-neutral VIEW SEMANTICS of a MellosMap.
3
+ *
4
+ * Every renderer (the terminal pane, a web panel, a future editor view) must
5
+ * agree on what a zoom step MEANS, when a map aggregates into its groups,
6
+ * how sequence time is oriented, and which map kinds render neutrally.
7
+ * Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
8
+ * no I/O. Geometry — how a mode maps onto character cells or pixels — stays
9
+ * private to each renderer.
10
+ */
11
+ import type { MapGroup, MapNode, MellosMap, NodeStatus } from '../domain/types.js';
12
+ /** One wheel tick on the zoom ladder. */
13
+ export type ZoomStep = -4 | -3 | -2 | -1 | 0 | 1 | 2;
14
+ export declare const ZOOM_MIN: ZoomStep;
15
+ export declare const ZOOM_MAX: ZoomStep;
16
+ export declare const ZOOM_DEFAULT: ZoomStep;
17
+ export declare function clampZoom(n: number): ZoomStep;
18
+ /**
19
+ * What a zoom step MEANS, before any renderer decides what it looks like:
20
+ * scaling only compresses, the ends of the ladder switch mode. 'detail'
21
+ * unfolds evidence and design notes; 'overview' switches to the aggregated
22
+ * far view (groups become the nodes — see aggregateMap); 'boxes' is every
23
+ * step in between, where boxes stay boxes and only whitespace and label
24
+ * budgets change.
25
+ */
26
+ export declare function zoomMode(zoom: ZoomStep): 'detail' | 'boxes' | 'overview';
27
+ /** What a footer shows: a percentage while scaling, a mode name at the ends. */
28
+ export declare function zoomLabel(zoom: ZoomStep): string;
29
+ /** Documentation kinds render neutrally: no status skins, no progress counts. */
30
+ export declare function isNeutralKind(map: MellosMap): boolean;
31
+ /**
32
+ * The derived coarse picture the far zoom renders when the map declares
33
+ * groups: each group becomes ONE labeled node (status derived from members,
34
+ * label carrying done/total), ungrouped nodes stay themselves, and edges
35
+ * collapse onto representatives (intra-group wiring disappears into the
36
+ * box). Derived for rendering only — never persisted. A map without groups
37
+ * returns undefined and falls back to whatever anonymous overview the
38
+ * renderer draws.
39
+ */
40
+ export declare function aggregateMap(map: MellosMap): MellosMap | undefined;
41
+ /** One neighbour across a dependency edge, with what flows along it. */
42
+ export interface NeighborRef {
43
+ readonly id: string;
44
+ readonly label: string;
45
+ readonly status: NodeStatus;
46
+ readonly edgeLabel?: string;
47
+ }
48
+ /** Everything a detail panel derives for a focused NODE. */
49
+ export interface NodeFocus {
50
+ readonly kind: 'node';
51
+ readonly node: MapNode;
52
+ readonly layerName: string;
53
+ readonly laneLabel?: string;
54
+ /** Lower neighbours this node stands on (on sequence pages: the earlier events). */
55
+ readonly uses: readonly NeighborRef[];
56
+ /** Upper neighbours standing on this node (on sequence pages: the later events). */
57
+ readonly usedBy: readonly NeighborRef[];
58
+ }
59
+ /** Everything a detail panel derives for a focused GROUP (the far zoom's boxes). */
60
+ export interface GroupFocus {
61
+ readonly kind: 'group';
62
+ readonly group: MapGroup;
63
+ readonly status: NodeStatus;
64
+ readonly layerName: string;
65
+ readonly members: readonly MapNode[];
66
+ /** Outside-the-group lower neighbours, each shown as its own group when it has one. */
67
+ readonly uses: readonly NeighborRef[];
68
+ /** Outside-the-group upper neighbours, each shown as its own group when it has one. */
69
+ readonly usedBy: readonly NeighborRef[];
70
+ }
71
+ /**
72
+ * Derive what a detail panel says about one focused id — a node, or a group
73
+ * when the far zoom's aggregated boxes are what the pointer is over. Pure
74
+ * data: every renderer picks its own glyphs, colors, and words (a sequence
75
+ * page reads uses/usedBy as after/before; that is the caller's vocabulary).
76
+ * @param map - the map the focus lives in.
77
+ * @param focusId - node or group id.
78
+ * @returns the focus view, or undefined when the id names neither.
79
+ */
80
+ export declare function focusInfo(map: MellosMap, focusId: string): NodeFocus | GroupFocus | undefined;
81
+ /**
82
+ * Page slugs some node of any map dives into. A page referenced as a submap
83
+ * is interior detail — it is reached by diving through its linking node,
84
+ * never by sitting beside its parent as a sibling tab.
85
+ * @param maps - every known page's map (undefined entries are skipped).
86
+ * @returns the referenced submap slugs.
87
+ */
88
+ export declare function submapRefs(maps: Iterable<MellosMap | undefined>): Set<string>;
89
+ /**
90
+ * Where a sub-map page was dived into from: the entry whose map links the
91
+ * page, plus the linking node's label. Derived by scan, so a breadcrumb
92
+ * survives any client restart with an empty dive stack.
93
+ * @param entries - known pages as (key, map) pairs; keys are caller-owned.
94
+ * @param pageId - the sub-map page's slug.
95
+ * @returns the linking entry's key and node label, or undefined for a top-level page.
96
+ */
97
+ export declare function diveParent<K>(entries: Iterable<readonly [K, MellosMap | undefined]>, pageId: string): {
98
+ parent: K;
99
+ label: string;
100
+ } | undefined;
101
+ /**
102
+ * The page a client shows when nobody asked for one: the most recently
103
+ * WRITTEN page — the ledger last touched is almost always the effort under
104
+ * way. Keys without a readable timestamp lose; an empty set answers the
105
+ * first key (caller-ordered: default page first, then slug order).
106
+ * @param keys - candidate page keys in the caller's fallback order.
107
+ * @param mtimeOf - last-written timestamp of a key, undefined when unknown.
108
+ * @returns the winning key, or undefined for an empty candidate set.
109
+ */
110
+ export declare function mostRecentKey<K>(keys: readonly K[], mtimeOf: (key: K) => number | undefined): K | undefined;
111
+ /**
112
+ * Sequence pages read like the classic diagram: time flows DOWNWARD, the
113
+ * earliest step right under the participant headers. The stored map keeps
114
+ * rank 0 = earliest with edges pointing later -> earlier ("later stands on
115
+ * earlier"); this derived value inverts the ranks and reverses the edges so
116
+ * unchanged top-down machinery draws top-down time — each wire now runs
117
+ * from the sender's moment down into the receiver's. Derived for rendering
118
+ * only, never persisted (same contract as aggregateMap).
119
+ */
120
+ export declare function flipForSequence(map: MellosMap): MellosMap;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Layer 1c — medium-neutral VIEW SEMANTICS of a MellosMap.
3
+ *
4
+ * Every renderer (the terminal pane, a web panel, a future editor view) must
5
+ * agree on what a zoom step MEANS, when a map aggregates into its groups,
6
+ * how sequence time is oriented, and which map kinds render neutrally.
7
+ * Those rules live here, pure of any medium: no cells, no glyphs, no DOM,
8
+ * no I/O. Geometry — how a mode maps onto character cells or pixels — stays
9
+ * private to each renderer.
10
+ */
11
+ import { groupStatus } from '../domain/ops.js';
12
+ export const ZOOM_MIN = -4;
13
+ export const ZOOM_MAX = 2;
14
+ export const ZOOM_DEFAULT = 0;
15
+ export function clampZoom(n) {
16
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(n)));
17
+ }
18
+ /**
19
+ * What a zoom step MEANS, before any renderer decides what it looks like:
20
+ * scaling only compresses, the ends of the ladder switch mode. 'detail'
21
+ * unfolds evidence and design notes; 'overview' switches to the aggregated
22
+ * far view (groups become the nodes — see aggregateMap); 'boxes' is every
23
+ * step in between, where boxes stay boxes and only whitespace and label
24
+ * budgets change.
25
+ */
26
+ export function zoomMode(zoom) {
27
+ if (zoom >= 1)
28
+ return 'detail';
29
+ if (zoom <= -4)
30
+ return 'overview';
31
+ return 'boxes';
32
+ }
33
+ /** What a footer shows: a percentage while scaling, a mode name at the ends. */
34
+ export function zoomLabel(zoom) {
35
+ switch (zoom) {
36
+ case 2:
37
+ return 'detail+';
38
+ case 1:
39
+ return 'detail';
40
+ case 0:
41
+ return '100%';
42
+ case -1:
43
+ return '85%';
44
+ case -2:
45
+ return '70%';
46
+ case -3:
47
+ return '55%';
48
+ case -4:
49
+ return 'overview';
50
+ }
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // kind semantics
54
+ // ---------------------------------------------------------------------------
55
+ /** Documentation kinds render neutrally: no status skins, no progress counts. */
56
+ export function isNeutralKind(map) {
57
+ return map.kind !== undefined && map.kind !== 'dev';
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // derived views (never persisted)
61
+ // ---------------------------------------------------------------------------
62
+ /**
63
+ * The derived coarse picture the far zoom renders when the map declares
64
+ * groups: each group becomes ONE labeled node (status derived from members,
65
+ * label carrying done/total), ungrouped nodes stay themselves, and edges
66
+ * collapse onto representatives (intra-group wiring disappears into the
67
+ * box). Derived for rendering only — never persisted. A map without groups
68
+ * returns undefined and falls back to whatever anonymous overview the
69
+ * renderer draws.
70
+ */
71
+ export function aggregateMap(map) {
72
+ if (map.groups.length === 0)
73
+ return undefined;
74
+ // Group ids join the node-id slug space inside this derived value; the
75
+ // brands only guard PERSISTED maps, and this one never leaves rendering.
76
+ const representative = new Map();
77
+ for (const n of map.nodes)
78
+ representative.set(n.id, (n.group ?? n.id));
79
+ const nodes = map.groups.map((g) => {
80
+ const members = map.nodes.filter((n) => n.group === g.id);
81
+ const done = members.filter((n) => n.status === 'done').length;
82
+ return {
83
+ id: g.id,
84
+ // neutral kinds document structure, not progress — no member counts
85
+ label: isNeutralKind(map) ? g.label : `${g.label} ${done}/${members.length}`,
86
+ layer: g.layer,
87
+ status: groupStatus(map, g.id),
88
+ };
89
+ });
90
+ for (const n of map.nodes)
91
+ if (n.group === undefined)
92
+ nodes.push(n);
93
+ const seen = new Set();
94
+ const edges = [];
95
+ for (const e of map.edges) {
96
+ const from = representative.get(e.from);
97
+ const to = representative.get(e.to);
98
+ if (from === to || seen.has(`${from}->${to}`))
99
+ continue;
100
+ seen.add(`${from}->${to}`);
101
+ edges.push({ from: from, to: to });
102
+ }
103
+ return {
104
+ ...(map.title !== undefined ? { title: map.title } : {}),
105
+ ...(map.kind !== undefined ? { kind: map.kind } : {}),
106
+ layers: map.layers,
107
+ groups: [],
108
+ lanes: map.lanes,
109
+ nodes,
110
+ edges,
111
+ };
112
+ }
113
+ /**
114
+ * Derive what a detail panel says about one focused id — a node, or a group
115
+ * when the far zoom's aggregated boxes are what the pointer is over. Pure
116
+ * data: every renderer picks its own glyphs, colors, and words (a sequence
117
+ * page reads uses/usedBy as after/before; that is the caller's vocabulary).
118
+ * @param map - the map the focus lives in.
119
+ * @param focusId - node or group id.
120
+ * @returns the focus view, or undefined when the id names neither.
121
+ */
122
+ export function focusInfo(map, focusId) {
123
+ const layerNameOf = (layerId) => map.layers.find((l) => l.id === layerId)?.name ?? layerId;
124
+ const group = map.groups.find((g) => g.id === focusId);
125
+ if (group) {
126
+ const members = map.nodes.filter((n) => n.group === group.id);
127
+ const memberIds = new Set(members.map((n) => n.id));
128
+ // A neighbour is shown as its own group when it has one, else as itself.
129
+ const rep = (id) => {
130
+ const n = map.nodes.find((x) => x.id === id);
131
+ const owner = n.group !== undefined ? map.groups.find((g) => g.id === n.group) : undefined;
132
+ return owner !== undefined
133
+ ? { id: owner.id, label: owner.label, status: groupStatus(map, owner.id) }
134
+ : { id: n.id, label: n.label, status: n.status };
135
+ };
136
+ const dedupe = (refs) => {
137
+ const seen = new Set();
138
+ const out = [];
139
+ for (const r of refs) {
140
+ if (seen.has(r.id))
141
+ continue;
142
+ seen.add(r.id);
143
+ out.push(r);
144
+ }
145
+ return out;
146
+ };
147
+ return {
148
+ kind: 'group',
149
+ group,
150
+ status: groupStatus(map, group.id),
151
+ layerName: layerNameOf(group.layer),
152
+ members,
153
+ uses: dedupe(map.edges
154
+ .filter((e) => memberIds.has(e.from) && !memberIds.has(e.to))
155
+ .map((e) => rep(e.to))),
156
+ usedBy: dedupe(map.edges
157
+ .filter((e) => memberIds.has(e.to) && !memberIds.has(e.from))
158
+ .map((e) => rep(e.from))),
159
+ };
160
+ }
161
+ const node = map.nodes.find((n) => n.id === focusId);
162
+ if (!node)
163
+ return undefined;
164
+ const ref = (id, edgeLabel) => {
165
+ const n = map.nodes.find((x) => x.id === id);
166
+ return {
167
+ id,
168
+ label: n?.label ?? id,
169
+ status: n?.status ?? 'planned',
170
+ ...(edgeLabel !== undefined ? { edgeLabel } : {}),
171
+ };
172
+ };
173
+ const laneLabel = node.lane !== undefined ? map.lanes.find((l) => l.id === node.lane)?.label : undefined;
174
+ return {
175
+ kind: 'node',
176
+ node,
177
+ layerName: layerNameOf(node.layer),
178
+ ...(laneLabel !== undefined ? { laneLabel } : {}),
179
+ uses: map.edges.filter((e) => e.from === node.id).map((e) => ref(e.to, e.label)),
180
+ usedBy: map.edges.filter((e) => e.to === node.id).map((e) => ref(e.from, e.label)),
181
+ };
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // page-set semantics — which pages are siblings, and where a dive came from
185
+ // ---------------------------------------------------------------------------
186
+ /**
187
+ * Page slugs some node of any map dives into. A page referenced as a submap
188
+ * is interior detail — it is reached by diving through its linking node,
189
+ * never by sitting beside its parent as a sibling tab.
190
+ * @param maps - every known page's map (undefined entries are skipped).
191
+ * @returns the referenced submap slugs.
192
+ */
193
+ export function submapRefs(maps) {
194
+ const refs = new Set();
195
+ for (const m of maps) {
196
+ for (const n of m?.nodes ?? [])
197
+ if (n.submap !== undefined)
198
+ refs.add(n.submap);
199
+ }
200
+ return refs;
201
+ }
202
+ /**
203
+ * Where a sub-map page was dived into from: the entry whose map links the
204
+ * page, plus the linking node's label. Derived by scan, so a breadcrumb
205
+ * survives any client restart with an empty dive stack.
206
+ * @param entries - known pages as (key, map) pairs; keys are caller-owned.
207
+ * @param pageId - the sub-map page's slug.
208
+ * @returns the linking entry's key and node label, or undefined for a top-level page.
209
+ */
210
+ export function diveParent(entries, pageId) {
211
+ for (const [key, m] of entries) {
212
+ const node = m?.nodes.find((n) => n.submap === pageId);
213
+ if (node !== undefined)
214
+ return { parent: key, label: node.label };
215
+ }
216
+ return undefined;
217
+ }
218
+ /**
219
+ * The page a client shows when nobody asked for one: the most recently
220
+ * WRITTEN page — the ledger last touched is almost always the effort under
221
+ * way. Keys without a readable timestamp lose; an empty set answers the
222
+ * first key (caller-ordered: default page first, then slug order).
223
+ * @param keys - candidate page keys in the caller's fallback order.
224
+ * @param mtimeOf - last-written timestamp of a key, undefined when unknown.
225
+ * @returns the winning key, or undefined for an empty candidate set.
226
+ */
227
+ export function mostRecentKey(keys, mtimeOf) {
228
+ let best;
229
+ let bestMtime = -Infinity;
230
+ for (const key of keys) {
231
+ const mtime = mtimeOf(key);
232
+ if (mtime !== undefined && mtime > bestMtime) {
233
+ best = key;
234
+ bestMtime = mtime;
235
+ }
236
+ }
237
+ return best ?? keys[0];
238
+ }
239
+ /**
240
+ * Sequence pages read like the classic diagram: time flows DOWNWARD, the
241
+ * earliest step right under the participant headers. The stored map keeps
242
+ * rank 0 = earliest with edges pointing later -> earlier ("later stands on
243
+ * earlier"); this derived value inverts the ranks and reverses the edges so
244
+ * unchanged top-down machinery draws top-down time — each wire now runs
245
+ * from the sender's moment down into the receiver's. Derived for rendering
246
+ * only, never persisted (same contract as aggregateMap).
247
+ */
248
+ export function flipForSequence(map) {
249
+ if (map.kind !== 'sequence')
250
+ return map;
251
+ return {
252
+ ...map,
253
+ layers: map.layers.map((l) => ({ ...l, rank: -l.rank })),
254
+ edges: map.edges.map((e) => ({ from: e.to, to: e.from, ...(e.label !== undefined ? { label: e.label } : {}) })),
255
+ };
256
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Layer 1a — the state-file FORMAT of a MellosMap, pure of any I/O.
3
+ *
4
+ * This module owns the on-disk vocabulary (version, page-id grammar) and the
5
+ * two format promises every consumer relies on:
6
+ *
7
+ * P1. Whatever parseMap accepts satisfies the structural invariants of
8
+ * Layer 0. Parsing is done by REPLAYING the raw data through the domain
9
+ * operations, so a hand-edited or corrupted file can never smuggle an
10
+ * invariant violation into the process (validate at the boundary,
11
+ * trust internal code afterwards).
12
+ * F1. serializeMap is the inverse of parseMap for valid maps.
13
+ *
14
+ * No node:* imports — this module must load in a browser as-is. Filesystem
15
+ * concerns (atomic writes, page file listing, focus requests) live in
16
+ * ./store.ts, the Node-side half.
17
+ */
18
+ import { type InvalidId, type MapError, type MellosMap, type Result } from '../domain/types.js';
19
+ /** On-disk format version. Bump only with a documented migration. */
20
+ export declare const STATE_FILE_VERSION = 1;
21
+ export type PageId = string & {
22
+ readonly __brand: 'PageId';
23
+ };
24
+ export declare function makePageId(raw: string): Result<PageId, InvalidId>;
25
+ export type StoreError = {
26
+ readonly kind: 'not-found';
27
+ readonly path: string;
28
+ } | {
29
+ readonly kind: 'malformed-json';
30
+ readonly path: string;
31
+ readonly detail: string;
32
+ } | {
33
+ readonly kind: 'bad-shape';
34
+ readonly path: string;
35
+ readonly detail: string;
36
+ } | {
37
+ readonly kind: 'invariant-violation';
38
+ readonly path: string;
39
+ readonly violation: MapError;
40
+ };
41
+ export declare function describeStoreError(e: StoreError): string;
42
+ /**
43
+ * Rebuild a MellosMap from untrusted raw data by replaying it through the
44
+ * Layer 0 operations (P1). Field order in the file does not matter; replay
45
+ * order (layers -> lanes -> groups -> nodes -> edges) supplies the required
46
+ * declaration order.
47
+ */
48
+ export declare function parseMap(raw: unknown, path: string): Result<MellosMap, StoreError>;
49
+ /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
50
+ export declare function serializeMap(map: MellosMap): string;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Layer 1a — the state-file FORMAT of a MellosMap, pure of any I/O.
3
+ *
4
+ * This module owns the on-disk vocabulary (version, page-id grammar) and the
5
+ * two format promises every consumer relies on:
6
+ *
7
+ * P1. Whatever parseMap accepts satisfies the structural invariants of
8
+ * Layer 0. Parsing is done by REPLAYING the raw data through the domain
9
+ * operations, so a hand-edited or corrupted file can never smuggle an
10
+ * invariant violation into the process (validate at the boundary,
11
+ * trust internal code afterwards).
12
+ * F1. serializeMap is the inverse of parseMap for valid maps.
13
+ *
14
+ * No node:* imports — this module must load in a browser as-is. Filesystem
15
+ * concerns (atomic writes, page file listing, focus requests) live in
16
+ * ./store.ts, the Node-side half.
17
+ */
18
+ import { declareGroup, declareLane, declareLayer, declareNode, linkNodes, setKind, setTitle, updateNode } from '../domain/ops.js';
19
+ import { EMPTY_MAP, ID_RULE, ID_RULE_TEXT, describeMapError, err, makeGroupId, makeLaneId, makeLayerId, makeMapKind, makeNodeId, makeNodeKind, makeNodeStatus, makeSubmapRef, ok, } from '../domain/types.js';
20
+ /** On-disk format version. Bump only with a documented migration. */
21
+ export const STATE_FILE_VERSION = 1;
22
+ export function makePageId(raw) {
23
+ return ID_RULE.test(raw) ? ok(raw) : err({ kind: 'invalid-id', raw, rule: ID_RULE_TEXT });
24
+ }
25
+ export function describeStoreError(e) {
26
+ switch (e.kind) {
27
+ case 'not-found':
28
+ return `no map file at ${e.path}`;
29
+ case 'malformed-json':
30
+ return `map file ${e.path} is not valid JSON: ${e.detail}`;
31
+ case 'bad-shape':
32
+ return `map file ${e.path} has an unexpected shape: ${e.detail}`;
33
+ case 'invariant-violation':
34
+ return `map file ${e.path} violates a structural invariant: ${describeMapError(e.violation)}`;
35
+ }
36
+ }
37
+ function isRecord(v) {
38
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
39
+ }
40
+ function asArray(v) {
41
+ return Array.isArray(v) ? v : [];
42
+ }
43
+ function optionalString(v) {
44
+ return typeof v === 'string' ? v : undefined;
45
+ }
46
+ /**
47
+ * Rebuild a MellosMap from untrusted raw data by replaying it through the
48
+ * Layer 0 operations (P1). Field order in the file does not matter; replay
49
+ * order (layers -> lanes -> groups -> nodes -> edges) supplies the required
50
+ * declaration order.
51
+ */
52
+ export function parseMap(raw, path) {
53
+ if (!isRecord(raw))
54
+ return err({ kind: 'bad-shape', path, detail: 'root is not an object' });
55
+ if (raw['version'] !== STATE_FILE_VERSION) {
56
+ return err({ kind: 'bad-shape', path, detail: `version is ${String(raw['version'])}, expected ${STATE_FILE_VERSION}` });
57
+ }
58
+ let map = EMPTY_MAP;
59
+ const title = optionalString(raw['title']);
60
+ if (title !== undefined)
61
+ map = setTitle(map, title);
62
+ const rawKind = optionalString(raw['kind']);
63
+ if (rawKind !== undefined) {
64
+ const kind = makeMapKind(rawKind);
65
+ if (!kind.ok)
66
+ return err({ kind: 'invariant-violation', path, violation: kind.error });
67
+ map = setKind(map, kind.value);
68
+ }
69
+ for (const [i, rawLayer] of asArray(raw['layers']).entries()) {
70
+ if (!isRecord(rawLayer))
71
+ return err({ kind: 'bad-shape', path, detail: `layers[${i}] is not an object` });
72
+ const id = makeLayerId(String(rawLayer['id'] ?? ''));
73
+ if (!id.ok)
74
+ return err({ kind: 'invariant-violation', path, violation: id.error });
75
+ const name = optionalString(rawLayer['name']);
76
+ const rank = rawLayer['rank'];
77
+ if (name === undefined || typeof rank !== 'number' || !Number.isInteger(rank)) {
78
+ return err({ kind: 'bad-shape', path, detail: `layers[${i}] needs a string name and an integer rank` });
79
+ }
80
+ const next = declareLayer(map, { id: id.value, name, rank });
81
+ if (!next.ok)
82
+ return err({ kind: 'invariant-violation', path, violation: next.error });
83
+ map = next.value;
84
+ }
85
+ for (const [i, rawLane] of asArray(raw['lanes']).entries()) {
86
+ if (!isRecord(rawLane))
87
+ return err({ kind: 'bad-shape', path, detail: `lanes[${i}] is not an object` });
88
+ const id = makeLaneId(String(rawLane['id'] ?? ''));
89
+ if (!id.ok)
90
+ return err({ kind: 'invariant-violation', path, violation: id.error });
91
+ const label = optionalString(rawLane['label']);
92
+ if (label === undefined)
93
+ return err({ kind: 'bad-shape', path, detail: `lanes[${i}] needs a string label` });
94
+ const declared = declareLane(map, { id: id.value, label });
95
+ if (!declared.ok)
96
+ return err({ kind: 'invariant-violation', path, violation: declared.error });
97
+ map = declared.value;
98
+ }
99
+ for (const [i, rawGroup] of asArray(raw['groups']).entries()) {
100
+ if (!isRecord(rawGroup))
101
+ return err({ kind: 'bad-shape', path, detail: `groups[${i}] is not an object` });
102
+ const id = makeGroupId(String(rawGroup['id'] ?? ''));
103
+ if (!id.ok)
104
+ return err({ kind: 'invariant-violation', path, violation: id.error });
105
+ const layer = makeLayerId(String(rawGroup['layer'] ?? ''));
106
+ if (!layer.ok)
107
+ return err({ kind: 'invariant-violation', path, violation: layer.error });
108
+ const label = optionalString(rawGroup['label']);
109
+ if (label === undefined)
110
+ return err({ kind: 'bad-shape', path, detail: `groups[${i}] needs a string label` });
111
+ const declared = declareGroup(map, { id: id.value, label, layer: layer.value });
112
+ if (!declared.ok)
113
+ return err({ kind: 'invariant-violation', path, violation: declared.error });
114
+ map = declared.value;
115
+ }
116
+ for (const [i, rawNode] of asArray(raw['nodes']).entries()) {
117
+ if (!isRecord(rawNode))
118
+ return err({ kind: 'bad-shape', path, detail: `nodes[${i}] is not an object` });
119
+ const id = makeNodeId(String(rawNode['id'] ?? ''));
120
+ if (!id.ok)
121
+ return err({ kind: 'invariant-violation', path, violation: id.error });
122
+ const layer = makeLayerId(String(rawNode['layer'] ?? ''));
123
+ if (!layer.ok)
124
+ return err({ kind: 'invariant-violation', path, violation: layer.error });
125
+ const status = makeNodeStatus(String(rawNode['status'] ?? ''));
126
+ if (!status.ok)
127
+ return err({ kind: 'invariant-violation', path, violation: status.error });
128
+ const label = optionalString(rawNode['label']);
129
+ if (label === undefined)
130
+ return err({ kind: 'bad-shape', path, detail: `nodes[${i}] needs a string label` });
131
+ const detail = optionalString(rawNode['detail']);
132
+ const rawGroup = optionalString(rawNode['group']);
133
+ let group;
134
+ if (rawGroup !== undefined) {
135
+ const made = makeGroupId(rawGroup);
136
+ if (!made.ok)
137
+ return err({ kind: 'invariant-violation', path, violation: made.error });
138
+ group = made.value;
139
+ }
140
+ const rawNodeKind = optionalString(rawNode['kind']);
141
+ let nodeKind;
142
+ if (rawNodeKind !== undefined) {
143
+ const made = makeNodeKind(rawNodeKind);
144
+ if (!made.ok)
145
+ return err({ kind: 'invariant-violation', path, violation: made.error });
146
+ nodeKind = made.value;
147
+ }
148
+ const rawLane = optionalString(rawNode['lane']);
149
+ let lane;
150
+ if (rawLane !== undefined) {
151
+ const made = makeLaneId(rawLane);
152
+ if (!made.ok)
153
+ return err({ kind: 'invariant-violation', path, violation: made.error });
154
+ lane = made.value;
155
+ }
156
+ const rawSubmap = optionalString(rawNode['submap']);
157
+ let submap;
158
+ if (rawSubmap !== undefined) {
159
+ const made = makeSubmapRef(rawSubmap);
160
+ if (!made.ok)
161
+ return err({ kind: 'invariant-violation', path, violation: made.error });
162
+ submap = made.value;
163
+ }
164
+ const declared = declareNode(map, {
165
+ id: id.value,
166
+ label,
167
+ layer: layer.value,
168
+ status: status.value,
169
+ ...(detail !== undefined ? { detail } : {}),
170
+ ...(group !== undefined ? { group } : {}),
171
+ ...(nodeKind !== undefined ? { kind: nodeKind } : {}),
172
+ ...(lane !== undefined ? { lane } : {}),
173
+ ...(submap !== undefined ? { submap } : {}),
174
+ });
175
+ if (!declared.ok)
176
+ return err({ kind: 'invariant-violation', path, violation: declared.error });
177
+ map = declared.value;
178
+ const evidence = optionalString(rawNode['evidence']);
179
+ if (evidence !== undefined) {
180
+ const updated = updateNode(map, { id: id.value, evidence });
181
+ if (!updated.ok)
182
+ return err({ kind: 'invariant-violation', path, violation: updated.error });
183
+ map = updated.value;
184
+ }
185
+ }
186
+ for (const [i, rawEdge] of asArray(raw['edges']).entries()) {
187
+ if (!isRecord(rawEdge))
188
+ return err({ kind: 'bad-shape', path, detail: `edges[${i}] is not an object` });
189
+ const from = makeNodeId(String(rawEdge['from'] ?? ''));
190
+ if (!from.ok)
191
+ return err({ kind: 'invariant-violation', path, violation: from.error });
192
+ const to = makeNodeId(String(rawEdge['to'] ?? ''));
193
+ if (!to.ok)
194
+ return err({ kind: 'invariant-violation', path, violation: to.error });
195
+ const linked = linkNodes(map, from.value, to.value, optionalString(rawEdge['label']));
196
+ if (!linked.ok)
197
+ return err({ kind: 'invariant-violation', path, violation: linked.error });
198
+ map = linked.value;
199
+ }
200
+ return ok(map);
201
+ }
202
+ /** Serialize a map into the on-disk shape. Inverse of parseMap for valid maps (F1). */
203
+ export function serializeMap(map) {
204
+ const body = {
205
+ version: STATE_FILE_VERSION,
206
+ ...(map.title !== undefined ? { title: map.title } : {}),
207
+ ...(map.kind !== undefined ? { kind: map.kind } : {}),
208
+ layers: map.layers,
209
+ ...(map.lanes.length > 0 ? { lanes: map.lanes } : {}),
210
+ ...(map.groups.length > 0 ? { groups: map.groups } : {}),
211
+ nodes: map.nodes,
212
+ edges: map.edges,
213
+ };
214
+ return JSON.stringify(body, null, 2) + '\n';
215
+ }