@ontrails/topography 1.0.0-beta.41
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 +543 -0
- package/README.md +196 -0
- package/package.json +33 -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 +272 -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-projection.ts +133 -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 +280 -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-topos.ts +483 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace-wide trail-id index for compose-app resolution.
|
|
3
|
+
*
|
|
4
|
+
* Builds a `{ trailId → appName }` index that lets `trails run <id>` resolve
|
|
5
|
+
* a trail to its owning app without scanning every app's source. The index is
|
|
6
|
+
* either read from a committed `trails.lock` workspace index (the cached, fast
|
|
7
|
+
* path) or discovered by walking the workspace's `workspaces` glob, loading each app's
|
|
8
|
+
* topo, and reading its trail ids.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* **Boundary.** This module lives in `@ontrails/topography` because it
|
|
12
|
+
* persists artifacts derived from the resolved graph (per ADR-0042). Compose-app
|
|
13
|
+
* resolution is CLI tooling that reads Topography artifacts before runtime
|
|
14
|
+
* begins — `@ontrails/core` resolves a single in-memory graph and stays
|
|
15
|
+
* unaware of workspace topology.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import type { Topo } from '@ontrails/core';
|
|
22
|
+
|
|
23
|
+
import { readWorkspaceTopoMetadata } from './io.js';
|
|
24
|
+
import type {
|
|
25
|
+
WorkspaceTopoMetadata,
|
|
26
|
+
WorkspaceTrailCollision,
|
|
27
|
+
WorkspaceTrailEntry,
|
|
28
|
+
WorkspaceTrailIndex,
|
|
29
|
+
} from './types.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Loader contract for a single workspace app.
|
|
37
|
+
*
|
|
38
|
+
* Receives the resolved app directory and the workspace root, and returns the
|
|
39
|
+
* loaded `Topo`. The default loader (see {@link defaultLoadTopo}) imports the
|
|
40
|
+
* app's entry module and reads a `default`, `graph`, or `app` export. Tests
|
|
41
|
+
* can substitute their own loader to avoid the dynamic-import dance against a
|
|
42
|
+
* temp directory.
|
|
43
|
+
*/
|
|
44
|
+
export type WorkspaceTopoLoader = (
|
|
45
|
+
appDir: string,
|
|
46
|
+
workspaceRoot: string,
|
|
47
|
+
entryRelative?: string | undefined
|
|
48
|
+
) => Promise<Topo>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Options accepted by {@link buildWorkspaceTrailIndex}.
|
|
52
|
+
*/
|
|
53
|
+
export interface BuildWorkspaceTrailIndexOptions {
|
|
54
|
+
/** Workspace root (a directory containing a `package.json` with `workspaces`). */
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
/**
|
|
57
|
+
* Loader for individual app topos. Defaults to {@link defaultLoadTopo} which
|
|
58
|
+
* imports the resolved entry module via `await import()`.
|
|
59
|
+
*/
|
|
60
|
+
readonly loadTopo?: WorkspaceTopoLoader;
|
|
61
|
+
/**
|
|
62
|
+
* Topo artifact directory consulted before discovery runs. Defaults to `cwd`,
|
|
63
|
+
* where the root `trails.lock` lives.
|
|
64
|
+
*/
|
|
65
|
+
readonly artifactDir?: string | undefined;
|
|
66
|
+
/** @deprecated Use `artifactDir`. */
|
|
67
|
+
readonly lockDir?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Structured result of building the workspace trail-id index.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* `index` is the trail-id-to-app-name map for **non-colliding** ids. When a
|
|
75
|
+
* trail id is exported by more than one app, it is **omitted** from `index`
|
|
76
|
+
* and recorded in `collisions` instead — callers must consult `collisions`
|
|
77
|
+
* to resolve ambiguous ids deterministically.
|
|
78
|
+
*
|
|
79
|
+
* `source` distinguishes a cache hit (the topo artifact already carried the
|
|
80
|
+
* workspace trail index) from discovery (apps were walked and loaded). The
|
|
81
|
+
* topo artifact path cannot collide because the index is itself a flat
|
|
82
|
+
* `{ trailId → appName }` map. `apps` lists the app names actually represented
|
|
83
|
+
* in the index. `warnings` reports load failures and other non-collision
|
|
84
|
+
* issues; collisions live in their own structured field.
|
|
85
|
+
*/
|
|
86
|
+
export interface WorkspaceTrailIndexResult {
|
|
87
|
+
readonly index: WorkspaceTrailIndex;
|
|
88
|
+
readonly source: 'trails-lock' | 'discovery';
|
|
89
|
+
readonly apps: readonly string[];
|
|
90
|
+
readonly warnings: readonly string[];
|
|
91
|
+
readonly collisions: readonly WorkspaceTrailCollision[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Helpers — package.json reading
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/** A small subset of app `package.json` fields the discovery layer cares about. */
|
|
99
|
+
export interface AppManifest {
|
|
100
|
+
readonly name?: string | undefined;
|
|
101
|
+
readonly trails?: { readonly module?: string | undefined } | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const isAppManifest = (value: unknown): value is AppManifest => {
|
|
105
|
+
if (typeof value !== 'object' || value === null) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
const candidate = value as Record<string, unknown>;
|
|
109
|
+
if ('name' in candidate && typeof candidate['name'] !== 'string') {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if ('trails' in candidate) {
|
|
113
|
+
const trailsField = candidate['trails'];
|
|
114
|
+
if (typeof trailsField !== 'object' || trailsField === null) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const moduleField = (trailsField as Record<string, unknown>)['module'];
|
|
118
|
+
if (moduleField !== undefined && typeof moduleField !== 'string') {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const readAppManifest = async (
|
|
126
|
+
appDir: string
|
|
127
|
+
): Promise<AppManifest | null> => {
|
|
128
|
+
const file = Bun.file(join(appDir, 'package.json'));
|
|
129
|
+
if (!(await file.exists())) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const parsed: unknown = await file.json();
|
|
134
|
+
return isAppManifest(parsed) ? parsed : null;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export interface RootManifest {
|
|
141
|
+
readonly workspaces?: readonly string[] | undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const isRootManifest = (value: unknown): value is RootManifest => {
|
|
145
|
+
if (typeof value !== 'object' || value === null) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const candidate = value as Record<string, unknown>;
|
|
149
|
+
if (!('workspaces' in candidate)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
const ws = candidate['workspaces'];
|
|
153
|
+
return Array.isArray(ws) && ws.every((entry) => typeof entry === 'string');
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const readWorkspacesGlobs = async (
|
|
157
|
+
cwd: string
|
|
158
|
+
): Promise<readonly string[]> => {
|
|
159
|
+
const file = Bun.file(join(cwd, 'package.json'));
|
|
160
|
+
if (!(await file.exists())) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const parsed: unknown = await file.json();
|
|
165
|
+
if (!isRootManifest(parsed) || parsed.workspaces === undefined) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
return parsed.workspaces;
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Discovery — walk workspaces and identify Trails apps
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
interface CandidateApp {
|
|
179
|
+
readonly appDir: string;
|
|
180
|
+
readonly appName: string;
|
|
181
|
+
readonly entryRelative: string;
|
|
182
|
+
readonly modulePath: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve a single workspace member into a candidate app, or `null` if it
|
|
187
|
+
* does not look like a Trails app (no `package.json`, or no Trails entry).
|
|
188
|
+
*
|
|
189
|
+
* The convention:
|
|
190
|
+
* 1. The package.json must exist and parse.
|
|
191
|
+
* 2. Either an explicit `trails.module` field must be present, OR a default
|
|
192
|
+
* `src/app.ts` file must exist in the member.
|
|
193
|
+
* 3. The package's `name` field becomes the app name; if missing, the last
|
|
194
|
+
* path segment of the member directory is used as a stable fallback.
|
|
195
|
+
*/
|
|
196
|
+
const resolveCandidateApp = async (
|
|
197
|
+
memberDir: string,
|
|
198
|
+
workspaceRoot: string
|
|
199
|
+
): Promise<CandidateApp | null> => {
|
|
200
|
+
const manifest = await readAppManifest(memberDir);
|
|
201
|
+
if (manifest === null) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const explicitEntry = manifest.trails?.module;
|
|
206
|
+
const entryRelative = explicitEntry ?? 'src/app.ts';
|
|
207
|
+
if (manifest.trails?.module === undefined) {
|
|
208
|
+
const conventionEntry = Bun.file(join(memberDir, 'src/app.ts'));
|
|
209
|
+
if (!(await conventionEntry.exists())) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const fallbackName = basename(memberDir) || memberDir;
|
|
215
|
+
const appName = manifest.name ?? fallbackName;
|
|
216
|
+
const modulePath = relative(workspaceRoot, join(memberDir, entryRelative));
|
|
217
|
+
return { appDir: memberDir, appName, entryRelative, modulePath };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Expand the workspace's `workspaces` globs into concrete member directories.
|
|
222
|
+
*
|
|
223
|
+
* Only directory-shaped globs are supported (`apps/*`, `packages/*`). The
|
|
224
|
+
* scanner uses {@link Bun.Glob} so we stay Bun-native and avoid pulling in
|
|
225
|
+
* extra glob libraries.
|
|
226
|
+
*/
|
|
227
|
+
const expandWorkspaceMembers = async (
|
|
228
|
+
cwd: string,
|
|
229
|
+
globs: readonly string[]
|
|
230
|
+
): Promise<readonly string[]> => {
|
|
231
|
+
const members: string[] = [];
|
|
232
|
+
for (const pattern of globs) {
|
|
233
|
+
const glob = new Bun.Glob(pattern);
|
|
234
|
+
for await (const match of glob.scan({ cwd, onlyFiles: false })) {
|
|
235
|
+
members.push(join(cwd, match));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Sort for deterministic discovery order. Collision resolution depends on
|
|
239
|
+
// a stable order so that "last-write-wins" is reproducible across runs.
|
|
240
|
+
return [...members].toSorted();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Default loader
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/** Property names checked, in order, when extracting a Topo from a module. */
|
|
248
|
+
const TOPO_EXPORT_NAMES = ['default', 'graph', 'app'] as const;
|
|
249
|
+
|
|
250
|
+
const isTopo = (value: unknown): value is Topo => {
|
|
251
|
+
if (typeof value !== 'object' || value === null) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const candidate = value as Record<string, unknown>;
|
|
255
|
+
return (
|
|
256
|
+
typeof candidate['ids'] === 'function' &&
|
|
257
|
+
typeof candidate['name'] === 'string'
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Default loader: imports the app's entry module and returns the first export
|
|
263
|
+
* matching the `default` / `graph` / `app` convention.
|
|
264
|
+
*/
|
|
265
|
+
export const defaultLoadTopo: WorkspaceTopoLoader = async (
|
|
266
|
+
appDir,
|
|
267
|
+
_workspaceRoot,
|
|
268
|
+
entryRelative
|
|
269
|
+
) => {
|
|
270
|
+
const entryAbsolute = join(appDir, entryRelative ?? 'src/app.ts');
|
|
271
|
+
const mod = (await import(entryAbsolute)) as Record<string, unknown>;
|
|
272
|
+
for (const exportName of TOPO_EXPORT_NAMES) {
|
|
273
|
+
const candidate = mod[exportName];
|
|
274
|
+
if (isTopo(candidate)) {
|
|
275
|
+
return candidate;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
throw new Error(
|
|
279
|
+
`App at "${appDir}" does not export a Topo via default, graph, or app.`
|
|
280
|
+
);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// Public API
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
interface IndexAccumulator {
|
|
288
|
+
/** All apps each trail id was registered by, in registration order. */
|
|
289
|
+
readonly owners: Map<string, WorkspaceTrailEntry[]>;
|
|
290
|
+
readonly apps: Set<string>;
|
|
291
|
+
readonly warnings: string[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const recordTrails = (
|
|
295
|
+
accumulator: IndexAccumulator,
|
|
296
|
+
app: CandidateApp,
|
|
297
|
+
trailIds: readonly string[]
|
|
298
|
+
): void => {
|
|
299
|
+
for (const trailId of trailIds) {
|
|
300
|
+
accumulator.apps.add(app.appName);
|
|
301
|
+
const entry: WorkspaceTrailEntry = {
|
|
302
|
+
appName: app.appName,
|
|
303
|
+
modulePath: app.modulePath,
|
|
304
|
+
trailId,
|
|
305
|
+
};
|
|
306
|
+
const owners = accumulator.owners.get(trailId);
|
|
307
|
+
if (owners === undefined) {
|
|
308
|
+
accumulator.owners.set(trailId, [entry]);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (!owners.some((owner) => owner.appName === app.appName)) {
|
|
312
|
+
owners.push(entry);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
interface ResolvedOwners {
|
|
318
|
+
readonly index: WorkspaceTrailIndex;
|
|
319
|
+
readonly collisions: readonly WorkspaceTrailCollision[];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const resolveOwners = (
|
|
323
|
+
owners: ReadonlyMap<string, readonly WorkspaceTrailEntry[]>
|
|
324
|
+
): ResolvedOwners => {
|
|
325
|
+
const index: WorkspaceTrailIndex = {};
|
|
326
|
+
const collisions: WorkspaceTrailCollision[] = [];
|
|
327
|
+
for (const [trailId, entries] of owners) {
|
|
328
|
+
if (entries.length === 1) {
|
|
329
|
+
// Safe: length-1 array, so destructured element is defined.
|
|
330
|
+
const [sole] = entries;
|
|
331
|
+
if (sole !== undefined) {
|
|
332
|
+
index[trailId] = sole;
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const sortedOwners = [...entries].toSorted((a, b) =>
|
|
337
|
+
a.appName < b.appName ? -1 : 1
|
|
338
|
+
);
|
|
339
|
+
collisions.push({
|
|
340
|
+
apps: sortedOwners.map((entry) => entry.appName),
|
|
341
|
+
owners: sortedOwners,
|
|
342
|
+
trailId,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
collisions.sort((a, b) => (a.trailId < b.trailId ? -1 : 1));
|
|
346
|
+
return { collisions, index };
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const buildFromTopoLock = (
|
|
350
|
+
workspace: WorkspaceTopoMetadata
|
|
351
|
+
): WorkspaceTrailIndexResult => {
|
|
352
|
+
const apps = new Set<string>();
|
|
353
|
+
const collisions = Object.freeze([...(workspace.collisions ?? [])]);
|
|
354
|
+
const workspaceIndex = workspace.trails;
|
|
355
|
+
for (const entry of Object.values(workspaceIndex)) {
|
|
356
|
+
apps.add(entry.appName);
|
|
357
|
+
}
|
|
358
|
+
for (const collision of collisions) {
|
|
359
|
+
for (const appName of collision.apps) {
|
|
360
|
+
apps.add(appName);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
apps: [...apps].toSorted(),
|
|
365
|
+
collisions,
|
|
366
|
+
index: Object.freeze({ ...workspaceIndex }),
|
|
367
|
+
source: 'trails-lock',
|
|
368
|
+
warnings: [],
|
|
369
|
+
};
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const buildFromDiscovery = async (
|
|
373
|
+
cwd: string,
|
|
374
|
+
loadTopo: WorkspaceTopoLoader,
|
|
375
|
+
initialWarnings: readonly string[] = []
|
|
376
|
+
): Promise<WorkspaceTrailIndexResult> => {
|
|
377
|
+
const accumulator: IndexAccumulator = {
|
|
378
|
+
apps: new Set<string>(),
|
|
379
|
+
owners: new Map<string, WorkspaceTrailEntry[]>(),
|
|
380
|
+
warnings: [...initialWarnings],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const globs = await readWorkspacesGlobs(cwd);
|
|
384
|
+
if (globs.length === 0) {
|
|
385
|
+
return {
|
|
386
|
+
apps: [],
|
|
387
|
+
collisions: [],
|
|
388
|
+
index: Object.freeze({}),
|
|
389
|
+
source: 'discovery',
|
|
390
|
+
warnings: [...accumulator.warnings],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const members = await expandWorkspaceMembers(cwd, globs);
|
|
395
|
+
const resolvedCandidates = await Promise.all(
|
|
396
|
+
members.map((memberDir) => resolveCandidateApp(memberDir, cwd))
|
|
397
|
+
);
|
|
398
|
+
const candidates = resolvedCandidates.filter(
|
|
399
|
+
(candidate): candidate is CandidateApp => candidate !== null
|
|
400
|
+
);
|
|
401
|
+
const settled = await Promise.allSettled(
|
|
402
|
+
candidates.map(async (candidate) => {
|
|
403
|
+
const loaded = await loadTopo(
|
|
404
|
+
candidate.appDir,
|
|
405
|
+
cwd,
|
|
406
|
+
candidate.entryRelative
|
|
407
|
+
);
|
|
408
|
+
return { candidate, trailIds: loaded.ids() };
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
for (const [index, result] of settled.entries()) {
|
|
412
|
+
const candidate = candidates[index];
|
|
413
|
+
if (candidate === undefined) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (result.status === 'fulfilled') {
|
|
417
|
+
recordTrails(accumulator, result.value.candidate, result.value.trailIds);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const message =
|
|
421
|
+
result.reason instanceof Error
|
|
422
|
+
? result.reason.message
|
|
423
|
+
: String(result.reason);
|
|
424
|
+
accumulator.warnings.push(
|
|
425
|
+
`Failed to load app "${candidate.appName}" at ${candidate.appDir}: ${message}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { index, collisions } = resolveOwners(accumulator.owners);
|
|
430
|
+
return {
|
|
431
|
+
apps: [...accumulator.apps].toSorted(),
|
|
432
|
+
collisions,
|
|
433
|
+
index: Object.freeze(index),
|
|
434
|
+
source: 'discovery',
|
|
435
|
+
warnings: [...accumulator.warnings],
|
|
436
|
+
};
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Build a workspace-wide trail-id-to-app-name index.
|
|
441
|
+
*
|
|
442
|
+
* Prefers a committed topo artifact (`trails.lock` carrying a workspace
|
|
443
|
+
* trail index) when present; otherwise walks the workspace's
|
|
444
|
+
* `workspaces` globs, loads each app's topo, and reads its trail ids.
|
|
445
|
+
*
|
|
446
|
+
* @example
|
|
447
|
+
* ```ts
|
|
448
|
+
* const result = await buildWorkspaceTrailIndex({ cwd: process.cwd() });
|
|
449
|
+
* if (result.source === 'trails-lock') {
|
|
450
|
+
* // Cached path — no app loading happened.
|
|
451
|
+
* }
|
|
452
|
+
* const owningApp = result.index['my-app.do-thing'];
|
|
453
|
+
* ```
|
|
454
|
+
*/
|
|
455
|
+
export const buildWorkspaceTrailIndex = async (
|
|
456
|
+
options: BuildWorkspaceTrailIndexOptions
|
|
457
|
+
): Promise<WorkspaceTrailIndexResult> => {
|
|
458
|
+
const {
|
|
459
|
+
artifactDir = options.lockDir,
|
|
460
|
+
cwd,
|
|
461
|
+
loadTopo = defaultLoadTopo,
|
|
462
|
+
} = options;
|
|
463
|
+
|
|
464
|
+
let resolvedArtifactDir: string;
|
|
465
|
+
if (artifactDir === undefined) {
|
|
466
|
+
resolvedArtifactDir = cwd;
|
|
467
|
+
} else if (isAbsolute(artifactDir)) {
|
|
468
|
+
resolvedArtifactDir = artifactDir;
|
|
469
|
+
} else {
|
|
470
|
+
resolvedArtifactDir = join(cwd, artifactDir);
|
|
471
|
+
}
|
|
472
|
+
const workspace = await readWorkspaceTopoMetadata({
|
|
473
|
+
dir: resolvedArtifactDir,
|
|
474
|
+
});
|
|
475
|
+
if (workspace !== null) {
|
|
476
|
+
return buildFromTopoLock(workspace);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const fallbackWarning = existsSync(join(resolvedArtifactDir, 'trails.lock'))
|
|
480
|
+
? `Workspace trails.lock in "${resolvedArtifactDir}" does not include workspace metadata; falling back to discovery.`
|
|
481
|
+
: `No workspace trails.lock found in "${resolvedArtifactDir}"; falling back to discovery.`;
|
|
482
|
+
return await buildFromDiscovery(cwd, loadTopo, [fallbackWarning]);
|
|
483
|
+
};
|