mellos-mapping 0.19.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 +366 -56
  2. package/README.zh-CN.md +319 -47
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1737 -897
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1612 -902
  8. package/lib/domain/ops.d.ts +171 -0
  9. package/lib/domain/ops.js +384 -0
  10. package/lib/domain/types.d.ts +283 -0
  11. package/lib/domain/types.js +153 -0
  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 +88 -0
  21. package/lib/render/render.js +128 -0
  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 +169 -0
  31. package/lib/semantics/semantics.js +380 -0
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +67 -0
  35. package/lib/store/format.js +334 -0
  36. package/lib/store/store.d.ts +296 -0
  37. package/lib/store/store.js +734 -0
  38. package/package.json +41 -5
  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
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Layer 0 — pure operations on a MellosMap.
3
+ *
4
+ * Every operation follows validate -> prepare -> commit: all refusals happen
5
+ * before any new value is built, and the commit expression can no longer
6
+ * fail. Inputs are never mutated; the result always carries a fresh map.
7
+ *
8
+ * These functions enforce the structural invariants I1-I10 documented in
9
+ * types.ts and nothing else. In particular there are no workflow rules here:
10
+ * any status may be set at any time, in any order. Discipline lives with the
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.
25
+ */
26
+ export declare function setTitle(map: MellosMap, title: string | null | undefined): MellosMap;
27
+ /** Set or replace the map kind (presentation intent — never structural). */
28
+ export declare function setKind(map: MellosMap, kind: MapKind): MellosMap;
29
+ export interface DeclareLaneInput {
30
+ readonly id: LaneId;
31
+ readonly label: string;
32
+ }
33
+ /** Add a new lane (I8). Declaration order is left-to-right render order. */
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>;
37
+ /**
38
+ * Remove a lane.
39
+ * Postcondition: former members stay on the map, merely off-lane — removing
40
+ * a column label never destroys work records (same contract as removeGroup).
41
+ */
42
+ export declare function removeLane(map: MellosMap, id: LaneId): Result<MellosMap, MapError>;
43
+ export interface DeclareLayerInput {
44
+ readonly id: LayerId;
45
+ readonly name: string;
46
+ readonly rank: Rank;
47
+ }
48
+ /** Add a new band. Refuses duplicate ids and duplicate ranks (I1). */
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>;
68
+ export interface DeclareGroupInput {
69
+ readonly id: GroupId;
70
+ readonly label: string;
71
+ readonly layer: LayerId;
72
+ }
73
+ /** Add a new group to an existing band (I6), under an id no node holds (I10). */
74
+ export declare function declareGroup(map: MellosMap, input: DeclareGroupInput): Result<MellosMap, MapError>;
75
+ /** Rename a group. */
76
+ export declare function updateGroup(map: MellosMap, id: GroupId, label: string): Result<MellosMap, MapError>;
77
+ /**
78
+ * Remove a group.
79
+ * Postcondition: former members stay on the map, merely ungrouped — removing
80
+ * a cluster label never destroys work records.
81
+ */
82
+ export declare function removeGroup(map: MellosMap, id: GroupId): Result<MellosMap, MapError>;
83
+ /**
84
+ * Derived, never stored: a group's aggregate status. Any regressed member
85
+ * cracks the group; else any spinner spins it; else all-done (non-empty)
86
+ * completes it; anything else is planned.
87
+ */
88
+ export declare function groupStatus(map: MellosMap, id: GroupId): NodeStatus;
89
+ /** Derived, never stored: the whole map's aggregate status (same rules as groupStatus). */
90
+ export declare function mapStatus(map: MellosMap): NodeStatus;
91
+ export interface DeclareNodeInput {
92
+ readonly id: NodeId;
93
+ readonly label: string;
94
+ readonly layer: LayerId;
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;
102
+ readonly detail?: string;
103
+ readonly group?: GroupId;
104
+ readonly kind?: NodeKind;
105
+ readonly lane?: LaneId;
106
+ readonly submap?: SubmapRef;
107
+ }
108
+ /**
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).
112
+ */
113
+ export declare function declareNode(map: MellosMap, input: DeclareNodeInput): Result<MellosMap, MapError>;
114
+ /**
115
+ * Add the dependency edge `from USES to`, optionally labeled with what flows
116
+ * along it. Refuses self-edges, duplicates and any edge that does not point
117
+ * strictly downward (I4).
118
+ */
119
+ export declare function linkNodes(map: MellosMap, from: NodeId, to: NodeId, label?: string): Result<MellosMap, MapError>;
120
+ export interface UpdateNodeInput {
121
+ readonly id: NodeId;
122
+ readonly status?: NodeStatus;
123
+ readonly label?: 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;
128
+ /** A GroupId joins that group (I7 validated); null leaves the current group. */
129
+ readonly group?: GroupId | null;
130
+ /** A NodeKind sets the presentation kind; null clears it. */
131
+ readonly kind?: NodeKind | null;
132
+ /** A LaneId joins that lane (I9 validated); null leaves the current lane. */
133
+ readonly lane?: LaneId | null;
134
+ /** A SubmapRef links a child map page; null unlinks it. */
135
+ readonly submap?: SubmapRef | null;
136
+ }
137
+ /**
138
+ * Update a node's status, label, evidence, design detail, group membership,
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.
144
+ */
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>;
162
+ /**
163
+ * Remove a node.
164
+ * Postcondition (explicit part of this contract): every edge touching the
165
+ * node is removed with it — a map never holds edges to missing nodes.
166
+ */
167
+ export declare function removeNode(map: MellosMap, id: NodeId): Result<MellosMap, MapError>;
168
+ /** Remove one dependency edge. */
169
+ export declare function removeEdge(map: MellosMap, from: NodeId, to: NodeId): Result<MellosMap, MapError>;
170
+ /** Remove a band. Only empty bands may go — neither a node (I2) nor a group (I6) may be orphaned. */
171
+ export declare function removeLayer(map: MellosMap, id: LayerId): Result<MellosMap, MapError>;
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Layer 0 — pure operations on a MellosMap.
3
+ *
4
+ * Every operation follows validate -> prepare -> commit: all refusals happen
5
+ * before any new value is built, and the commit expression can no longer
6
+ * fail. Inputs are never mutated; the result always carries a fresh map.
7
+ *
8
+ * These functions enforce the structural invariants I1-I10 documented in
9
+ * types.ts and nothing else. In particular there are no workflow rules here:
10
+ * any status may be set at any time, in any order. Discipline lives with the
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 { err, ok, } from './types.js';
20
+ function findLayer(map, id) {
21
+ return map.layers.find((l) => l.id === id);
22
+ }
23
+ function findNode(map, id) {
24
+ return map.nodes.find((n) => n.id === id);
25
+ }
26
+ function findGroup(map, id) {
27
+ return map.groups.find((g) => g.id === id);
28
+ }
29
+ /** Validate that `node` may join `group` (I7): the group exists on the node's own band. */
30
+ function checkMembership(map, node, nodeLayer, group) {
31
+ const g = findGroup(map, group);
32
+ if (!g)
33
+ return { kind: 'unknown-group', id: group };
34
+ if (g.layer !== nodeLayer)
35
+ return { kind: 'group-layer-mismatch', node, nodeLayer, group, groupLayer: g.layer };
36
+ return undefined;
37
+ }
38
+ function hasEdge(map, from, to) {
39
+ return map.edges.some((e) => e.from === from && e.to === to);
40
+ }
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
+ */
58
+ export function setTitle(map, title) {
59
+ if (title === null || title === undefined) {
60
+ const { title: _dropped, ...rest } = map;
61
+ return rest;
62
+ }
63
+ return { ...map, title };
64
+ }
65
+ /** Set or replace the map kind (presentation intent — never structural). */
66
+ export function setKind(map, kind) {
67
+ return { ...map, kind };
68
+ }
69
+ function findLane(map, id) {
70
+ return map.lanes.find((l) => l.id === id);
71
+ }
72
+ /** Add a new lane (I8). Declaration order is left-to-right render order. */
73
+ export function declareLane(map, input) {
74
+ if (findLane(map, input.id))
75
+ return err({ kind: 'duplicate-lane', id: input.id });
76
+ return ok({ ...map, lanes: [...map.lanes, { id: input.id, label: input.label }] });
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
+ }
84
+ /**
85
+ * Remove a lane.
86
+ * Postcondition: former members stay on the map, merely off-lane — removing
87
+ * a column label never destroys work records (same contract as removeGroup).
88
+ */
89
+ export function removeLane(map, id) {
90
+ if (!findLane(map, id))
91
+ return err({ kind: 'unknown-lane', id });
92
+ return ok({
93
+ ...map,
94
+ lanes: map.lanes.filter((l) => l.id !== id),
95
+ nodes: map.nodes.map((n) => {
96
+ if (n.lane !== id)
97
+ return n;
98
+ const { lane: _dropped, ...rest } = n;
99
+ return rest;
100
+ }),
101
+ });
102
+ }
103
+ /** Add a new band. Refuses duplicate ids and duplicate ranks (I1). */
104
+ export function declareLayer(map, input) {
105
+ if (findLayer(map, input.id))
106
+ return err({ kind: 'duplicate-layer', id: input.id });
107
+ const rankHolder = map.layers.find((l) => l.rank === input.rank);
108
+ if (rankHolder)
109
+ return err({ kind: 'duplicate-rank', rank: input.rank, existing: rankHolder.id });
110
+ return ok({ ...map, layers: [...map.layers, { id: input.id, name: input.name, rank: input.rank }] });
111
+ }
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). */
153
+ export function declareGroup(map, input) {
154
+ if (findGroup(map, input.id))
155
+ return err({ kind: 'duplicate-group', id: input.id });
156
+ const collision = checkIdSpace(map, input.id, 'group');
157
+ if (collision)
158
+ return err(collision);
159
+ if (!findLayer(map, input.layer))
160
+ return err({ kind: 'unknown-layer', id: input.layer });
161
+ return ok({ ...map, groups: [...map.groups, { id: input.id, label: input.label, layer: input.layer }] });
162
+ }
163
+ /** Rename a group. */
164
+ export function updateGroup(map, id, label) {
165
+ if (!findGroup(map, id))
166
+ return err({ kind: 'unknown-group', id });
167
+ return ok({ ...map, groups: map.groups.map((g) => (g.id === id ? { ...g, label } : g)) });
168
+ }
169
+ /**
170
+ * Remove a group.
171
+ * Postcondition: former members stay on the map, merely ungrouped — removing
172
+ * a cluster label never destroys work records.
173
+ */
174
+ export function removeGroup(map, id) {
175
+ if (!findGroup(map, id))
176
+ return err({ kind: 'unknown-group', id });
177
+ return ok({
178
+ ...map,
179
+ groups: map.groups.filter((g) => g.id !== id),
180
+ nodes: map.nodes.map((n) => {
181
+ if (n.group !== id)
182
+ return n;
183
+ const { group: _dropped, ...rest } = n;
184
+ return rest;
185
+ }),
186
+ });
187
+ }
188
+ /** Aggregate status over a set of nodes: regression trumps, then activity, then completion. */
189
+ function aggregateStatus(nodes) {
190
+ if (nodes.some((n) => n.status === 'regressed'))
191
+ return 'regressed';
192
+ if (nodes.some((n) => n.status === 'in-progress'))
193
+ return 'in-progress';
194
+ if (nodes.length > 0 && nodes.every((n) => n.status === 'done'))
195
+ return 'done';
196
+ return 'planned';
197
+ }
198
+ /**
199
+ * Derived, never stored: a group's aggregate status. Any regressed member
200
+ * cracks the group; else any spinner spins it; else all-done (non-empty)
201
+ * completes it; anything else is planned.
202
+ */
203
+ export function groupStatus(map, id) {
204
+ return aggregateStatus(map.nodes.filter((n) => n.group === id));
205
+ }
206
+ /** Derived, never stored: the whole map's aggregate status (same rules as groupStatus). */
207
+ export function mapStatus(map) {
208
+ return aggregateStatus(map.nodes);
209
+ }
210
+ /**
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).
214
+ */
215
+ export function declareNode(map, input) {
216
+ if (findNode(map, input.id))
217
+ return err({ kind: 'duplicate-node', id: input.id });
218
+ const collision = checkIdSpace(map, input.id, 'node');
219
+ if (collision)
220
+ return err(collision);
221
+ if (!findLayer(map, input.layer))
222
+ return err({ kind: 'unknown-layer', id: input.layer });
223
+ if (input.group !== undefined) {
224
+ const bad = checkMembership(map, input.id, input.layer, input.group);
225
+ if (bad)
226
+ return err(bad);
227
+ }
228
+ if (input.lane !== undefined && !findLane(map, input.lane))
229
+ return err({ kind: 'unknown-lane', id: input.lane });
230
+ const node = {
231
+ id: input.id,
232
+ label: input.label,
233
+ layer: input.layer,
234
+ status: input.status ?? 'planned',
235
+ ...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
236
+ ...(input.detail !== undefined ? { detail: input.detail } : {}),
237
+ ...(input.group !== undefined ? { group: input.group } : {}),
238
+ ...(input.kind !== undefined ? { kind: input.kind } : {}),
239
+ ...(input.lane !== undefined ? { lane: input.lane } : {}),
240
+ ...(input.submap !== undefined ? { submap: input.submap } : {}),
241
+ };
242
+ return ok({ ...map, nodes: [...map.nodes, node] });
243
+ }
244
+ /**
245
+ * Add the dependency edge `from USES to`, optionally labeled with what flows
246
+ * along it. Refuses self-edges, duplicates and any edge that does not point
247
+ * strictly downward (I4).
248
+ */
249
+ export function linkNodes(map, from, to, label) {
250
+ if (from === to)
251
+ return err({ kind: 'self-edge', id: from });
252
+ const fromNode = findNode(map, from);
253
+ if (!fromNode)
254
+ return err({ kind: 'unknown-node', id: from });
255
+ const toNode = findNode(map, to);
256
+ if (!toNode)
257
+ return err({ kind: 'unknown-node', id: to });
258
+ if (hasEdge(map, from, to))
259
+ return err({ kind: 'duplicate-edge', from, to });
260
+ // Layers are guaranteed to exist for stored nodes (I2), so the lookups cannot miss.
261
+ const fromRank = findLayer(map, fromNode.layer).rank;
262
+ const toRank = findLayer(map, toNode.layer).rank;
263
+ if (fromRank <= toRank)
264
+ return err({ kind: 'edge-not-downward', from, fromRank, to, toRank });
265
+ return ok({ ...map, edges: [...map.edges, { from, to, ...(label !== undefined ? { label } : {}) }] });
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
+ }
274
+ /**
275
+ * Update a node's status, label, evidence, design detail, group membership,
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.
281
+ */
282
+ export function updateNode(map, input) {
283
+ const node = findNode(map, input.id);
284
+ if (!node)
285
+ return err({ kind: 'unknown-node', id: input.id });
286
+ if (input.group !== undefined && input.group !== null) {
287
+ const bad = checkMembership(map, node.id, node.layer, input.group);
288
+ if (bad)
289
+ return err(bad);
290
+ }
291
+ if (input.lane !== undefined && input.lane !== null && !findLane(map, input.lane)) {
292
+ return err({ kind: 'unknown-lane', id: input.lane });
293
+ }
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);
301
+ const updated = {
302
+ ...bare,
303
+ ...(nextEvidence !== undefined ? { evidence: nextEvidence } : {}),
304
+ ...(nextDetail !== undefined ? { detail: nextDetail } : {}),
305
+ ...(nextGroup !== undefined ? { group: nextGroup } : {}),
306
+ ...(nextKind !== undefined ? { kind: nextKind } : {}),
307
+ ...(nextLane !== undefined ? { lane: nextLane } : {}),
308
+ ...(nextSubmap !== undefined ? { submap: nextSubmap } : {}),
309
+ ...(input.status !== undefined ? { status: input.status } : {}),
310
+ ...(input.label !== undefined ? { label: input.label } : {}),
311
+ };
312
+ return ok({ ...map, nodes: map.nodes.map((n) => (n.id === input.id ? updated : n)) });
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
+ }
353
+ /**
354
+ * Remove a node.
355
+ * Postcondition (explicit part of this contract): every edge touching the
356
+ * node is removed with it — a map never holds edges to missing nodes.
357
+ */
358
+ export function removeNode(map, id) {
359
+ if (!findNode(map, id))
360
+ return err({ kind: 'unknown-node', id });
361
+ return ok({
362
+ ...map,
363
+ nodes: map.nodes.filter((n) => n.id !== id),
364
+ edges: map.edges.filter((e) => e.from !== id && e.to !== id),
365
+ });
366
+ }
367
+ /** Remove one dependency edge. */
368
+ export function removeEdge(map, from, to) {
369
+ if (!hasEdge(map, from, to))
370
+ return err({ kind: 'unknown-edge', from, to });
371
+ return ok({ ...map, edges: map.edges.filter((e) => !(e.from === from && e.to === to)) });
372
+ }
373
+ /** Remove a band. Only empty bands may go — neither a node (I2) nor a group (I6) may be orphaned. */
374
+ export function removeLayer(map, id) {
375
+ if (!findLayer(map, id))
376
+ return err({ kind: 'unknown-layer', id });
377
+ const occupant = map.nodes.find((n) => n.layer === id);
378
+ if (occupant)
379
+ return err({ kind: 'layer-not-empty', id, occupant: occupant.id });
380
+ const groupOccupant = map.groups.find((g) => g.layer === id);
381
+ if (groupOccupant)
382
+ return err({ kind: 'layer-holds-group', id, occupant: groupOccupant.id });
383
+ return ok({ ...map, layers: map.layers.filter((l) => l.id !== id) });
384
+ }