mellos-mapping 0.20.0 → 0.20.2

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 (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -5,14 +5,25 @@
5
5
  * before any new value is built, and the commit expression can no longer
6
6
  * fail. Inputs are never mutated; the result always carries a fresh map.
7
7
  *
8
- * These functions enforce the structural invariants I1-I9 documented in
8
+ * These functions enforce the structural invariants I1-I10 documented in
9
9
  * types.ts and nothing else. In particular there are no workflow rules here:
10
10
  * any status may be set at any time, in any order. Discipline lives with the
11
11
  * caller; this layer only keeps the map structurally true.
12
+ *
13
+ * Every declared thing can also be REVISED, because a ghost design is a
14
+ * hypothesis and revising it is honest work: a node moves band (moveNode), a
15
+ * band is renamed or re-ordered (updateLayer), a group or lane is relabeled
16
+ * (updateGroup, updateLane), and every optional field a node carries can be
17
+ * cleared as explicitly as it was set (null, never an empty string).
18
+ */
19
+ import { type GroupId, type LaneId, type LayerId, type MapError, type MapKind, type MellosMap, type NodeId, type NodeKind, type NodeStatus, type Rank, type Result, type SubmapRef } from './types.js';
20
+ /**
21
+ * Set, replace or clear the map title.
22
+ * @param title - the new title; null (or an explicit undefined) removes the
23
+ * field entirely, so a cleared title serializes as an absent key rather
24
+ * than as an empty string nobody can tell from a real one.
12
25
  */
13
- import { type GroupId, type LaneId, type LayerId, type MapError, type MapKind, type MellosMap, type NodeId, type NodeKind, type NodeStatus, type Result, type SubmapRef } from './types.js';
14
- /** Set or replace the map title. */
15
- export declare function setTitle(map: MellosMap, title: string): MellosMap;
26
+ export declare function setTitle(map: MellosMap, title: string | null | undefined): MellosMap;
16
27
  /** Set or replace the map kind (presentation intent — never structural). */
17
28
  export declare function setKind(map: MellosMap, kind: MapKind): MellosMap;
18
29
  export interface DeclareLaneInput {
@@ -21,6 +32,8 @@ export interface DeclareLaneInput {
21
32
  }
22
33
  /** Add a new lane (I8). Declaration order is left-to-right render order. */
23
34
  export declare function declareLane(map: MellosMap, input: DeclareLaneInput): Result<MellosMap, MapError>;
35
+ /** Relabel a lane. The column keeps its id, its order and its members. */
36
+ export declare function updateLane(map: MellosMap, id: LaneId, label: string): Result<MellosMap, MapError>;
24
37
  /**
25
38
  * Remove a lane.
26
39
  * Postcondition: former members stay on the map, merely off-lane — removing
@@ -30,16 +43,34 @@ export declare function removeLane(map: MellosMap, id: LaneId): Result<MellosMap
30
43
  export interface DeclareLayerInput {
31
44
  readonly id: LayerId;
32
45
  readonly name: string;
33
- readonly rank: number;
46
+ readonly rank: Rank;
34
47
  }
35
48
  /** Add a new band. Refuses duplicate ids and duplicate ranks (I1). */
36
49
  export declare function declareLayer(map: MellosMap, input: DeclareLayerInput): Result<MellosMap, MapError>;
50
+ export interface UpdateLayerInput {
51
+ readonly name?: string;
52
+ readonly rank?: Rank;
53
+ }
54
+ /**
55
+ * Rename a band and/or move it in the vertical order. Absent fields are left
56
+ * untouched; the band keeps its id and its occupants either way.
57
+ *
58
+ * A rank change is the one revision that can invalidate the whole graph, so
59
+ * it is validated against the map as a whole before anything changes:
60
+ * - the new rank is free (I1) — bands stay totally ordered;
61
+ * - EVERY edge, not only the ones touching this band, still points
62
+ * strictly downward under the new order (I4). Re-ranking a band moves
63
+ * all of its nodes at once, so an edge two bands away can be the one
64
+ * that breaks; the refusal names it.
65
+ * @returns the reordered map, or the first refusal.
66
+ */
67
+ export declare function updateLayer(map: MellosMap, id: LayerId, input: UpdateLayerInput): Result<MellosMap, MapError>;
37
68
  export interface DeclareGroupInput {
38
69
  readonly id: GroupId;
39
70
  readonly label: string;
40
71
  readonly layer: LayerId;
41
72
  }
42
- /** Add a new group to an existing band (I6). */
73
+ /** Add a new group to an existing band (I6), under an id no node holds (I10). */
43
74
  export declare function declareGroup(map: MellosMap, input: DeclareGroupInput): Result<MellosMap, MapError>;
44
75
  /** Rename a group. */
45
76
  export declare function updateGroup(map: MellosMap, id: GroupId, label: string): Result<MellosMap, MapError>;
@@ -62,6 +93,12 @@ export interface DeclareNodeInput {
62
93
  readonly label: string;
63
94
  readonly layer: LayerId;
64
95
  readonly status?: NodeStatus;
96
+ /**
97
+ * Verification evidence, for a node declared straight into `done` — work
98
+ * already finished when the map is drawn is as entitled to its proof as
99
+ * work finished under the map's eyes.
100
+ */
101
+ readonly evidence?: string;
65
102
  readonly detail?: string;
66
103
  readonly group?: GroupId;
67
104
  readonly kind?: NodeKind;
@@ -69,8 +106,9 @@ export interface DeclareNodeInput {
69
106
  readonly submap?: SubmapRef;
70
107
  }
71
108
  /**
72
- * Add a new node to an existing band (I2, I3), optionally joining a same-band
73
- * group (I7) and/or an existing lane (I9).
109
+ * Add a new node to an existing band (I2, I3) under an id no group holds
110
+ * (I10), optionally joining a same-band group (I7) and/or an existing lane
111
+ * (I9).
74
112
  */
75
113
  export declare function declareNode(map: MellosMap, input: DeclareNodeInput): Result<MellosMap, MapError>;
76
114
  /**
@@ -83,8 +121,10 @@ export interface UpdateNodeInput {
83
121
  readonly id: NodeId;
84
122
  readonly status?: NodeStatus;
85
123
  readonly label?: string;
86
- readonly evidence?: string;
87
- readonly detail?: string;
124
+ /** Text replaces the evidence; null clears it (a node demoted back to a plan). */
125
+ readonly evidence?: string | null;
126
+ /** Text replaces the design notes; null clears them. */
127
+ readonly detail?: string | null;
88
128
  /** A GroupId joins that group (I7 validated); null leaves the current group. */
89
129
  readonly group?: GroupId | null;
90
130
  /** A NodeKind sets the presentation kind; null clears it. */
@@ -96,10 +136,29 @@ export interface UpdateNodeInput {
96
136
  }
97
137
  /**
98
138
  * Update a node's status, label, evidence, design detail, group membership,
99
- * kind and/or lane. Absent fields are left untouched. No transition rules:
100
- * the ledger records whatever the caller reports, whenever they report it.
139
+ * kind, lane and/or submap link. Absent fields are left untouched, null
140
+ * clears the field. No transition rules: the ledger records whatever the
141
+ * caller reports, whenever they report it.
142
+ * Postcondition: the node keeps its band — moving between bands is moveNode,
143
+ * which is the operation that re-checks I4.
101
144
  */
102
145
  export declare function updateNode(map: MellosMap, input: UpdateNodeInput): Result<MellosMap, MapError>;
146
+ /**
147
+ * Move a node to another band — the revision every ghost design eventually
148
+ * needs, and the only way to empty a band without deleting work.
149
+ *
150
+ * Validated before anything changes:
151
+ * - the target band exists (I2);
152
+ * - every edge touching the node still points strictly downward from its
153
+ * NEW rank (I4) — a move that would flatten or invert a dependency is
154
+ * refused, naming the edge that blocks it;
155
+ * - group membership (I7) still holds. A grouped node may only move to its
156
+ * group's band; the move never silently ungroups it, because dropping a
157
+ * subsystem membership is a decision the caller must make out loud with
158
+ * updateNode({ group: null }).
159
+ * @returns the map with the node rebanded, or the first refusal.
160
+ */
161
+ export declare function moveNode(map: MellosMap, id: NodeId, layer: LayerId): Result<MellosMap, MapError>;
103
162
  /**
104
163
  * Remove a node.
105
164
  * Postcondition (explicit part of this contract): every edge touching the
package/lib/domain/ops.js CHANGED
@@ -5,10 +5,16 @@
5
5
  * before any new value is built, and the commit expression can no longer
6
6
  * fail. Inputs are never mutated; the result always carries a fresh map.
7
7
  *
8
- * These functions enforce the structural invariants I1-I9 documented in
8
+ * These functions enforce the structural invariants I1-I10 documented in
9
9
  * types.ts and nothing else. In particular there are no workflow rules here:
10
10
  * any status may be set at any time, in any order. Discipline lives with the
11
11
  * caller; this layer only keeps the map structurally true.
12
+ *
13
+ * Every declared thing can also be REVISED, because a ghost design is a
14
+ * hypothesis and revising it is honest work: a node moves band (moveNode), a
15
+ * band is renamed or re-ordered (updateLayer), a group or lane is relabeled
16
+ * (updateGroup, updateLane), and every optional field a node carries can be
17
+ * cleared as explicitly as it was set (null, never an empty string).
12
18
  */
13
19
  import { err, ok, } from './types.js';
14
20
  function findLayer(map, id) {
@@ -32,8 +38,28 @@ function checkMembership(map, node, nodeLayer, group) {
32
38
  function hasEdge(map, from, to) {
33
39
  return map.edges.some((e) => e.from === from && e.to === to);
34
40
  }
35
- /** Set or replace the map title. */
41
+ /**
42
+ * Validate that a new id does not already name the OTHER kind of box (I10).
43
+ * Ids are compared as raw slugs on purpose: the shared namespace is exactly
44
+ * what the brands cannot express, which is why this check exists.
45
+ */
46
+ function checkIdSpace(map, id, declaring) {
47
+ const taken = declaring === 'node'
48
+ ? map.groups.some((g) => g.id === id)
49
+ : map.nodes.some((n) => n.id === id);
50
+ return taken ? { kind: 'id-collision', id, taken: declaring === 'node' ? 'group' : 'node' } : undefined;
51
+ }
52
+ /**
53
+ * Set, replace or clear the map title.
54
+ * @param title - the new title; null (or an explicit undefined) removes the
55
+ * field entirely, so a cleared title serializes as an absent key rather
56
+ * than as an empty string nobody can tell from a real one.
57
+ */
36
58
  export function setTitle(map, title) {
59
+ if (title === null || title === undefined) {
60
+ const { title: _dropped, ...rest } = map;
61
+ return rest;
62
+ }
37
63
  return { ...map, title };
38
64
  }
39
65
  /** Set or replace the map kind (presentation intent — never structural). */
@@ -49,6 +75,12 @@ export function declareLane(map, input) {
49
75
  return err({ kind: 'duplicate-lane', id: input.id });
50
76
  return ok({ ...map, lanes: [...map.lanes, { id: input.id, label: input.label }] });
51
77
  }
78
+ /** Relabel a lane. The column keeps its id, its order and its members. */
79
+ export function updateLane(map, id, label) {
80
+ if (!findLane(map, id))
81
+ return err({ kind: 'unknown-lane', id });
82
+ return ok({ ...map, lanes: map.lanes.map((l) => (l.id === id ? { ...l, label } : l)) });
83
+ }
52
84
  /**
53
85
  * Remove a lane.
54
86
  * Postcondition: former members stay on the map, merely off-lane — removing
@@ -77,10 +109,53 @@ export function declareLayer(map, input) {
77
109
  return err({ kind: 'duplicate-rank', rank: input.rank, existing: rankHolder.id });
78
110
  return ok({ ...map, layers: [...map.layers, { id: input.id, name: input.name, rank: input.rank }] });
79
111
  }
80
- /** Add a new group to an existing band (I6). */
112
+ /**
113
+ * Rename a band and/or move it in the vertical order. Absent fields are left
114
+ * untouched; the band keeps its id and its occupants either way.
115
+ *
116
+ * A rank change is the one revision that can invalidate the whole graph, so
117
+ * it is validated against the map as a whole before anything changes:
118
+ * - the new rank is free (I1) — bands stay totally ordered;
119
+ * - EVERY edge, not only the ones touching this band, still points
120
+ * strictly downward under the new order (I4). Re-ranking a band moves
121
+ * all of its nodes at once, so an edge two bands away can be the one
122
+ * that breaks; the refusal names it.
123
+ * @returns the reordered map, or the first refusal.
124
+ */
125
+ export function updateLayer(map, id, input) {
126
+ const layer = findLayer(map, id);
127
+ if (!layer)
128
+ return err({ kind: 'unknown-layer', id });
129
+ if (input.rank !== undefined && input.rank !== layer.rank) {
130
+ const rankHolder = map.layers.find((l) => l.rank === input.rank && l.id !== id);
131
+ if (rankHolder)
132
+ return err({ kind: 'duplicate-rank', rank: input.rank, existing: rankHolder.id });
133
+ // Layers are guaranteed to exist for stored nodes (I2), so no lookup misses.
134
+ const rankAfter = (nodeId) => {
135
+ const nodeLayer = findNode(map, nodeId).layer;
136
+ return nodeLayer === id ? input.rank : findLayer(map, nodeLayer).rank;
137
+ };
138
+ for (const e of map.edges) {
139
+ const fromRank = rankAfter(e.from);
140
+ const toRank = rankAfter(e.to);
141
+ if (fromRank <= toRank)
142
+ return err({ kind: 'edge-not-downward', from: e.from, fromRank, to: e.to, toRank });
143
+ }
144
+ }
145
+ const updated = {
146
+ ...layer,
147
+ ...(input.name !== undefined ? { name: input.name } : {}),
148
+ ...(input.rank !== undefined ? { rank: input.rank } : {}),
149
+ };
150
+ return ok({ ...map, layers: map.layers.map((l) => (l.id === id ? updated : l)) });
151
+ }
152
+ /** Add a new group to an existing band (I6), under an id no node holds (I10). */
81
153
  export function declareGroup(map, input) {
82
154
  if (findGroup(map, input.id))
83
155
  return err({ kind: 'duplicate-group', id: input.id });
156
+ const collision = checkIdSpace(map, input.id, 'group');
157
+ if (collision)
158
+ return err(collision);
84
159
  if (!findLayer(map, input.layer))
85
160
  return err({ kind: 'unknown-layer', id: input.layer });
86
161
  return ok({ ...map, groups: [...map.groups, { id: input.id, label: input.label, layer: input.layer }] });
@@ -133,12 +208,16 @@ export function mapStatus(map) {
133
208
  return aggregateStatus(map.nodes);
134
209
  }
135
210
  /**
136
- * Add a new node to an existing band (I2, I3), optionally joining a same-band
137
- * group (I7) and/or an existing lane (I9).
211
+ * Add a new node to an existing band (I2, I3) under an id no group holds
212
+ * (I10), optionally joining a same-band group (I7) and/or an existing lane
213
+ * (I9).
138
214
  */
139
215
  export function declareNode(map, input) {
140
216
  if (findNode(map, input.id))
141
217
  return err({ kind: 'duplicate-node', id: input.id });
218
+ const collision = checkIdSpace(map, input.id, 'node');
219
+ if (collision)
220
+ return err(collision);
142
221
  if (!findLayer(map, input.layer))
143
222
  return err({ kind: 'unknown-layer', id: input.layer });
144
223
  if (input.group !== undefined) {
@@ -153,6 +232,7 @@ export function declareNode(map, input) {
153
232
  label: input.label,
154
233
  layer: input.layer,
155
234
  status: input.status ?? 'planned',
235
+ ...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
156
236
  ...(input.detail !== undefined ? { detail: input.detail } : {}),
157
237
  ...(input.group !== undefined ? { group: input.group } : {}),
158
238
  ...(input.kind !== undefined ? { kind: input.kind } : {}),
@@ -184,10 +264,20 @@ export function linkNodes(map, from, to, label) {
184
264
  return err({ kind: 'edge-not-downward', from, fromRank, to, toRank });
185
265
  return ok({ ...map, edges: [...map.edges, { from, to, ...(label !== undefined ? { label } : {}) }] });
186
266
  }
267
+ /**
268
+ * Resolve one optional-and-clearable field: absent input keeps the stored
269
+ * value, null erases it, anything else replaces it.
270
+ */
271
+ function resolveOptional(input, current) {
272
+ return input === undefined ? current : input === null ? undefined : input;
273
+ }
187
274
  /**
188
275
  * Update a node's status, label, evidence, design detail, group membership,
189
- * kind and/or lane. Absent fields are left untouched. No transition rules:
190
- * the ledger records whatever the caller reports, whenever they report it.
276
+ * kind, lane and/or submap link. Absent fields are left untouched, null
277
+ * clears the field. No transition rules: the ledger records whatever the
278
+ * caller reports, whenever they report it.
279
+ * Postcondition: the node keeps its band — moving between bands is moveNode,
280
+ * which is the operation that re-checks I4.
191
281
  */
192
282
  export function updateNode(map, input) {
193
283
  const node = findNode(map, input.id);
@@ -201,24 +291,65 @@ export function updateNode(map, input) {
201
291
  if (input.lane !== undefined && input.lane !== null && !findLane(map, input.lane)) {
202
292
  return err({ kind: 'unknown-lane', id: input.lane });
203
293
  }
204
- const { group: currentGroup, kind: currentKind, lane: currentLane, submap: currentSubmap, ...bare } = node;
205
- const nextGroup = input.group === undefined ? currentGroup : input.group === null ? undefined : input.group;
206
- const nextKind = input.kind === undefined ? currentKind : input.kind === null ? undefined : input.kind;
207
- const nextLane = input.lane === undefined ? currentLane : input.lane === null ? undefined : input.lane;
208
- const nextSubmap = input.submap === undefined ? currentSubmap : input.submap === null ? undefined : input.submap;
294
+ const { group: currentGroup, kind: currentKind, lane: currentLane, submap: currentSubmap, evidence: currentEvidence, detail: currentDetail, ...bare } = node;
295
+ const nextGroup = resolveOptional(input.group, currentGroup);
296
+ const nextKind = resolveOptional(input.kind, currentKind);
297
+ const nextLane = resolveOptional(input.lane, currentLane);
298
+ const nextSubmap = resolveOptional(input.submap, currentSubmap);
299
+ const nextEvidence = resolveOptional(input.evidence, currentEvidence);
300
+ const nextDetail = resolveOptional(input.detail, currentDetail);
209
301
  const updated = {
210
302
  ...bare,
303
+ ...(nextEvidence !== undefined ? { evidence: nextEvidence } : {}),
304
+ ...(nextDetail !== undefined ? { detail: nextDetail } : {}),
211
305
  ...(nextGroup !== undefined ? { group: nextGroup } : {}),
212
306
  ...(nextKind !== undefined ? { kind: nextKind } : {}),
213
307
  ...(nextLane !== undefined ? { lane: nextLane } : {}),
214
308
  ...(nextSubmap !== undefined ? { submap: nextSubmap } : {}),
215
309
  ...(input.status !== undefined ? { status: input.status } : {}),
216
310
  ...(input.label !== undefined ? { label: input.label } : {}),
217
- ...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
218
- ...(input.detail !== undefined ? { detail: input.detail } : {}),
219
311
  };
220
312
  return ok({ ...map, nodes: map.nodes.map((n) => (n.id === input.id ? updated : n)) });
221
313
  }
314
+ /**
315
+ * Move a node to another band — the revision every ghost design eventually
316
+ * needs, and the only way to empty a band without deleting work.
317
+ *
318
+ * Validated before anything changes:
319
+ * - the target band exists (I2);
320
+ * - every edge touching the node still points strictly downward from its
321
+ * NEW rank (I4) — a move that would flatten or invert a dependency is
322
+ * refused, naming the edge that blocks it;
323
+ * - group membership (I7) still holds. A grouped node may only move to its
324
+ * group's band; the move never silently ungroups it, because dropping a
325
+ * subsystem membership is a decision the caller must make out loud with
326
+ * updateNode({ group: null }).
327
+ * @returns the map with the node rebanded, or the first refusal.
328
+ */
329
+ export function moveNode(map, id, layer) {
330
+ const node = findNode(map, id);
331
+ if (!node)
332
+ return err({ kind: 'unknown-node', id });
333
+ const target = findLayer(map, layer);
334
+ if (!target)
335
+ return err({ kind: 'unknown-layer', id: layer });
336
+ if (node.group !== undefined) {
337
+ const bad = checkMembership(map, id, layer, node.group);
338
+ if (bad)
339
+ return err(bad);
340
+ }
341
+ // Layers are guaranteed to exist for stored nodes (I2), so no lookup misses.
342
+ const rankAfter = (nodeId) => nodeId === id ? target.rank : findLayer(map, findNode(map, nodeId).layer).rank;
343
+ for (const e of map.edges) {
344
+ if (e.from !== id && e.to !== id)
345
+ continue;
346
+ const fromRank = rankAfter(e.from);
347
+ const toRank = rankAfter(e.to);
348
+ if (fromRank <= toRank)
349
+ return err({ kind: 'edge-not-downward', from: e.from, fromRank, to: e.to, toRank });
350
+ }
351
+ return ok({ ...map, nodes: map.nodes.map((n) => (n.id === id ? { ...n, layer } : n)) });
352
+ }
222
353
  /**
223
354
  * Remove a node.
224
355
  * Postcondition (explicit part of this contract): every edge touching the
@@ -8,7 +8,9 @@
8
8
  *
9
9
  * Structural invariants owned by this layer (and only these — the map is a
10
10
  * ledger, not a judge; it records work honestly and never polices workflow):
11
- * I1. Layer ids are unique; layer ranks are unique (bands are totally ordered).
11
+ * I1. Layer ids are unique; layer ranks are unique (bands are totally
12
+ * ordered). A rank is a Rank — a branded integer in a closed range, so
13
+ * "unique" and "strictly lower" are decidable; see makeRank.
12
14
  * I2. Every node belongs to exactly one existing layer.
13
15
  * I3. Node ids are unique.
14
16
  * I4. An edge `from -> to` means "from USES to" and requires
@@ -26,6 +28,13 @@
26
28
  * bands (a sequence participant, a swim lane); lane declaration order
27
29
  * is left-to-right render order.
28
30
  * I9. A node's lane, when set, exists.
31
+ * I10. Node ids and group ids share ONE namespace: an id names a node or a
32
+ * group, never both. I3 and I6 are per-set and a group is a BOX in
33
+ * every view that shows one — the far zoom replaces its members with
34
+ * it, and a detail panel resolves a hovered id against groups first.
35
+ * Two boxes under one id therefore make the second unreachable and
36
+ * the aggregated view ambiguous, which is a structural fault and not
37
+ * a rendering accident, so it is refused where ids are declared.
29
38
  *
30
39
  * The map kind (dev | architecture | dataflow | behavior-tree | sequence) is
31
40
  * presentation intent, not structure: every kind shares the same invariants,
@@ -90,6 +99,34 @@ export declare function makeGroupId(raw: string): Result<GroupId, InvalidId>;
90
99
  export declare function makeLaneId(raw: string): Result<LaneId, InvalidId>;
91
100
  export declare function makeNodeKind(raw: string): Result<NodeKind, InvalidId>;
92
101
  export declare function makeSubmapRef(raw: string): Result<SubmapRef, InvalidId>;
102
+ /**
103
+ * A band's position on the vertical order — a branded integer, never a raw
104
+ * number. The brand is what makes I1 and I4 hold: `===` dedupe (I1) and
105
+ * `fromRank > toRank` (I4) are both silently false for NaN, so a NaN rank
106
+ * would admit same-band and reciprocal edges and lose acyclicity. The closed
107
+ * range is the same one every surface (tool schema, file format) states, so
108
+ * a rank the domain accepts always survives a save/reload round trip.
109
+ */
110
+ export type Rank = number & {
111
+ readonly __brand: 'Rank';
112
+ };
113
+ /** Bottom band — "primitives are the ground". */
114
+ export declare const RANK_MIN = 0;
115
+ /** Highest band. A map deeper than a hundred bands is a different problem. */
116
+ export declare const RANK_MAX = 99;
117
+ export declare const RANK_RULE_TEXT = "an integer in 0..99, 0 = bottom / most primitive";
118
+ export type InvalidRank = {
119
+ readonly kind: 'invalid-rank';
120
+ readonly raw: number;
121
+ readonly rule: string;
122
+ };
123
+ /**
124
+ * The only way to obtain a Rank.
125
+ * @param raw - a candidate rank; NaN, Infinity, fractions and out-of-range
126
+ * integers are refused.
127
+ * @returns the branded rank, or the refusal as a value.
128
+ */
129
+ export declare function makeRank(raw: number): Result<Rank, InvalidRank>;
93
130
  /**
94
131
  * Closed vocabulary of map kinds — the diagram's presentation intent.
95
132
  * 'dev' (the default) is the progress ledger; the rest are documentation
@@ -112,7 +149,7 @@ export declare function makeNodeStatus(raw: string): Result<NodeStatus, {
112
149
  export interface MapLayer {
113
150
  readonly id: LayerId;
114
151
  readonly name: string;
115
- readonly rank: number;
152
+ readonly rank: Rank;
116
153
  }
117
154
  /**
118
155
  * A labeled cluster of same-band nodes — a subsystem. The far zoom renders
@@ -172,7 +209,7 @@ export interface MellosMap {
172
209
  }
173
210
  export declare const EMPTY_MAP: MellosMap;
174
211
  /** Every way an operation can be refused, as data. */
175
- export type MapError = InvalidId | {
212
+ export type MapError = InvalidId | InvalidRank | {
176
213
  readonly kind: 'invalid-status';
177
214
  readonly raw: string;
178
215
  } | {
@@ -180,7 +217,7 @@ export type MapError = InvalidId | {
180
217
  readonly id: LayerId;
181
218
  } | {
182
219
  readonly kind: 'duplicate-rank';
183
- readonly rank: number;
220
+ readonly rank: Rank;
184
221
  readonly existing: LayerId;
185
222
  } | {
186
223
  readonly kind: 'duplicate-node';
@@ -205,6 +242,10 @@ export type MapError = InvalidId | {
205
242
  } | {
206
243
  readonly kind: 'duplicate-group';
207
244
  readonly id: GroupId;
245
+ } | {
246
+ readonly kind: 'id-collision';
247
+ readonly id: NodeId | GroupId;
248
+ readonly taken: 'node' | 'group';
208
249
  } | {
209
250
  readonly kind: 'unknown-group';
210
251
  readonly id: GroupId;
@@ -234,9 +275,9 @@ export type MapError = InvalidId | {
234
275
  } | {
235
276
  readonly kind: 'edge-not-downward';
236
277
  readonly from: NodeId;
237
- readonly fromRank: number;
278
+ readonly fromRank: Rank;
238
279
  readonly to: NodeId;
239
- readonly toRank: number;
280
+ readonly toRank: Rank;
240
281
  };
241
282
  /** Human-readable rendering of a MapError, for tool results and logs. */
242
283
  export declare function describeMapError(e: MapError): string;
@@ -8,7 +8,9 @@
8
8
  *
9
9
  * Structural invariants owned by this layer (and only these — the map is a
10
10
  * ledger, not a judge; it records work honestly and never polices workflow):
11
- * I1. Layer ids are unique; layer ranks are unique (bands are totally ordered).
11
+ * I1. Layer ids are unique; layer ranks are unique (bands are totally
12
+ * ordered). A rank is a Rank — a branded integer in a closed range, so
13
+ * "unique" and "strictly lower" are decidable; see makeRank.
12
14
  * I2. Every node belongs to exactly one existing layer.
13
15
  * I3. Node ids are unique.
14
16
  * I4. An edge `from -> to` means "from USES to" and requires
@@ -26,6 +28,13 @@
26
28
  * bands (a sequence participant, a swim lane); lane declaration order
27
29
  * is left-to-right render order.
28
30
  * I9. A node's lane, when set, exists.
31
+ * I10. Node ids and group ids share ONE namespace: an id names a node or a
32
+ * group, never both. I3 and I6 are per-set and a group is a BOX in
33
+ * every view that shows one — the far zoom replaces its members with
34
+ * it, and a detail panel resolves a hovered id against groups first.
35
+ * Two boxes under one id therefore make the second unreachable and
36
+ * the aggregated view ambiguous, which is a structural fault and not
37
+ * a rendering accident, so it is refused where ids are declared.
29
38
  *
30
39
  * The map kind (dev | architecture | dataflow | behavior-tree | sequence) is
31
40
  * presentation intent, not structure: every kind shares the same invariants,
@@ -58,6 +67,22 @@ export function makeNodeKind(raw) {
58
67
  export function makeSubmapRef(raw) {
59
68
  return ID_RULE.test(raw) ? ok(raw) : err({ kind: 'invalid-id', raw, rule: ID_RULE_TEXT });
60
69
  }
70
+ /** Bottom band — "primitives are the ground". */
71
+ export const RANK_MIN = 0;
72
+ /** Highest band. A map deeper than a hundred bands is a different problem. */
73
+ export const RANK_MAX = 99;
74
+ export const RANK_RULE_TEXT = `an integer in ${RANK_MIN}..${RANK_MAX}, 0 = bottom / most primitive`;
75
+ /**
76
+ * The only way to obtain a Rank.
77
+ * @param raw - a candidate rank; NaN, Infinity, fractions and out-of-range
78
+ * integers are refused.
79
+ * @returns the branded rank, or the refusal as a value.
80
+ */
81
+ export function makeRank(raw) {
82
+ return Number.isInteger(raw) && raw >= RANK_MIN && raw <= RANK_MAX
83
+ ? ok(raw)
84
+ : err({ kind: 'invalid-rank', raw, rule: RANK_RULE_TEXT });
85
+ }
61
86
  /**
62
87
  * Closed vocabulary of map kinds — the diagram's presentation intent.
63
88
  * 'dev' (the default) is the progress ledger; the rest are documentation
@@ -80,6 +105,8 @@ export function describeMapError(e) {
80
105
  switch (e.kind) {
81
106
  case 'invalid-id':
82
107
  return `invalid id "${e.raw}" (rule: ${e.rule})`;
108
+ case 'invalid-rank':
109
+ return `invalid rank ${e.raw} (rule: ${e.rule})`;
83
110
  case 'invalid-status':
84
111
  return `invalid status "${e.raw}" (expected: ${NODE_STATUSES.join(' | ')})`;
85
112
  case 'duplicate-layer':
@@ -100,6 +127,9 @@ export function describeMapError(e) {
100
127
  return `node "${e.id}" cannot depend on itself`;
101
128
  case 'duplicate-group':
102
129
  return `group "${e.id}" already exists`;
130
+ case 'id-collision':
131
+ return (`id "${e.id}" already names a ${e.taken} on this map; nodes and groups share one id namespace ` +
132
+ `(both render as boxes, so one id must mean one box) — rename "${e.id}"`);
103
133
  case 'unknown-group':
104
134
  return `group "${e.id}" does not exist`;
105
135
  case 'invalid-map-kind':
@@ -112,9 +142,10 @@ export function describeMapError(e) {
112
142
  return (`node "${e.node}" (layer ${e.nodeLayer}) cannot join group "${e.group}" (layer ${e.groupLayer}); ` +
113
143
  `groups cluster nodes within one band`);
114
144
  case 'layer-not-empty':
115
- return `layer "${e.id}" still holds node "${e.occupant}"; move or remove its nodes first`;
145
+ return (`layer "${e.id}" still holds node "${e.occupant}"; move its nodes to another band (moveNode) ` +
146
+ `or remove them (removeNode) first`);
116
147
  case 'layer-holds-group':
117
- return `layer "${e.id}" still holds group "${e.occupant}"; remove its groups first`;
148
+ return `layer "${e.id}" still holds group "${e.occupant}"; remove its groups (removeGroup) first`;
118
149
  case 'edge-not-downward':
119
150
  return (`edge ${e.from} (rank ${e.fromRank}) -> ${e.to} (rank ${e.toRank}) is not strictly downward; ` +
120
151
  `dependencies may only point to a lower layer`);
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Layer 4a — the drawing surface: a grid of cells that knows how to merge
3
+ * crossing lines, and how to emit itself as terminal rows.
4
+ *
5
+ * Two kinds of ink live in a cell. A LITERAL is a character somebody chose (a
6
+ * label, a box border); a MASK is a set of directions a routed line passes
7
+ * through, and the character comes out of the mask at emit time. That is what
8
+ * makes junctions free: two wires crossing simply union their masks, and ┼
9
+ * appears without anyone routing around anything.
10
+ *
11
+ * The surface knows nothing about maps, statuses or zoom — it takes a Style
12
+ * (the ink palette below) and coordinates, and it is the only place ANSI is
13
+ * produced.
14
+ */
15
+ import type { RenderOptions, Viewport } from './options.js';
16
+ export type Style = 'none' | 'dim' | 'amber' | 'green' | 'greenDim' | 'red' | 'faint';
17
+ /** SGR parameter per style; combined with bold ("1") at emit time. */
18
+ export declare const SGR: Readonly<Record<Style, string>>;
19
+ export declare const ANSI_RESET = "\u001B[0m";
20
+ export declare const UP = 1;
21
+ export declare const DOWN = 2;
22
+ export declare const LEFT = 4;
23
+ export declare const RIGHT = 8;
24
+ export declare class Canvas {
25
+ private readonly rows;
26
+ private cell;
27
+ get height(): number;
28
+ get width(): number;
29
+ /** Write literal text starting at (x, y). Returns the column just past it. */
30
+ text(x: number, y: number, s: string, style: Style, bold?: boolean): number;
31
+ /** Merge a routed-line direction mask into (x, y). */
32
+ line(x: number, y: number, mask: number, heavyHorizontal?: boolean, bright?: boolean): void;
33
+ /**
34
+ * Emit terminal lines, optionally windowed to a viewport. Slicing happens
35
+ * at the cell level so ANSI codes reopen correctly inside the window and a
36
+ * CJK character cut in half at either edge degrades to a space instead of
37
+ * shifting the whole row. Routed wiring (mask cells) emits FAINT — the
38
+ * circuit board recedes, the boxes glow.
39
+ */
40
+ emit(opts: RenderOptions, viewport?: Viewport): string[];
41
+ }
42
+ /**
43
+ * Draw an orthogonal polyline through `points` (consecutive points must share
44
+ * an x or a y). Interior cells of a segment carry the segment's axis mask;
45
+ * every point cell carries only the directions of the segments that actually
46
+ * touch it — so path endpoints become clean junction stubs (e.g. ┬ when
47
+ * entering a box border) and turning points become corner characters, all via
48
+ * the same mask union. Zero-length segments vanish naturally.
49
+ */
50
+ export declare function drawPath(canvas: Canvas, points: ReadonlyArray<readonly [number, number]>, bright?: boolean): void;