bc-atlas 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/views.js ADDED
@@ -0,0 +1,674 @@
1
+ import { normalizeIdentifier } from "./resolver.js";
2
+ import { workflowView } from "./workflow.js";
3
+
4
+ function globToRegExp(pattern) {
5
+ const escaped = String(pattern)
6
+ .replace(/[.+^${}()|[\]\\]/gu, "\\$&")
7
+ .replaceAll("**", "\u0000")
8
+ .replaceAll("*", "[^/]*")
9
+ .replaceAll("\u0000", ".*")
10
+ .replaceAll("?", ".");
11
+ return new RegExp(`^${escaped}$`, "iu");
12
+ }
13
+
14
+ function matchesAny(value, patterns = []) {
15
+ return patterns.some((pattern) => globToRegExp(pattern).test(value));
16
+ }
17
+
18
+ export function filterModel(model, options = {}) {
19
+ const types = new Set(
20
+ (options.types ?? [])
21
+ .flatMap((value) => value.split(","))
22
+ .map((value) => value.toLowerCase())
23
+ .filter(Boolean)
24
+ );
25
+ const namespaces = options.namespaces ?? [];
26
+ const include = options.include ?? [];
27
+ const exclude = options.exclude ?? [];
28
+
29
+ const objects = model.objects.filter((object) => {
30
+ const searchable = `${object.type}:${object.id ?? ""}:${object.name}`;
31
+ if (types.size && !types.has(object.type)) return false;
32
+ if (namespaces.length && !matchesAny(object.namespace, namespaces)) return false;
33
+ const file = object.file.replaceAll("\\", "/");
34
+ if (include.length && !matchesAny(file, include) && !matchesAny(searchable, include)) {
35
+ return false;
36
+ }
37
+ return !matchesAny(file, exclude) && !matchesAny(searchable, exclude);
38
+ });
39
+ const keys = new Set(objects.map(({ key }) => key));
40
+ const edges = model.edges.filter((edge) => keys.has(edge.from) && (!edge.to || keys.has(edge.to)));
41
+ return { ...model, objects, edges };
42
+ }
43
+
44
+ function selectObject(model, selector) {
45
+ const normalized = normalizeIdentifier(selector);
46
+ const [kind, identity] = selector.includes(":")
47
+ ? selector.split(/:(.*)/su, 2)
48
+ : [undefined, selector];
49
+ const matches = model.objects.filter((object) => {
50
+ if (kind && object.type !== kind.toLowerCase()) return false;
51
+ return [object.id, object.name, object.key]
52
+ .filter(Boolean)
53
+ .some((value) => normalizeIdentifier(value) === normalizeIdentifier(identity ?? normalized));
54
+ });
55
+ if (matches.length !== 1) {
56
+ throw new Error(
57
+ matches.length
58
+ ? `Object selector "${selector}" is ambiguous (${matches.length} matches)`
59
+ : `Object selector "${selector}" did not match any object`
60
+ );
61
+ }
62
+ return matches[0];
63
+ }
64
+
65
+ function objectView(model, selector) {
66
+ if (!selector) throw new Error("--object is required for the object view");
67
+ const focus = selectObject(model, selector);
68
+ const keys = new Set([focus.key]);
69
+ for (const edge of model.edges) {
70
+ if (edge.from === focus.key && edge.to) keys.add(edge.to);
71
+ if (edge.to === focus.key) keys.add(edge.from);
72
+ }
73
+ return {
74
+ ...model,
75
+ titleSuffix: focus.name,
76
+ objects: model.objects
77
+ .filter(({ key }) => keys.has(key))
78
+ .map((object) => object.key === focus.key ? { ...object, isFocus: true } : object),
79
+ edges: model.edges.filter((edge) => keys.has(edge.from) && (!edge.to || keys.has(edge.to)))
80
+ };
81
+ }
82
+
83
+ function dataView(model) {
84
+ const dataTypes = new Set(["table", "tableextension", "query", "report", "xmlport", "page"]);
85
+ const relevantEdges = model.edges.filter(
86
+ (edge) => ["reads", "writes", "relates", "extends"].includes(edge.kind)
87
+ );
88
+ const relevantKeys = new Set();
89
+ for (const edge of relevantEdges) {
90
+ const source = model.objects.find(({ key }) => key === edge.from);
91
+ const target = model.objects.find(({ key }) => key === edge.to);
92
+ if (source && (dataTypes.has(source.type) || target?.type === "table")) relevantKeys.add(source.key);
93
+ if (target?.type === "table") relevantKeys.add(target.key);
94
+ }
95
+ return {
96
+ ...model,
97
+ objects: model.objects.filter(({ key }) => relevantKeys.has(key)),
98
+ edges: relevantEdges.filter(
99
+ (edge) => relevantKeys.has(edge.from) && (!edge.to || relevantKeys.has(edge.to))
100
+ )
101
+ };
102
+ }
103
+
104
+ function moduleName(namespace, depth) {
105
+ if (namespace === "(global)") return namespace;
106
+ return namespace.split(".").slice(0, depth).join(".");
107
+ }
108
+
109
+ function automaticModuleDepth(objects) {
110
+ const namespaces = objects
111
+ .map(({ namespace }) => namespace)
112
+ .filter((namespace) => namespace && namespace !== "(global)")
113
+ .map((namespace) => namespace.split("."));
114
+ if (!namespaces.length) return 1;
115
+ let common = 0;
116
+ const shortest = Math.min(...namespaces.map((segments) => segments.length));
117
+ while (
118
+ common < shortest &&
119
+ namespaces.every((segments) => segments[common] === namespaces[0][common])
120
+ ) {
121
+ common++;
122
+ }
123
+ const longest = Math.max(...namespaces.map((segments) => segments.length));
124
+ return Math.min(common + 1, longest);
125
+ }
126
+
127
+ function folderModuleName(file, depth = 1) {
128
+ const directory = file.replaceAll("\\", "/").split("/").slice(0, -1);
129
+ return directory.length ? directory.slice(0, depth).join("/") : "(root)";
130
+ }
131
+
132
+ function moduleView(model, options = {}) {
133
+ const groupBy = options.groupBy ?? "namespace";
134
+ const depth = options.moduleDepth === "auto" || options.moduleDepth === undefined
135
+ ? automaticModuleDepth(model.objects)
136
+ : Number(options.moduleDepth);
137
+ const folderDepth = Number(options.folderDepth ?? 1);
138
+ const modules = new Map();
139
+ const keyToModule = new Map();
140
+ for (const object of model.objects) {
141
+ const name = groupBy === "folder"
142
+ ? folderModuleName(object.file, folderDepth)
143
+ : moduleName(object.namespace, depth);
144
+ if (!modules.has(name)) {
145
+ modules.set(name, {
146
+ key: `module::${normalizeIdentifier(name)}`,
147
+ name,
148
+ type: "module",
149
+ namespace: "(modules)",
150
+ file: "",
151
+ relations: [],
152
+ members: []
153
+ });
154
+ }
155
+ modules.get(name).members.push(object.key);
156
+ keyToModule.set(object.key, modules.get(name).key);
157
+ }
158
+ const combined = new Map();
159
+ for (const edge of model.edges) {
160
+ if (!edge.to) continue;
161
+ const from = keyToModule.get(edge.from);
162
+ const to = keyToModule.get(edge.to);
163
+ if (!from || !to || from === to) continue;
164
+ const key = `${from}|${to}|${edge.kind}`;
165
+ const current = combined.get(key) ?? {
166
+ id: `m${combined.size}`,
167
+ from,
168
+ to,
169
+ kind: edge.kind,
170
+ confidence: "resolved",
171
+ weight: 0
172
+ };
173
+ current.weight++;
174
+ combined.set(key, current);
175
+ }
176
+ return { ...model, objects: [...modules.values()], edges: [...combined.values()] };
177
+ }
178
+
179
+ function callView(model, options = {}) {
180
+ const objects = [];
181
+ const edges = [];
182
+ const procedureIndex = new Map();
183
+ const objectIndex = new Map();
184
+
185
+ for (const owner of model.objects) {
186
+ objectIndex.set(`${owner.type}:${normalizeIdentifier(owner.name)}`, owner);
187
+ if (owner.id) objectIndex.set(`${owner.type}:${owner.id}`, owner);
188
+ for (const procedure of owner.procedures ?? []) {
189
+ const key = `${owner.key}::procedure::${normalizeIdentifier(procedure.name)}`;
190
+ const item = {
191
+ key,
192
+ name: procedure.name,
193
+ type: procedure.kind,
194
+ namespace: `${owner.namespace}.${owner.name}`,
195
+ file: owner.file,
196
+ location: procedure.location,
197
+ relations: []
198
+ };
199
+ objects.push(item);
200
+ procedureIndex.set(`${owner.key}:${normalizeIdentifier(procedure.name)}`, item);
201
+ }
202
+ }
203
+
204
+ for (const owner of model.objects) {
205
+ for (const procedure of owner.procedures ?? []) {
206
+ const from = procedureIndex.get(`${owner.key}:${normalizeIdentifier(procedure.name)}`);
207
+ if (!from) continue;
208
+ for (const call of procedure.calls ?? []) {
209
+ let targetOwner = owner;
210
+ if (call.receiver) {
211
+ const variable = (owner.variables ?? []).find(
212
+ ({ name }) => normalizeIdentifier(name) === normalizeIdentifier(call.receiver)
213
+ );
214
+ targetOwner = variable?.target
215
+ ? objectIndex.get(
216
+ `${variable.targetType}:${normalizeIdentifier(variable.target)}`
217
+ ) ?? objectIndex.get(`${variable.targetType}:${variable.target}`)
218
+ : objectIndex.get(`codeunit:${normalizeIdentifier(call.receiver)}`);
219
+ }
220
+ const to = targetOwner
221
+ ? procedureIndex.get(`${targetOwner.key}:${normalizeIdentifier(call.name)}`)
222
+ : undefined;
223
+ edges.push({
224
+ id: `c${edges.length}`,
225
+ from: from.key,
226
+ to: to?.key,
227
+ unresolved: to ? undefined : {
228
+ name: call.receiver ? `${call.receiver}.${call.name}` : call.name,
229
+ type: "procedure"
230
+ },
231
+ kind: "calls",
232
+ confidence: to ? "resolved" : "syntactic",
233
+ location: call.location
234
+ });
235
+ }
236
+ for (const attribute of procedure.attributes ?? []) {
237
+ const subscriber = attribute.match(
238
+ /EventSubscriber\s*\(\s*ObjectType::(\w+)\s*,\s*(?:\w+::)?(?:"([^"]+)"|([^,\s]+))\s*,\s*'([^']+)'/iu
239
+ );
240
+ if (!subscriber) continue;
241
+ const targetOwner = objectIndex.get(
242
+ `${subscriber[1].toLowerCase()}:${normalizeIdentifier(subscriber[2] ?? subscriber[3])}`
243
+ );
244
+ const to = targetOwner
245
+ ? procedureIndex.get(`${targetOwner.key}:${normalizeIdentifier(subscriber[4])}`)
246
+ : undefined;
247
+ edges.push({
248
+ id: `s${edges.length}`,
249
+ from: from.key,
250
+ to: to?.key,
251
+ unresolved: to ? undefined : {
252
+ name: `${subscriber[2] ?? subscriber[3]}.${subscriber[4]}`,
253
+ type: "event"
254
+ },
255
+ kind: "subscribes",
256
+ confidence: to ? "resolved" : "syntactic",
257
+ location: procedure.location
258
+ });
259
+ }
260
+ }
261
+ }
262
+ if (options.includeUnresolvedCalls) return { ...model, objects, edges };
263
+ const resolvedEdges = edges.filter(({ to }) => to);
264
+ const connected = new Set(
265
+ resolvedEdges.flatMap(({ from, to }) => [from, to])
266
+ );
267
+ return {
268
+ ...model,
269
+ objects: objects.filter(({ key }) => connected.has(key)),
270
+ edges: resolvedEdges
271
+ };
272
+ }
273
+
274
+ function asArray(value) {
275
+ return value === undefined ? [] : Array.isArray(value) ? value : [value];
276
+ }
277
+
278
+ function connectedProjection(model, seedKeys) {
279
+ const keys = new Set(seedKeys);
280
+ let changed = true;
281
+ while (changed) {
282
+ changed = false;
283
+ for (const edge of model.edges) {
284
+ if (keys.has(edge.from) && edge.to && !keys.has(edge.to)) {
285
+ keys.add(edge.to);
286
+ changed = true;
287
+ } else if (edge.to && keys.has(edge.to) && !keys.has(edge.from)) {
288
+ keys.add(edge.from);
289
+ changed = true;
290
+ }
291
+ }
292
+ }
293
+ return {
294
+ ...model,
295
+ objects: model.objects.filter(({ key }) => keys.has(key)),
296
+ edges: model.edges.filter(
297
+ ({ from, to }) => keys.has(from) && (!to || keys.has(to))
298
+ )
299
+ };
300
+ }
301
+
302
+ function focusSeeds(model, focus) {
303
+ if (!focus) return [];
304
+ const wanted = normalizeIdentifier(focus);
305
+ return model.objects
306
+ .filter((object) =>
307
+ [object.name, object.id, object.ownerName, object.eventName]
308
+ .filter(Boolean)
309
+ .some((value) => normalizeIdentifier(value).includes(wanted))
310
+ )
311
+ .map(({ key }) => key);
312
+ }
313
+
314
+ function scopeMatches(object, scope) {
315
+ const separator = scope.indexOf(":");
316
+ if (separator < 1) {
317
+ throw new Error(
318
+ `Invalid boundary scope "${scope}"; use namespace:, folder:, app:, or object:`
319
+ );
320
+ }
321
+ const kind = scope.slice(0, separator).toLowerCase();
322
+ const value = scope.slice(separator + 1);
323
+ if (!value) throw new Error(`Boundary scope "${scope}" has no value`);
324
+ if (kind === "namespace") {
325
+ return value.includes("*") || value.includes("?")
326
+ ? matchesAny(object.namespace, [value])
327
+ : object.namespace === value || object.namespace.startsWith(`${value}.`);
328
+ }
329
+ if (kind === "folder") {
330
+ const file = object.file.replaceAll("\\", "/");
331
+ const folder = value.replaceAll("\\", "/").replace(/^\.?\//u, "").replace(/\/$/u, "");
332
+ return folder.includes("*") || folder.includes("?")
333
+ ? matchesAny(file, [folder, `${folder}/**`])
334
+ : file === folder || file.startsWith(`${folder}/`);
335
+ }
336
+ if (kind === "app" || kind === "application") {
337
+ const wanted = normalizeIdentifier(value);
338
+ return [object.app?.id, object.app?.name, object.app?.publisher]
339
+ .filter(Boolean)
340
+ .some((candidate) => normalizeIdentifier(candidate) === wanted);
341
+ }
342
+ if (kind === "object") {
343
+ const [type, identity] = value.includes(":")
344
+ ? value.split(/:(.*)/su, 2)
345
+ : [undefined, value];
346
+ if (type && object.type !== type.toLowerCase()) return false;
347
+ const wanted = normalizeIdentifier(identity);
348
+ return [object.id, object.name, object.key]
349
+ .filter(Boolean)
350
+ .some((candidate) => normalizeIdentifier(candidate) === wanted);
351
+ }
352
+ throw new Error(
353
+ `Unsupported boundary scope kind "${kind}"; use namespace, folder, app, or object`
354
+ );
355
+ }
356
+
357
+ function boundaryView(model, options = {}) {
358
+ const scopes = asArray(options.scope);
359
+ if (!scopes.length) {
360
+ throw new Error(
361
+ "--scope is required for the boundary view (namespace:, folder:, app:, or object:)"
362
+ );
363
+ }
364
+ const inside = new Set(
365
+ model.objects
366
+ .filter((object) => scopes.some((scope) => scopeMatches(object, scope)))
367
+ .map(({ key }) => key)
368
+ );
369
+ if (!inside.size) {
370
+ throw new Error(`Boundary scope did not match any object: ${scopes.join(", ")}`);
371
+ }
372
+
373
+ const edges = model.edges
374
+ .filter((edge) =>
375
+ edge.to
376
+ ? inside.has(edge.from) !== inside.has(edge.to)
377
+ : inside.has(edge.from)
378
+ )
379
+ .map((edge) => ({
380
+ ...edge,
381
+ label: `${edge.kind} ${inside.has(edge.from) ? "outbound" : "inbound"}`
382
+ }));
383
+ const keys = new Set(
384
+ edges.flatMap(({ from, to }) => [from, to].filter(Boolean))
385
+ );
386
+ return {
387
+ ...model,
388
+ titleSuffix: scopes.join(", "),
389
+ objects: model.objects
390
+ .filter(({ key }) => keys.has(key))
391
+ .map((object) => ({
392
+ ...object,
393
+ viewGroup: inside.has(object.key) ? "Inside boundary" : "Outside boundary",
394
+ isFocus: inside.has(object.key)
395
+ })),
396
+ edges
397
+ };
398
+ }
399
+
400
+ function contractsView(model, options = {}) {
401
+ const kinds = new Set(["implements", "selects"]);
402
+ const edges = model.edges
403
+ .filter(({ kind }) => kinds.has(kind))
404
+ .map((edge) => ({
405
+ ...edge,
406
+ label: edge.kind === "selects"
407
+ ? [
408
+ "enum implementation",
409
+ edge.contract ? `for ${edge.contract}` : undefined,
410
+ edge.member ? `value ${edge.member}` : undefined
411
+ ].filter(Boolean).join(" ")
412
+ : "implements"
413
+ }));
414
+ const keys = new Set(edges.flatMap(({ from, to }) => [from, to].filter(Boolean)));
415
+ let view = {
416
+ ...model,
417
+ emptyMessage: "No interfaces or implementations found",
418
+ objects: model.objects
419
+ .filter(({ key }) => keys.has(key))
420
+ .map((object) => ({
421
+ ...object,
422
+ viewGroup: object.type === "interface"
423
+ ? "Interfaces"
424
+ : ["enum", "enumextension"].includes(object.type)
425
+ ? "Enum dispatch"
426
+ : "Implementations"
427
+ })),
428
+ edges
429
+ };
430
+ if (options.focus) {
431
+ const seeds = focusSeeds(view, options.focus);
432
+ if (!seeds.length) throw new Error(`Contract focus "${options.focus}" did not match`);
433
+ view = connectedProjection(view, seeds);
434
+ }
435
+ return view;
436
+ }
437
+
438
+ function subscriberAttribute(attribute) {
439
+ return attribute.match(
440
+ /EventSubscriber\s*\(\s*ObjectType::(\w+)\s*,\s*(?:\w+::)?(?:"([^"]+)"|([^,\s]+))\s*,\s*'([^']+)'/iu
441
+ );
442
+ }
443
+
444
+ function eventsView(model, options = {}) {
445
+ const objectIndex = new Map();
446
+ const publisherIndex = new Map();
447
+ const objects = [];
448
+ const edges = [];
449
+
450
+ for (const owner of model.objects) {
451
+ objectIndex.set(`${owner.type}:${normalizeIdentifier(owner.name)}`, owner);
452
+ if (owner.id) objectIndex.set(`${owner.type}:${owner.id}`, owner);
453
+ for (const procedure of owner.procedures ?? []) {
454
+ if (procedure.kind !== "event") continue;
455
+ const item = {
456
+ key: `${owner.key}::event::${normalizeIdentifier(procedure.name)}`,
457
+ name: procedure.name,
458
+ eventName: procedure.name,
459
+ ownerName: owner.name,
460
+ type: "event",
461
+ namespace: owner.namespace,
462
+ file: owner.file,
463
+ location: procedure.location,
464
+ relations: [],
465
+ viewGroup: "Publishers"
466
+ };
467
+ objects.push(item);
468
+ publisherIndex.set(
469
+ `${owner.key}:${normalizeIdentifier(procedure.name)}`,
470
+ item
471
+ );
472
+ }
473
+ }
474
+
475
+ const subscribers = new Map();
476
+ for (const owner of model.objects) {
477
+ for (const procedure of owner.procedures ?? []) {
478
+ for (const attribute of procedure.attributes ?? []) {
479
+ const match = subscriberAttribute(attribute);
480
+ if (!match) continue;
481
+ const subscriberKey =
482
+ `${owner.key}::subscriber::${normalizeIdentifier(procedure.name)}`;
483
+ let subscriber = subscribers.get(subscriberKey);
484
+ if (!subscriber) {
485
+ subscriber = {
486
+ key: subscriberKey,
487
+ name: procedure.name,
488
+ ownerName: owner.name,
489
+ type: "subscriber",
490
+ namespace: owner.namespace,
491
+ file: owner.file,
492
+ location: procedure.location,
493
+ relations: [],
494
+ viewGroup: "Subscribers"
495
+ };
496
+ subscribers.set(subscriberKey, subscriber);
497
+ objects.push(subscriber);
498
+ }
499
+ const targetName = match[2] ?? match[3];
500
+ const targetOwner = objectIndex.get(
501
+ `${match[1].toLowerCase()}:${normalizeIdentifier(targetName)}`
502
+ );
503
+ const publisher = targetOwner
504
+ ? publisherIndex.get(
505
+ `${targetOwner.key}:${normalizeIdentifier(match[4])}`
506
+ )
507
+ : undefined;
508
+ edges.push({
509
+ id: `event${edges.length}`,
510
+ from: publisher?.key ?? subscriber.key,
511
+ to: publisher ? subscriber.key : undefined,
512
+ unresolved: publisher ? undefined : {
513
+ name: `${targetName}.${match[4]}`,
514
+ type: "event"
515
+ },
516
+ kind: publisher ? "publishes" : "subscribes",
517
+ label: publisher ? "publishes to" : "subscribes (unresolved)",
518
+ confidence: publisher ? "resolved" : "syntactic",
519
+ location: procedure.location
520
+ });
521
+ }
522
+ }
523
+ }
524
+
525
+ let view = {
526
+ ...model,
527
+ emptyMessage: "No event publishers or subscribers found",
528
+ objects,
529
+ edges
530
+ };
531
+ if (options.focus) {
532
+ const wanted = normalizeIdentifier(options.focus);
533
+ const seeds = objects
534
+ .filter((object) =>
535
+ [object.name, object.ownerName, object.eventName]
536
+ .filter(Boolean)
537
+ .some((value) => normalizeIdentifier(value).includes(wanted))
538
+ )
539
+ .map(({ key }) => key);
540
+ for (const edge of edges) {
541
+ if (
542
+ edge.unresolved &&
543
+ normalizeIdentifier(edge.unresolved.name).includes(wanted)
544
+ ) {
545
+ seeds.push(edge.from);
546
+ }
547
+ }
548
+ if (!seeds.length) throw new Error(`Event focus "${options.focus}" did not match`);
549
+ view = connectedProjection(view, seeds);
550
+ }
551
+ return view;
552
+ }
553
+
554
+ function uiView(model, options = {}) {
555
+ const pageTypes = new Set(["page", "pageextension", "pagecustomization"]);
556
+ const pageKeys = new Set(
557
+ model.objects.filter(({ type }) => pageTypes.has(type)).map(({ key }) => key)
558
+ );
559
+ const relevant = model.edges.filter((edge) =>
560
+ pageKeys.has(edge.from) &&
561
+ (
562
+ edge.kind === "part" ||
563
+ edge.kind === "runs" ||
564
+ edge.kind === "extends" ||
565
+ (edge.kind === "reads" && edge.property === "sourcetable")
566
+ )
567
+ );
568
+ const objectKeys = new Set(pageKeys);
569
+ for (const edge of relevant) if (edge.to) objectKeys.add(edge.to);
570
+
571
+ const objects = model.objects
572
+ .filter(({ key }) => objectKeys.has(key))
573
+ .map((object) => ({
574
+ ...object,
575
+ viewGroup: pageTypes.has(object.type)
576
+ ? "Pages"
577
+ : object.type === "table"
578
+ ? "Source tables"
579
+ : "Navigation targets"
580
+ }));
581
+ const edges = [];
582
+ const actionIndex = new Map();
583
+
584
+ for (const page of model.objects.filter(({ key }) => pageKeys.has(key))) {
585
+ for (const action of page.actions ?? []) {
586
+ const key = `${page.key}::action::${normalizeIdentifier(action.name)}`;
587
+ const item = {
588
+ key,
589
+ name: action.name,
590
+ ownerName: page.name,
591
+ type: "action",
592
+ namespace: page.namespace,
593
+ file: page.file,
594
+ location: action.location,
595
+ relations: [],
596
+ viewGroup: "Actions"
597
+ };
598
+ objects.push(item);
599
+ actionIndex.set(`${page.key}:${normalizeIdentifier(action.name)}`, item);
600
+ edges.push({
601
+ id: `ui${edges.length}`,
602
+ from: page.key,
603
+ to: key,
604
+ kind: "contains",
605
+ label: "action",
606
+ confidence: "resolved"
607
+ });
608
+ }
609
+ }
610
+
611
+ for (const edge of relevant) {
612
+ const action = edge.member
613
+ ? actionIndex.get(`${edge.from}:${normalizeIdentifier(edge.member)}`)
614
+ : undefined;
615
+ edges.push({
616
+ ...edge,
617
+ from: edge.kind === "runs" && action ? action.key : edge.from,
618
+ label: edge.kind === "part" && edge.member
619
+ ? `part ${edge.member}`
620
+ : edge.kind === "reads"
621
+ ? "source table"
622
+ : edge.kind
623
+ });
624
+ }
625
+
626
+ let view = {
627
+ ...model,
628
+ emptyMessage: "No pages, page extensions, or page customizations found",
629
+ objects,
630
+ edges
631
+ };
632
+ if (options.focus) {
633
+ const seeds = objects
634
+ .filter((object) =>
635
+ pageKeys.has(object.key) &&
636
+ normalizeIdentifier(object.name).includes(normalizeIdentifier(options.focus))
637
+ )
638
+ .map(({ key }) => key);
639
+ if (!seeds.length) throw new Error(`UI focus "${options.focus}" did not match a page`);
640
+ const keys = new Set(seeds);
641
+ for (let depth = 0; depth < 2; depth++) {
642
+ for (const edge of edges) {
643
+ if (keys.has(edge.from) && edge.to) keys.add(edge.to);
644
+ if (edge.kind === "extends" && edge.to && keys.has(edge.to)) keys.add(edge.from);
645
+ }
646
+ }
647
+ view = {
648
+ ...view,
649
+ objects: objects.filter(({ key }) => keys.has(key)),
650
+ edges: edges.filter(
651
+ ({ from, to }) => keys.has(from) && (!to || keys.has(to))
652
+ )
653
+ };
654
+ }
655
+ return view;
656
+ }
657
+
658
+ export function createView(model, view = "project", options = {}) {
659
+ switch (view) {
660
+ case "project": return model;
661
+ case "module": return moduleView(model, options);
662
+ case "object": return objectView(model, options.object);
663
+ case "data": return dataView(model);
664
+ case "call": return callView(model, options);
665
+ case "boundary": return boundaryView(model, options);
666
+ case "contracts": return contractsView(model, options);
667
+ case "events": return eventsView(model, options);
668
+ case "ui": return uiView(model, options);
669
+ case "workflow": return workflowView(model, options);
670
+ default: throw new Error(`Unsupported view: ${view}`);
671
+ }
672
+ }
673
+
674
+ export const testing = { globToRegExp, selectObject };