@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.
- package/CHANGELOG.md +866 -0
- package/README.md +228 -0
- package/package.json +40 -0
- package/src/activation-report.ts +385 -0
- package/src/backend-support.ts +11 -0
- package/src/derive.ts +690 -0
- package/src/diff.ts +1088 -0
- package/src/forces.ts +125 -0
- package/src/hash.ts +55 -0
- package/src/index.ts +273 -0
- package/src/internal/topo-snapshots.ts +682 -0
- package/src/internal/topo-store-read.ts +1102 -0
- package/src/internal/topo-store.ts +1651 -0
- package/src/io.ts +280 -0
- package/src/library-derivation.ts +131 -0
- package/src/overlays.ts +155 -0
- package/src/permit.ts +19 -0
- package/src/source-fingerprint.ts +103 -0
- package/src/surface-bindings.ts +101 -0
- package/src/topo-store.ts +716 -0
- package/src/types.ts +749 -0
- package/src/versioning.ts +277 -0
- package/src/wayfind/error-facts.ts +363 -0
- package/src/wayfind/filters.ts +430 -0
- package/src/wayfind/loader.ts +443 -0
- package/src/wayfind/navigation.ts +265 -0
- package/src/wayfind/provenance.ts +152 -0
- package/src/wayfind/queries.ts +1656 -0
- package/src/wayfind/relations.ts +344 -0
- package/src/workspace-configured-aliases.ts +221 -0
- package/src/workspace-lock-census.ts +306 -0
- package/src/workspace-view-types.ts +104 -0
- package/src/workspace-view.ts +486 -0
|
@@ -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
|
+
};
|