@ontrails/topography 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ import { AmbiguousError, Result } from '@ontrails/core';
2
+ import { z } from 'zod';
3
+
4
+ import type { DiffResult, TopoGraph, TopoGraphEntry } from '../types.js';
5
+ import { listWayfinderEntityRefs } from './filters.js';
6
+ import type { WayfinderEntityKind, WayfinderEntityRef } from './filters.js';
7
+
8
+ export const relationKindSchema = z.enum([
9
+ 'composed-by',
10
+ 'consumed-by',
11
+ 'entity-referenced-by',
12
+ 'trailhead-groups',
13
+ 'fired-by',
14
+ 'has-version',
15
+ 'surface-renders',
16
+ 'used-by',
17
+ ]);
18
+
19
+ export const relationRefSchema = z.object({
20
+ id: z.string(),
21
+ kind: z.enum([
22
+ 'entity',
23
+ 'trailhead',
24
+ 'resource',
25
+ 'signal',
26
+ 'surface',
27
+ 'trail',
28
+ 'version',
29
+ ]),
30
+ trailId: z.string().optional(),
31
+ versionKey: z.string().optional(),
32
+ });
33
+
34
+ export const relationEdgeSchema = z.object({
35
+ from: relationRefSchema,
36
+ relation: relationKindSchema,
37
+ to: relationRefSchema,
38
+ });
39
+
40
+ export const relationGroupSchema = z.object({
41
+ direction: z.enum(['incoming', 'outgoing']),
42
+ refs: z.array(relationRefSchema).readonly(),
43
+ relation: relationKindSchema,
44
+ });
45
+
46
+ export const impactNodeSchema = relationRefSchema.extend({
47
+ depth: z.number(),
48
+ from: relationRefSchema.optional(),
49
+ via: relationKindSchema.optional(),
50
+ });
51
+
52
+ export type RelationKind = z.output<typeof relationKindSchema>;
53
+ export type RelationRef = z.output<typeof relationRefSchema>;
54
+ export type RelationEdge = z.output<typeof relationEdgeSchema>;
55
+ export type ImpactDirection = 'downstream' | 'upstream' | 'both';
56
+
57
+ export interface ImpactOptions {
58
+ readonly direction: ImpactDirection;
59
+ readonly limit: number;
60
+ readonly maxDepth: number;
61
+ }
62
+
63
+ const entryRef = (entry: TopoGraphEntry): WayfinderEntityRef => ({
64
+ entry,
65
+ id: entry.id,
66
+ kind: entry.kind,
67
+ });
68
+
69
+ const refFor = (
70
+ graph: TopoGraph,
71
+ id: string,
72
+ kind: WayfinderEntityKind
73
+ ): WayfinderEntityRef =>
74
+ listWayfinderEntityRefs(graph).find(
75
+ (ref) => ref.id === id && ref.kind === kind
76
+ ) ?? { id, kind };
77
+
78
+ export const refSummary = (ref: WayfinderEntityRef): RelationRef => ({
79
+ id: ref.id,
80
+ kind: ref.kind,
81
+ ...(ref.trailId === undefined ? {} : { trailId: ref.trailId }),
82
+ ...(ref.versionKey === undefined ? {} : { versionKey: ref.versionKey }),
83
+ });
84
+
85
+ const signalUses = (
86
+ entry: TopoGraphEntry
87
+ ): readonly {
88
+ readonly relation: Extract<RelationKind, 'consumed-by' | 'fired-by'>;
89
+ readonly signalId: string;
90
+ }[] => [
91
+ ...(entry.fires ?? []).map((signalId) => ({
92
+ relation: 'fired-by' as const,
93
+ signalId,
94
+ })),
95
+ ...[
96
+ ...(entry.on ?? []),
97
+ ...(entry.from ?? []),
98
+ ...(entry.consumers ?? []),
99
+ ].map((signalId) => ({
100
+ relation: 'consumed-by' as const,
101
+ signalId,
102
+ })),
103
+ ...(entry.producers ?? []).map((signalId) => ({
104
+ relation: 'fired-by' as const,
105
+ signalId,
106
+ })),
107
+ ];
108
+
109
+ const relationEdge = (
110
+ from: WayfinderEntityRef,
111
+ relation: RelationKind,
112
+ to: WayfinderEntityRef
113
+ ): RelationEdge => ({
114
+ from: refSummary(from),
115
+ relation,
116
+ to: refSummary(to),
117
+ });
118
+
119
+ const relationKey = (edge: RelationEdge): string =>
120
+ `${edge.from.kind}:${edge.from.id}:${edge.relation}:${edge.to.kind}:${edge.to.id}`;
121
+
122
+ export const relationEdges = (graph: TopoGraph): readonly RelationEdge[] => {
123
+ const edges: RelationEdge[] = [];
124
+ const add = (
125
+ from: WayfinderEntityRef,
126
+ relation: RelationKind,
127
+ to: WayfinderEntityRef
128
+ ) => edges.push(relationEdge(from, relation, to));
129
+
130
+ for (const entry of graph.entries) {
131
+ const target = entryRef(entry);
132
+ for (const composedId of entry.composes ?? []) {
133
+ add(refFor(graph, composedId, 'trail'), 'composed-by', target);
134
+ }
135
+ for (const entityId of entry.entities ?? []) {
136
+ add(refFor(graph, entityId, 'entity'), 'entity-referenced-by', target);
137
+ }
138
+ for (const resourceId of entry.resources ?? []) {
139
+ add(refFor(graph, resourceId, 'resource'), 'used-by', target);
140
+ }
141
+ for (const { relation, signalId } of signalUses(entry)) {
142
+ add(refFor(graph, signalId, 'signal'), relation, target);
143
+ }
144
+ for (const surfaceId of entry.surfaces) {
145
+ add(refFor(graph, surfaceId, 'surface'), 'surface-renders', target);
146
+ }
147
+ if (entry.kind === 'trail' && entry.version !== undefined) {
148
+ add(target, 'has-version', {
149
+ entry,
150
+ id: `${entry.id}@${entry.version}`,
151
+ kind: 'version',
152
+ trailId: entry.id,
153
+ versionKey: String(entry.version),
154
+ });
155
+ }
156
+ for (const [versionKey, version] of Object.entries(entry.versions ?? {})) {
157
+ add(target, 'has-version', {
158
+ entry,
159
+ id: `${entry.id}@${versionKey}`,
160
+ kind: 'version',
161
+ trailId: entry.id,
162
+ version,
163
+ versionKey,
164
+ });
165
+ }
166
+ }
167
+
168
+ for (const trailhead of graph.trailheads ?? []) {
169
+ const trailheadRef = refFor(graph, trailhead.id, 'trailhead');
170
+ const surfaceRefs = trailhead.surfaces.map((surfaceId) =>
171
+ refFor(graph, surfaceId, 'surface')
172
+ );
173
+ for (const memberId of trailhead.memberIds) {
174
+ const memberRef = refFor(graph, memberId, 'trail');
175
+ add(trailheadRef, 'trailhead-groups', memberRef);
176
+ for (const surfaceRef of surfaceRefs) {
177
+ add(surfaceRef, 'surface-renders', memberRef);
178
+ }
179
+ }
180
+ }
181
+
182
+ return [
183
+ ...new Map(edges.map((edge) => [relationKey(edge), edge])).values(),
184
+ ].toSorted((left, right) =>
185
+ relationKey(left).localeCompare(relationKey(right))
186
+ );
187
+ };
188
+
189
+ const refMatches = (ref: RelationRef, target: RelationRef): boolean =>
190
+ ref.id === target.id && ref.kind === target.kind;
191
+
192
+ export const groupNearbyEdges = (
193
+ edges: readonly RelationEdge[],
194
+ target: RelationRef
195
+ ) => {
196
+ const groups = new Map<string, z.output<typeof relationGroupSchema>>();
197
+ for (const edge of edges) {
198
+ const direction = refMatches(edge.from, target) ? 'outgoing' : 'incoming';
199
+ const ref = direction === 'outgoing' ? edge.to : edge.from;
200
+ const key = `${direction}:${edge.relation}`;
201
+ const group = groups.get(key) ?? {
202
+ direction,
203
+ refs: [],
204
+ relation: edge.relation,
205
+ };
206
+ groups.set(key, {
207
+ ...group,
208
+ refs: [...group.refs, ref].toSorted((left, right) =>
209
+ `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`)
210
+ ),
211
+ });
212
+ }
213
+ return [...groups.values()].toSorted((left, right) =>
214
+ `${left.direction}:${left.relation}`.localeCompare(
215
+ `${right.direction}:${right.relation}`
216
+ )
217
+ );
218
+ };
219
+
220
+ export const edgeTouches = (edge: RelationEdge, target: RelationRef): boolean =>
221
+ refMatches(edge.from, target) || refMatches(edge.to, target);
222
+
223
+ const ambiguousRefId = (
224
+ id: string,
225
+ refs: readonly WayfinderEntityRef[]
226
+ ): AmbiguousError =>
227
+ new AmbiguousError(
228
+ `Wayfinder id "${id}" matched multiple entity kinds: ${refs
229
+ .map((ref) => ref.kind)
230
+ .join(', ')}. Pass kind to disambiguate.`
231
+ );
232
+
233
+ export const resolveEntityRef = (
234
+ graph: TopoGraph,
235
+ input: {
236
+ readonly id: string;
237
+ readonly kind?: WayfinderEntityKind | undefined;
238
+ }
239
+ ): Result<WayfinderEntityRef | undefined, AmbiguousError> => {
240
+ const refs = listWayfinderEntityRefs(graph).filter(
241
+ (ref) =>
242
+ ref.id === input.id &&
243
+ (input.kind === undefined || ref.kind === input.kind)
244
+ );
245
+ if (input.kind === undefined) {
246
+ const uniqueKinds = new Set(refs.map((ref) => ref.kind));
247
+ if (uniqueKinds.size > 1) {
248
+ return Result.err(ambiguousRefId(input.id, refs));
249
+ }
250
+ }
251
+ return Result.ok(refs[0]);
252
+ };
253
+
254
+ const edgesForDirection = (
255
+ edges: readonly RelationEdge[],
256
+ current: RelationRef,
257
+ direction: ImpactDirection
258
+ ): readonly {
259
+ readonly edge: RelationEdge;
260
+ readonly node: RelationRef;
261
+ }[] => {
262
+ const outgoing =
263
+ direction === 'downstream' || direction === 'both'
264
+ ? edges
265
+ .filter((edge) => refMatches(edge.from, current))
266
+ .map((edge) => ({ edge, node: edge.to }))
267
+ : [];
268
+ const incoming =
269
+ direction === 'upstream' || direction === 'both'
270
+ ? edges
271
+ .filter((edge) => refMatches(edge.to, current))
272
+ .map((edge) => ({ edge, node: edge.from }))
273
+ : [];
274
+ return [...outgoing, ...incoming].toSorted((left, right) =>
275
+ relationKey(left.edge).localeCompare(relationKey(right.edge))
276
+ );
277
+ };
278
+
279
+ export const impactFor = (
280
+ graph: TopoGraph,
281
+ input: ImpactOptions,
282
+ target: RelationRef
283
+ ) => {
284
+ const edges = relationEdges(graph);
285
+ const seen = new Set([`${target.kind}:${target.id}`]);
286
+ const queue: {
287
+ readonly depth: number;
288
+ readonly ref: RelationRef;
289
+ }[] = [{ depth: 0, ref: target }];
290
+ const nodes: z.output<typeof impactNodeSchema>[] = [];
291
+ const includedEdges = new Map<string, RelationEdge>();
292
+
293
+ for (
294
+ let index = 0;
295
+ index < queue.length && nodes.length < input.limit;
296
+ index += 1
297
+ ) {
298
+ const current = queue[index];
299
+ if (current === undefined) {
300
+ break;
301
+ }
302
+ if (current.depth >= input.maxDepth) {
303
+ continue;
304
+ }
305
+ for (const { edge, node } of edgesForDirection(
306
+ edges,
307
+ current.ref,
308
+ input.direction
309
+ )) {
310
+ includedEdges.set(relationKey(edge), edge);
311
+ const key = `${node.kind}:${node.id}`;
312
+ if (seen.has(key)) {
313
+ continue;
314
+ }
315
+ seen.add(key);
316
+ const reached = {
317
+ ...node,
318
+ depth: current.depth + 1,
319
+ from: current.ref,
320
+ via: edge.relation,
321
+ };
322
+ nodes.push(reached);
323
+ queue.push({ depth: reached.depth, ref: node });
324
+ if (nodes.length >= input.limit) {
325
+ break;
326
+ }
327
+ }
328
+ }
329
+
330
+ return {
331
+ edges: [...includedEdges.values()].toSorted((left, right) =>
332
+ relationKey(left).localeCompare(relationKey(right))
333
+ ),
334
+ nodes,
335
+ };
336
+ };
337
+
338
+ export const diffResult = (diff: DiffResult): DiffResult => ({
339
+ breaking: diff.breaking,
340
+ entries: diff.entries,
341
+ hasBreaking: diff.hasBreaking,
342
+ info: diff.info,
343
+ warnings: diff.warnings,
344
+ });
@@ -0,0 +1,221 @@
1
+ import { trailsLockFileName } from '@ontrails/config';
2
+ import type { ReadTrailsProjectIdentityResult } from '@ontrails/config';
3
+ import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
4
+ import {
5
+ basename,
6
+ dirname,
7
+ isAbsolute,
8
+ posix,
9
+ relative,
10
+ resolve,
11
+ sep,
12
+ } from 'node:path';
13
+
14
+ export interface ConfiguredAppAlias {
15
+ readonly aliasEntryPaths: ReadonlySet<string>;
16
+ readonly authoredLockPath: string;
17
+ readonly authoredRoot: string;
18
+ readonly physicalLockPath: string;
19
+ readonly physicalAliasEntryPaths: ReadonlySet<string>;
20
+ readonly physicalRoot: string;
21
+ readonly physicalRootDir: string;
22
+ }
23
+
24
+ const toPosixRelative = (root: string, target: string): string => {
25
+ const path = relative(root, target);
26
+ return sep === posix.sep ? path : path.split(sep).join(posix.sep);
27
+ };
28
+
29
+ const isWithinBoundary = (boundary: string, target: string): boolean => {
30
+ const path = relative(boundary, target);
31
+ return (
32
+ path === '' ||
33
+ (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`))
34
+ );
35
+ };
36
+
37
+ const isNormalizedConfiguredRoot = (root: string): boolean =>
38
+ root !== '.' &&
39
+ !posix.isAbsolute(root) &&
40
+ posix.normalize(root) === root &&
41
+ root.split('/').every((segment) => segment !== '' && segment !== '..');
42
+
43
+ /** Express an in-workspace target as segments to descend from the canonical root. */
44
+ const workspaceDescentSegments = (
45
+ canonicalWorkspaceRoot: string,
46
+ target: string
47
+ ): readonly string[] | undefined => {
48
+ const suffix: string[] = [];
49
+ let ancestor = target;
50
+ while (!isWithinBoundary(canonicalWorkspaceRoot, ancestor)) {
51
+ if (realpathSync(ancestor) === canonicalWorkspaceRoot) {
52
+ return suffix;
53
+ }
54
+ const parent = dirname(ancestor);
55
+ if (parent === ancestor) {
56
+ return undefined;
57
+ }
58
+ suffix.unshift(basename(ancestor));
59
+ ancestor = parent;
60
+ }
61
+ const path = toPosixRelative(canonicalWorkspaceRoot, ancestor);
62
+ return path === '' ? suffix : [...path.split(posix.sep), ...suffix];
63
+ };
64
+
65
+ /** Walk a target path segment by segment so parent alias hops stay visible. */
66
+ const physicalAliasTargetChain = (
67
+ canonicalWorkspaceRoot: string,
68
+ absoluteAliasPath: string,
69
+ canonicalAliasTarget: string
70
+ ): readonly string[] | undefined => {
71
+ const entries: string[] = [];
72
+ const visited = new Set<string>();
73
+ const pending: string[] = [];
74
+ let current = resolve(
75
+ realpathSync(dirname(absoluteAliasPath)),
76
+ basename(absoluteAliasPath)
77
+ );
78
+ while (true) {
79
+ if (!isWithinBoundary(canonicalWorkspaceRoot, current)) {
80
+ return undefined;
81
+ }
82
+ if (!lstatSync(current).isSymbolicLink()) {
83
+ const segment = pending.shift();
84
+ if (segment === undefined) {
85
+ return realpathSync(current) === canonicalAliasTarget
86
+ ? entries
87
+ : undefined;
88
+ }
89
+ current = resolve(current, segment);
90
+ continue;
91
+ }
92
+ if (visited.has(current)) {
93
+ return undefined;
94
+ }
95
+ visited.add(current);
96
+ entries.push(toPosixRelative(canonicalWorkspaceRoot, current));
97
+ if (pending.length > 0) {
98
+ entries.push(
99
+ toPosixRelative(canonicalWorkspaceRoot, resolve(current, ...pending))
100
+ );
101
+ }
102
+ const targetSegments = workspaceDescentSegments(
103
+ canonicalWorkspaceRoot,
104
+ resolve(dirname(current), readlinkSync(current))
105
+ );
106
+ if (targetSegments === undefined) {
107
+ return undefined;
108
+ }
109
+ pending.unshift(...targetSegments);
110
+ current = canonicalWorkspaceRoot;
111
+ }
112
+ };
113
+
114
+ const configuredAliasEntryPaths = (
115
+ workspaceRoot: string,
116
+ canonicalWorkspaceRoot: string,
117
+ authoredRoot: string
118
+ ): {
119
+ readonly authored: ReadonlySet<string>;
120
+ readonly physical: ReadonlySet<string>;
121
+ } => {
122
+ const authored = new Set<string>();
123
+ const physical = new Set<string>();
124
+ const segments = authoredRoot.split('/');
125
+ for (let index = 1; index <= segments.length; index += 1) {
126
+ const path = segments.slice(0, index).join('/');
127
+ const absolutePath = resolve(workspaceRoot, path);
128
+ try {
129
+ if (!lstatSync(absolutePath).isSymbolicLink()) {
130
+ continue;
131
+ }
132
+ const canonicalAliasTarget = realpathSync(absolutePath);
133
+ if (isWithinBoundary(canonicalWorkspaceRoot, canonicalAliasTarget)) {
134
+ const physicalEntries = physicalAliasTargetChain(
135
+ canonicalWorkspaceRoot,
136
+ absolutePath,
137
+ canonicalAliasTarget
138
+ );
139
+ if (physicalEntries !== undefined) {
140
+ authored.add(path);
141
+ for (const physicalEntry of physicalEntries) {
142
+ physical.add(physicalEntry);
143
+ }
144
+ }
145
+ }
146
+ } catch {
147
+ // A changing or unreadable alias remains an unsupported collection edge.
148
+ }
149
+ }
150
+ return { authored, physical };
151
+ };
152
+
153
+ /** Reconcile only normalized, unique aliases whose real target stays in-workspace. */
154
+ export const deriveConfiguredAppAliases = (
155
+ identity: ReadTrailsProjectIdentityResult
156
+ ): readonly ConfiguredAppAlias[] => {
157
+ let canonicalWorkspaceRoot: string;
158
+ try {
159
+ canonicalWorkspaceRoot = realpathSync(identity.rootDir);
160
+ } catch {
161
+ return [];
162
+ }
163
+
164
+ const candidates = identity.apps.flatMap((app): ConfiguredAppAlias[] => {
165
+ if (!isNormalizedConfiguredRoot(app.root)) {
166
+ return [];
167
+ }
168
+ const authoredAppRoot = resolve(identity.rootDir, app.root);
169
+ if (resolve(app.rootDir) !== authoredAppRoot) {
170
+ return [];
171
+ }
172
+
173
+ let canonicalAppRoot: string;
174
+ try {
175
+ canonicalAppRoot = realpathSync(app.rootDir);
176
+ } catch {
177
+ return [];
178
+ }
179
+ if (!isWithinBoundary(canonicalWorkspaceRoot, canonicalAppRoot)) {
180
+ return [];
181
+ }
182
+
183
+ const physicalRoot = toPosixRelative(
184
+ canonicalWorkspaceRoot,
185
+ canonicalAppRoot
186
+ );
187
+ if (physicalRoot === app.root) {
188
+ return [];
189
+ }
190
+ const aliasEntryPaths = configuredAliasEntryPaths(
191
+ identity.rootDir,
192
+ canonicalWorkspaceRoot,
193
+ app.root
194
+ );
195
+ if (aliasEntryPaths.authored.size === 0) {
196
+ return [];
197
+ }
198
+ return [
199
+ {
200
+ aliasEntryPaths: aliasEntryPaths.authored,
201
+ authoredLockPath: posix.join(app.root, trailsLockFileName),
202
+ authoredRoot: app.root,
203
+ physicalAliasEntryPaths: aliasEntryPaths.physical,
204
+ physicalLockPath: posix.join(physicalRoot, trailsLockFileName),
205
+ physicalRoot,
206
+ physicalRootDir: canonicalAppRoot,
207
+ },
208
+ ];
209
+ });
210
+
211
+ const physicalLockOwners = new Map<string, number>();
212
+ for (const alias of candidates) {
213
+ physicalLockOwners.set(
214
+ alias.physicalLockPath,
215
+ (physicalLockOwners.get(alias.physicalLockPath) ?? 0) + 1
216
+ );
217
+ }
218
+ return candidates.filter(
219
+ (alias) => physicalLockOwners.get(alias.physicalLockPath) === 1
220
+ );
221
+ };