@arcote.tech/arc-map 0.7.28 → 0.7.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -6
- package/src/app/nodes.tsx +0 -247
- package/src/app/theme.ts +1 -0
- package/src/app/usecase-view.tsx +64 -144
- package/src/catalog.ts +0 -0
- package/src/index.ts +7 -2
- package/src/mapper.ts +78 -0
- package/src/module-index.ts +1 -1
- package/src/scan.ts +1 -1
- package/src/server.ts +48 -1
- package/src/types.ts +39 -0
- package/tests/catalog.test.ts +67 -0
- package/src/app/arc.tsx +0 -92
- package/src/app/contexts.tsx +0 -72
- package/src/app/mdx-view.tsx +0 -74
- package/src/app/ref-context.tsx +0 -49
- package/src/app/structure.tsx +0 -95
- package/src/app/subgraph.ts +0 -127
- package/src/app/usecase-layout.ts +0 -249
- package/src/app/usecase-model.ts +0 -346
- package/src/refs.ts +0 -121
- package/tests/refs.test.ts +0 -56
- package/tests/subgraph.test.ts +0 -54
- package/tests/usecase-model.test.ts +0 -197
package/src/app/usecase-model.ts
DELETED
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Use-case data-flow model. Turns the ordered `<Arc>` occurrences + the full
|
|
3
|
-
* map into a per-step model:
|
|
4
|
-
*
|
|
5
|
-
* - steps: one node PER occurrence (no dedup); existing or missing.
|
|
6
|
-
* - connectors: elements on the shortest undirected path between two existing
|
|
7
|
-
* steps (the plumbing the author didn't narrate).
|
|
8
|
-
* - per step: related elements that are NOT already visible, grouped by relation
|
|
9
|
-
* kind, ready to be collapsed into badges / expanded into columns.
|
|
10
|
-
*
|
|
11
|
-
* Pure and dependency-free (testable in bun).
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { MapEdge, MapElement, MapJson } from "../types";
|
|
15
|
-
import type { ResolvedRef } from "../refs";
|
|
16
|
-
import type { PathSeg } from "./contexts";
|
|
17
|
-
|
|
18
|
-
export const DEFAULT_MAX_PATH = 5;
|
|
19
|
-
|
|
20
|
-
/** Relation kinds surfaced as badges / expandable columns. */
|
|
21
|
-
export type RelKind =
|
|
22
|
-
| "queries" // forward: this step reads X → left column
|
|
23
|
-
| "mutates" // forward: this step writes X → right column
|
|
24
|
-
| "mutatedBy" // reverse: X is written by others (producers) → left column (arrows in)
|
|
25
|
-
| "queriedBy" // reverse: X is read by others → left column
|
|
26
|
-
| "handledBy" // reverse: X (event) handled/observed by others → left column
|
|
27
|
-
| "usedByUI"; // reverse: React components/pages that use X (ts-morph) → left column
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Which side a relation expands to. Forward outputs (`mutates`) go right; every
|
|
31
|
-
* INCOMING relationship — what feeds the step (`queries`) and everyone who acts
|
|
32
|
-
* on / reads / observes / renders it (`mutatedBy`, `queriedBy`, `handledBy`,
|
|
33
|
-
* `usedByUI`) — goes left. Edge arrows keep their semantic direction regardless.
|
|
34
|
-
*/
|
|
35
|
-
export const REL_SIDE: Record<RelKind, "left" | "right"> = {
|
|
36
|
-
queries: "left",
|
|
37
|
-
queriedBy: "left",
|
|
38
|
-
mutatedBy: "left",
|
|
39
|
-
handledBy: "left",
|
|
40
|
-
usedByUI: "left",
|
|
41
|
-
mutates: "right",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export interface OccurrenceRef {
|
|
45
|
-
occId: string;
|
|
46
|
-
ref: ResolvedRef | null;
|
|
47
|
-
label: string;
|
|
48
|
-
/** Structural path (parallel/branch/lane/step) from the MDX. */
|
|
49
|
-
path?: PathSeg[];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface StepNode {
|
|
53
|
-
occId: string;
|
|
54
|
-
seq: number;
|
|
55
|
-
ref: ResolvedRef | null;
|
|
56
|
-
exists: boolean;
|
|
57
|
-
element?: MapElement;
|
|
58
|
-
label: string;
|
|
59
|
-
/** Method name actually used (aggregate mutationMethod/queryMethod), if any. */
|
|
60
|
-
method?: { name: string; kind: "mutation" | "query" };
|
|
61
|
-
/** Related elements NOT already visible, grouped by relation kind. */
|
|
62
|
-
badges: Partial<Record<RelKind, string[]>>;
|
|
63
|
-
/** Horizontal position (time / dependency depth), 0-based. */
|
|
64
|
-
depth: number;
|
|
65
|
-
/** Vertical track (parallel / branch lane), 0-based. */
|
|
66
|
-
lane: number;
|
|
67
|
-
/** True for branch (alternative / XOR) lanes. */
|
|
68
|
-
alt?: boolean;
|
|
69
|
-
/** Label of the enclosing `<Step>` phase, if any. */
|
|
70
|
-
stepTitle?: string;
|
|
71
|
-
/** Label of the enclosing `<Lane>`, if any. */
|
|
72
|
-
laneTitle?: string;
|
|
73
|
-
/** Id of the enclosing parallel/branch block, if any. */
|
|
74
|
-
blockId?: string;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface ConnectorNode {
|
|
78
|
-
id: string;
|
|
79
|
-
element: MapElement;
|
|
80
|
-
/** seq of the two narrated steps this connector sits between, flow-ordered
|
|
81
|
-
* [upstream → downstream]. */
|
|
82
|
-
between: [number, number];
|
|
83
|
-
/** 1-based position among the intermediates on the directed path. */
|
|
84
|
-
order: number;
|
|
85
|
-
/** Total intermediates on that path (for spreading between the two steps). */
|
|
86
|
-
count: number;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface UseCaseModel {
|
|
90
|
-
steps: StepNode[];
|
|
91
|
-
connectors: ConnectorNode[];
|
|
92
|
-
/** Visible element ids: existing step elements + connectors. */
|
|
93
|
-
visible: Set<string>;
|
|
94
|
-
/** element id -> seq of its first existing step occurrence. */
|
|
95
|
-
firstSeqByElement: Map<string, number>;
|
|
96
|
-
/** Pseudo-elements for React components/pages (UI layer) by id. */
|
|
97
|
-
uiById: Map<string, MapElement>;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ---------------------------------------------------------------------------
|
|
101
|
-
// Graph helpers (directed flow adjacency + BFS shortest path)
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Directed left→right flow adjacency: each edge points producer→consumer, so a
|
|
106
|
-
* BFS path follows the actual data flow.
|
|
107
|
-
* - mutates/emits: source produces target → source → target
|
|
108
|
-
* - queries/listens/handles: source consumes target → target → source
|
|
109
|
-
* Mirrors the layout's INPUT/OUTPUT edge orientation, so a connector found here
|
|
110
|
-
* lies on the same flow the diagram draws — i.e. genuinely BETWEEN the two
|
|
111
|
-
* narrated steps, never a shared upstream/downstream they both happen to touch.
|
|
112
|
-
*/
|
|
113
|
-
function flowAdjacency(edges: MapEdge[]): Map<string, Set<string>> {
|
|
114
|
-
const adj = new Map<string, Set<string>>();
|
|
115
|
-
const link = (a: string, b: string) => {
|
|
116
|
-
let s = adj.get(a);
|
|
117
|
-
if (!s) adj.set(a, (s = new Set()));
|
|
118
|
-
s.add(b);
|
|
119
|
-
};
|
|
120
|
-
for (const e of edges) {
|
|
121
|
-
if (e.kind === "mutates" || e.kind === "emits") link(e.source, e.target);
|
|
122
|
-
else link(e.target, e.source);
|
|
123
|
-
}
|
|
124
|
-
return adj;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function shortestPath(
|
|
128
|
-
adj: Map<string, Set<string>>,
|
|
129
|
-
start: string,
|
|
130
|
-
goal: string,
|
|
131
|
-
maxLen: number,
|
|
132
|
-
): string[] | null {
|
|
133
|
-
if (start === goal) return [start];
|
|
134
|
-
const prev = new Map<string, string>();
|
|
135
|
-
const depth = new Map<string, number>([[start, 0]]);
|
|
136
|
-
const queue = [start];
|
|
137
|
-
while (queue.length) {
|
|
138
|
-
const cur = queue.shift()!;
|
|
139
|
-
const d = depth.get(cur)!;
|
|
140
|
-
if (d >= maxLen) continue;
|
|
141
|
-
for (const nb of adj.get(cur) ?? []) {
|
|
142
|
-
if (depth.has(nb)) continue;
|
|
143
|
-
depth.set(nb, d + 1);
|
|
144
|
-
prev.set(nb, cur);
|
|
145
|
-
if (nb === goal) {
|
|
146
|
-
const path = [goal];
|
|
147
|
-
let p = cur;
|
|
148
|
-
while (p !== start) {
|
|
149
|
-
path.push(p);
|
|
150
|
-
p = prev.get(p)!;
|
|
151
|
-
}
|
|
152
|
-
path.push(start);
|
|
153
|
-
return path.reverse();
|
|
154
|
-
}
|
|
155
|
-
queue.push(nb);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
// Reverse relation index
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
function reverseSources(edges: MapEdge[], target: string, kinds: MapEdge["kind"][]): string[] {
|
|
166
|
-
const out = new Set<string>();
|
|
167
|
-
for (const e of edges) {
|
|
168
|
-
if (e.target === target && kinds.includes(e.kind)) out.add(e.source);
|
|
169
|
-
}
|
|
170
|
-
return [...out];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function forwardDeps(step: StepNode): { queries: string[]; mutates: string[] } {
|
|
174
|
-
const el = step.element;
|
|
175
|
-
if (!el) return { queries: [], mutates: [] };
|
|
176
|
-
if (step.method) {
|
|
177
|
-
const m = el.methods?.find((x) => x.name === step.method!.name);
|
|
178
|
-
return { queries: m?.queryDeps ?? [], mutates: m?.mutationDeps ?? [] };
|
|
179
|
-
}
|
|
180
|
-
return { queries: el.queryDeps ?? [], mutates: el.mutationDeps ?? [] };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ---------------------------------------------------------------------------
|
|
184
|
-
// Public builder
|
|
185
|
-
// ---------------------------------------------------------------------------
|
|
186
|
-
|
|
187
|
-
export interface BuildModelInput {
|
|
188
|
-
map: MapJson;
|
|
189
|
-
occurrences: OccurrenceRef[];
|
|
190
|
-
maxPath?: number;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Assign depth (time, x) and lane (parallel/branch track, y) from the structural
|
|
195
|
-
* paths. Plain steps flow as a sequence; a `<Parallel>`/`<Branch>` block places
|
|
196
|
-
* its members at the same base depth on separate lanes (sequences within a lane
|
|
197
|
-
* extend the depth). The whole block occupies one slot of the outer sequence.
|
|
198
|
-
*/
|
|
199
|
-
function assignDepthLane(steps: StepNode[], occurrences: OccurrenceRef[]): void {
|
|
200
|
-
let depthCursor = 0;
|
|
201
|
-
let block: { id: string; base: number; maxLocal: number; laneDepth: Map<number, number> } | null = null;
|
|
202
|
-
const finalize = () => {
|
|
203
|
-
if (block) {
|
|
204
|
-
depthCursor = block.base + block.maxLocal + 1;
|
|
205
|
-
block = null;
|
|
206
|
-
}
|
|
207
|
-
};
|
|
208
|
-
steps.forEach((step, i) => {
|
|
209
|
-
const path = occurrences[i].path ?? [];
|
|
210
|
-
const P = path.find((s) => s.type === "parallel" || s.type === "branch");
|
|
211
|
-
if (!P) {
|
|
212
|
-
finalize();
|
|
213
|
-
step.depth = depthCursor;
|
|
214
|
-
step.lane = 0;
|
|
215
|
-
depthCursor += 1;
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (!block || block.id !== P.id) {
|
|
219
|
-
finalize();
|
|
220
|
-
block = { id: P.id, base: depthCursor, maxLocal: 0, laneDepth: new Map() };
|
|
221
|
-
}
|
|
222
|
-
const lane = P.laneIndex ?? 0;
|
|
223
|
-
const local = block.laneDepth.get(lane) ?? 0;
|
|
224
|
-
step.depth = block.base + local;
|
|
225
|
-
step.lane = lane;
|
|
226
|
-
step.alt = P.type === "branch";
|
|
227
|
-
step.blockId = P.id;
|
|
228
|
-
block.laneDepth.set(lane, local + 1);
|
|
229
|
-
block.maxLocal = Math.max(block.maxLocal, local);
|
|
230
|
-
});
|
|
231
|
-
finalize();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function buildUseCaseModel(input: BuildModelInput): UseCaseModel {
|
|
235
|
-
const { map, occurrences, maxPath = DEFAULT_MAX_PATH } = input;
|
|
236
|
-
const elementById = new Map(map.elements.map((e) => [e.id, e]));
|
|
237
|
-
|
|
238
|
-
// Pseudo-elements for React components (ts-morph UI layer). Referenced via
|
|
239
|
-
// `<Arc component>` and surfaced as the `usedByUI` badge.
|
|
240
|
-
const uiById = new Map<string, MapElement>();
|
|
241
|
-
for (const c of map.components) {
|
|
242
|
-
uiById.set(c.id, { id: c.id, name: c.name ?? c.file, kind: "page", spaceId: "ui" });
|
|
243
|
-
}
|
|
244
|
-
const resolve = (id: string) => elementById.get(id) ?? uiById.get(id);
|
|
245
|
-
|
|
246
|
-
// 1) Steps — one per occurrence.
|
|
247
|
-
const steps: StepNode[] = occurrences.map((o, i) => {
|
|
248
|
-
const exists = !!o.ref && !!resolve(o.ref.nodeId);
|
|
249
|
-
const path = o.path ?? [];
|
|
250
|
-
return {
|
|
251
|
-
occId: o.occId,
|
|
252
|
-
seq: i + 1,
|
|
253
|
-
ref: o.ref,
|
|
254
|
-
exists,
|
|
255
|
-
element: exists ? resolve(o.ref!.nodeId) : undefined,
|
|
256
|
-
label: o.label,
|
|
257
|
-
method: o.ref?.method,
|
|
258
|
-
badges: {},
|
|
259
|
-
depth: 0,
|
|
260
|
-
lane: 0,
|
|
261
|
-
stepTitle: path.find((s) => s.type === "step")?.title,
|
|
262
|
-
laneTitle: path.find((s) => s.type === "lane")?.title,
|
|
263
|
-
};
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
assignDepthLane(steps, occurrences);
|
|
267
|
-
|
|
268
|
-
// First occurrence seq per existing element id.
|
|
269
|
-
const firstSeqByElement = new Map<string, number>();
|
|
270
|
-
for (const s of steps) {
|
|
271
|
-
if (s.exists && !firstSeqByElement.has(s.element!.id)) {
|
|
272
|
-
firstSeqByElement.set(s.element!.id, s.seq);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
const stepElementIds = [...firstSeqByElement.keys()];
|
|
276
|
-
|
|
277
|
-
// 2) Connectors — elements on a DIRECTED flow path between two narrated steps.
|
|
278
|
-
// Following flow direction (not undirected) guarantees the connector sits
|
|
279
|
-
// BETWEEN the two steps left→right, never a node that's upstream of both.
|
|
280
|
-
const flowAdj = flowAdjacency(map.edges);
|
|
281
|
-
const connectorMap = new Map<string, ConnectorNode>();
|
|
282
|
-
for (let i = 0; i < stepElementIds.length; i++) {
|
|
283
|
-
for (let j = i + 1; j < stepElementIds.length; j++) {
|
|
284
|
-
const a = stepElementIds[i];
|
|
285
|
-
const b = stepElementIds[j];
|
|
286
|
-
// Try both flow orientations; keep the first directed path found.
|
|
287
|
-
let path = shortestPath(flowAdj, a, b, maxPath);
|
|
288
|
-
let from = a;
|
|
289
|
-
let to = b;
|
|
290
|
-
if (!path) {
|
|
291
|
-
path = shortestPath(flowAdj, b, a, maxPath);
|
|
292
|
-
from = b;
|
|
293
|
-
to = a;
|
|
294
|
-
}
|
|
295
|
-
if (!path) continue;
|
|
296
|
-
// Intermediates only: drop the endpoints and any other narrated step that
|
|
297
|
-
// happens to fall on the path (those render as steps, not connectors).
|
|
298
|
-
const intermediates = path.filter((id) => !firstSeqByElement.has(id));
|
|
299
|
-
const between: [number, number] = [
|
|
300
|
-
firstSeqByElement.get(from)!,
|
|
301
|
-
firstSeqByElement.get(to)!,
|
|
302
|
-
];
|
|
303
|
-
intermediates.forEach((id, k) => {
|
|
304
|
-
const el = elementById.get(id);
|
|
305
|
-
if (!el || connectorMap.has(id)) return;
|
|
306
|
-
connectorMap.set(id, {
|
|
307
|
-
id,
|
|
308
|
-
element: el,
|
|
309
|
-
between,
|
|
310
|
-
order: k + 1,
|
|
311
|
-
count: intermediates.length,
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
const connectors = [...connectorMap.values()];
|
|
317
|
-
|
|
318
|
-
// 3) Visible set.
|
|
319
|
-
const visible = new Set<string>([...stepElementIds, ...connectorMap.keys()]);
|
|
320
|
-
|
|
321
|
-
// 4) Per-step badges — related elements NOT visible, grouped by relation kind.
|
|
322
|
-
for (const s of steps) {
|
|
323
|
-
if (!s.exists) continue;
|
|
324
|
-
const id = s.element!.id;
|
|
325
|
-
const fwd = forwardDeps(s);
|
|
326
|
-
const usedByUI = map.componentEdges.filter((e) => e.target === id).map((e) => e.source);
|
|
327
|
-
const groups: Partial<Record<RelKind, string[]>> = {
|
|
328
|
-
queries: fwd.queries,
|
|
329
|
-
mutates: fwd.mutates,
|
|
330
|
-
mutatedBy: reverseSources(map.edges, id, ["mutates", "emits"]),
|
|
331
|
-
queriedBy: reverseSources(map.edges, id, ["queries"]),
|
|
332
|
-
handledBy: reverseSources(map.edges, id, ["handles", "listens"]),
|
|
333
|
-
usedByUI,
|
|
334
|
-
};
|
|
335
|
-
const badges: Partial<Record<RelKind, string[]>> = {};
|
|
336
|
-
for (const [kind, list] of Object.entries(groups) as [RelKind, string[]][]) {
|
|
337
|
-
// UI badge targets live in uiById; the rest are context elements.
|
|
338
|
-
const known = kind === "usedByUI" ? uiById : elementById;
|
|
339
|
-
const filtered = [...new Set(list)].filter((t) => t !== id && !visible.has(t) && known.has(t));
|
|
340
|
-
if (filtered.length) badges[kind] = filtered;
|
|
341
|
-
}
|
|
342
|
-
s.badges = badges;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return { steps, connectors, visible, firstSeqByElement, uiById };
|
|
346
|
-
}
|
package/src/refs.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolution of `<Arc>` use-case references to map nodes.
|
|
3
|
-
*
|
|
4
|
-
* Dependency-free (no React) so it is unit-testable and importable by both the
|
|
5
|
-
* `<Arc>` component and the subgraph builder. The `<Arc>` props are a typed
|
|
6
|
-
* union per element kind; `resolveRef` maps them to a target node id (an
|
|
7
|
-
* element name present in `MapJson.elements`) plus an optional aggregate method.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Props accepted by the `<Arc>` component (one kind per element). */
|
|
11
|
-
export interface ArcRefProps {
|
|
12
|
-
command?: string;
|
|
13
|
-
event?: string;
|
|
14
|
-
view?: string;
|
|
15
|
-
staticView?: string;
|
|
16
|
-
listener?: string;
|
|
17
|
-
route?: string;
|
|
18
|
-
aggregate?: string;
|
|
19
|
-
/** With `aggregate`: target a mutation method. */
|
|
20
|
-
mutationMethod?: string;
|
|
21
|
-
/** With `aggregate`: target a query method. */
|
|
22
|
-
queryMethod?: string;
|
|
23
|
-
/** A registered page (arc `page()`), referenced by its route path. */
|
|
24
|
-
page?: string;
|
|
25
|
-
/** A React component / UI file (matched against the ts-morph scan). */
|
|
26
|
-
component?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type ArcRefKind =
|
|
30
|
-
| "command"
|
|
31
|
-
| "event"
|
|
32
|
-
| "view"
|
|
33
|
-
| "static-view"
|
|
34
|
-
| "listener"
|
|
35
|
-
| "route"
|
|
36
|
-
| "aggregate"
|
|
37
|
-
| "page";
|
|
38
|
-
|
|
39
|
-
export interface ResolvedRef {
|
|
40
|
-
/** Target node id — an element name in MapJson. */
|
|
41
|
-
nodeId: string;
|
|
42
|
-
kind: ArcRefKind;
|
|
43
|
-
/** Aggregate method, when the ref targets one. */
|
|
44
|
-
method?: { name: string; kind: "mutation" | "query" };
|
|
45
|
-
/** Canonical key for dedup / chip<->node linking. */
|
|
46
|
-
key: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Canonical page identity = its route PATH, stripped of query/hash and any
|
|
51
|
-
* trailing slash (root "/" kept). Pages are identified by path, never by the
|
|
52
|
-
* module's page key — the key (`{ checkout: page("/checkout", …) }`) is a
|
|
53
|
-
* module-local name absent from the fragment, while the path is the stable
|
|
54
|
-
* identity `buildPageNodes` registers nodes under. Normalizing here AND in
|
|
55
|
-
* buildPageNodes guarantees `<Arc page="/checkout">` and a node registered as
|
|
56
|
-
* `/checkout` collapse to the same node regardless of incidental `?query`.
|
|
57
|
-
*/
|
|
58
|
-
export function normalizePagePath(path: string): string {
|
|
59
|
-
let p = path.split("?")[0].split("#")[0].trim();
|
|
60
|
-
if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
|
|
61
|
-
return p;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Map `<Arc>` props to a resolved reference, or null if no recognised kind is
|
|
66
|
-
* present. Resolution is purely structural — whether the node actually exists
|
|
67
|
-
* is validated separately against MapJson (front-side red-border).
|
|
68
|
-
*/
|
|
69
|
-
export function resolveRef(props: ArcRefProps): ResolvedRef | null {
|
|
70
|
-
if (props.command) {
|
|
71
|
-
return { nodeId: props.command, kind: "command", key: `command:${props.command}` };
|
|
72
|
-
}
|
|
73
|
-
if (props.view) {
|
|
74
|
-
return { nodeId: props.view, kind: "view", key: `view:${props.view}` };
|
|
75
|
-
}
|
|
76
|
-
if (props.staticView) {
|
|
77
|
-
return { nodeId: props.staticView, kind: "static-view", key: `static-view:${props.staticView}` };
|
|
78
|
-
}
|
|
79
|
-
if (props.listener) {
|
|
80
|
-
return { nodeId: props.listener, kind: "listener", key: `listener:${props.listener}` };
|
|
81
|
-
}
|
|
82
|
-
if (props.route) {
|
|
83
|
-
return { nodeId: props.route, kind: "route", key: `route:${props.route}` };
|
|
84
|
-
}
|
|
85
|
-
if (props.page) {
|
|
86
|
-
const path = normalizePagePath(props.page);
|
|
87
|
-
return { nodeId: path, kind: "page", key: `page:${path}` };
|
|
88
|
-
}
|
|
89
|
-
if (props.component) {
|
|
90
|
-
return { nodeId: props.component, kind: "page", key: `component:${props.component}` };
|
|
91
|
-
}
|
|
92
|
-
if (props.aggregate) {
|
|
93
|
-
if (props.mutationMethod) {
|
|
94
|
-
return {
|
|
95
|
-
nodeId: props.aggregate,
|
|
96
|
-
kind: "aggregate",
|
|
97
|
-
method: { name: props.mutationMethod, kind: "mutation" },
|
|
98
|
-
key: `aggregate:${props.aggregate}#mutate:${props.mutationMethod}`,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
if (props.queryMethod) {
|
|
102
|
-
return {
|
|
103
|
-
nodeId: props.aggregate,
|
|
104
|
-
kind: "aggregate",
|
|
105
|
-
method: { name: props.queryMethod, kind: "query" },
|
|
106
|
-
key: `aggregate:${props.aggregate}#query:${props.queryMethod}`,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
// `<Arc aggregate="x" event="e">` — the inline event is a node of its own
|
|
110
|
-
// (the mapper synthesises event nodes for aggregate events).
|
|
111
|
-
if (props.event) {
|
|
112
|
-
return { nodeId: props.event, kind: "event", key: `event:${props.event}` };
|
|
113
|
-
}
|
|
114
|
-
return { nodeId: props.aggregate, kind: "aggregate", key: `aggregate:${props.aggregate}` };
|
|
115
|
-
}
|
|
116
|
-
// Standalone event (no aggregate).
|
|
117
|
-
if (props.event) {
|
|
118
|
-
return { nodeId: props.event, kind: "event", key: `event:${props.event}` };
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
package/tests/refs.test.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { resolveRef, normalizePagePath } from "../src/refs";
|
|
3
|
-
|
|
4
|
-
describe("normalizePagePath", () => {
|
|
5
|
-
test("strips query, hash and trailing slash; keeps root", () => {
|
|
6
|
-
expect(normalizePagePath("/checkout/success?orderId=1")).toBe("/checkout/success");
|
|
7
|
-
expect(normalizePagePath("/credits#top")).toBe("/credits");
|
|
8
|
-
expect(normalizePagePath("/checkout/")).toBe("/checkout");
|
|
9
|
-
expect(normalizePagePath("/")).toBe("/");
|
|
10
|
-
});
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe("resolveRef", () => {
|
|
14
|
-
test("page → identified by normalized path (not module key)", () => {
|
|
15
|
-
expect(resolveRef({ page: "/checkout/success?orderId=1" })).toMatchObject({
|
|
16
|
-
nodeId: "/checkout/success",
|
|
17
|
-
kind: "page",
|
|
18
|
-
key: "page:/checkout/success",
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
test("command / view / listener / route / event", () => {
|
|
24
|
-
expect(resolveRef({ command: "signIn" })).toMatchObject({ nodeId: "signIn", kind: "command", key: "command:signIn" });
|
|
25
|
-
expect(resolveRef({ view: "orders" })).toMatchObject({ nodeId: "orders", kind: "view" });
|
|
26
|
-
expect(resolveRef({ listener: "onX" })).toMatchObject({ nodeId: "onX", kind: "listener" });
|
|
27
|
-
expect(resolveRef({ route: "webhook" })).toMatchObject({ nodeId: "webhook", kind: "route" });
|
|
28
|
-
expect(resolveRef({ event: "taskCreated" })).toMatchObject({ nodeId: "taskCreated", kind: "event" });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("aggregate alone → aggregate node", () => {
|
|
32
|
-
expect(resolveRef({ aggregate: "tasks" })).toMatchObject({ nodeId: "tasks", kind: "aggregate", key: "aggregate:tasks" });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test("aggregate + mutationMethod → aggregate node + method", () => {
|
|
36
|
-
const r = resolveRef({ aggregate: "userAccounts", mutationMethod: "checkEmail" });
|
|
37
|
-
expect(r).toMatchObject({
|
|
38
|
-
nodeId: "userAccounts",
|
|
39
|
-
kind: "aggregate",
|
|
40
|
-
method: { name: "checkEmail", kind: "mutation" },
|
|
41
|
-
key: "aggregate:userAccounts#mutate:checkEmail",
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("aggregate + queryMethod → query method", () => {
|
|
46
|
-
expect(resolveRef({ aggregate: "tasks", queryMethod: "getAll" })?.method).toEqual({ name: "getAll", kind: "query" });
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("aggregate + event → the event node", () => {
|
|
50
|
-
expect(resolveRef({ aggregate: "tasks", event: "taskCreated" })).toMatchObject({ nodeId: "taskCreated", kind: "event" });
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("no recognised prop → null", () => {
|
|
54
|
-
expect(resolveRef({})).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
});
|
package/tests/subgraph.test.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { buildSubgraph, neighborsOf } from "../src/app/subgraph";
|
|
3
|
-
import type { MapEdge, MapJson } from "../src/types";
|
|
4
|
-
|
|
5
|
-
function el(id: string, kind: any = "unknown") {
|
|
6
|
-
return { id, name: id, kind, spaceId: "s" };
|
|
7
|
-
}
|
|
8
|
-
function edge(source: string, target: string, kind: MapEdge["kind"] = "mutates"): MapEdge {
|
|
9
|
-
return { id: `${source}->${target}`, source, target, kind, crossContext: false };
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// Graph: A(command) --mutates--> E(event) <--listens-- L(listener) --mutates--> E2(event) <--handles-- V(view)
|
|
13
|
-
// X is unrelated.
|
|
14
|
-
const map: MapJson = {
|
|
15
|
-
spaces: [{ id: "s", name: "s" }],
|
|
16
|
-
elements: [el("A", "command"), el("E", "event"), el("L", "listener"), el("E2", "event"), el("V", "view"), el("X")],
|
|
17
|
-
edges: [edge("A", "E"), edge("L", "E", "listens"), edge("L", "E2"), edge("V", "E2", "handles")],
|
|
18
|
-
components: [],
|
|
19
|
-
componentEdges: [],
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
describe("buildSubgraph", () => {
|
|
23
|
-
test("referenced + 1-hop + connecting path (undirected through plumbing)", () => {
|
|
24
|
-
const sub = buildSubgraph({ map, referenced: ["A", "V"] });
|
|
25
|
-
// A..V connects via A-E-L-E2-V (undirected) — all included; X excluded.
|
|
26
|
-
expect([...sub.nodeIds].sort()).toEqual(["A", "E", "E2", "L", "V"]);
|
|
27
|
-
expect(sub.referencedIds).toEqual(["A", "V"]);
|
|
28
|
-
expect(sub.edges.length).toBe(4);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("path cap drops too-long connectors", () => {
|
|
32
|
-
const sub = buildSubgraph({ map, referenced: ["A", "V"], maxPath: 2 });
|
|
33
|
-
// shortest A-V path is 4 hops > 2 → only referenced + their 1-hop neighbours.
|
|
34
|
-
expect(sub.nodeIds.has("A")).toBe(true);
|
|
35
|
-
expect(sub.nodeIds.has("E")).toBe(true); // 1-hop of A
|
|
36
|
-
expect(sub.nodeIds.has("E2")).toBe(true); // 1-hop of V
|
|
37
|
-
expect(sub.nodeIds.has("L")).toBe(false); // only reachable via the long path
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("unresolved references are filtered", () => {
|
|
41
|
-
const sub = buildSubgraph({ map, referenced: ["A", "ghost"] });
|
|
42
|
-
expect(sub.referencedIds).toEqual(["A"]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("expand adds a node and its neighbours", () => {
|
|
46
|
-
const sub = buildSubgraph({ map, referenced: ["A"], expanded: ["V"] });
|
|
47
|
-
expect(sub.nodeIds.has("V")).toBe(true);
|
|
48
|
-
expect(sub.nodeIds.has("E2")).toBe(true);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("neighborsOf is undirected", () => {
|
|
52
|
-
expect(neighborsOf(map, "E").sort()).toEqual(["A", "L"]);
|
|
53
|
-
});
|
|
54
|
-
});
|