@ontrails/trails 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.
Files changed (121) hide show
  1. package/CHANGELOG.md +1906 -0
  2. package/README.md +48 -0
  3. package/bin/trails.ts +3 -0
  4. package/package.json +57 -0
  5. package/src/app.ts +167 -0
  6. package/src/clack.ts +111 -0
  7. package/src/cli.ts +308 -0
  8. package/src/completions.ts +431 -0
  9. package/src/lifecycle-source-io.ts +33 -0
  10. package/src/load-app-mirror.ts +202 -0
  11. package/src/local-state-io.ts +129 -0
  12. package/src/mcp-app.ts +42 -0
  13. package/src/mcp-options.ts +92 -0
  14. package/src/mcp.ts +8 -0
  15. package/src/project-writes.ts +377 -0
  16. package/src/regrade/audit.ts +571 -0
  17. package/src/regrade/config.ts +152 -0
  18. package/src/regrade/history.ts +636 -0
  19. package/src/regrade/lifecycle.ts +76 -0
  20. package/src/regrade/live-api-preserve.ts +123 -0
  21. package/src/regrade/plan-artifact.ts +515 -0
  22. package/src/regrade/plan-derivation.ts +301 -0
  23. package/src/regrade/prepared-run.ts +259 -0
  24. package/src/regrade/receipt-history.ts +446 -0
  25. package/src/regrade/source-transaction.ts +185 -0
  26. package/src/release/bindings.ts +58 -0
  27. package/src/release/changeset-packages.ts +99 -0
  28. package/src/release/check.ts +1191 -0
  29. package/src/release/cli-bundle.ts +575 -0
  30. package/src/release/config.ts +73 -0
  31. package/src/release/contract-facts.ts +425 -0
  32. package/src/release/homebrew.ts +221 -0
  33. package/src/release/index.ts +180 -0
  34. package/src/release/lock-roundtrip-smoke.ts +255 -0
  35. package/src/release/lock-roundtrip-workspace.ts +107 -0
  36. package/src/release/native-bun-publish.ts +964 -0
  37. package/src/release/native-bun-registry.ts +848 -0
  38. package/src/release/notes-cli.ts +171 -0
  39. package/src/release/notes.ts +390 -0
  40. package/src/release/pack-coherence.ts +455 -0
  41. package/src/release/package-route-facts.ts +146 -0
  42. package/src/release/packed-artifacts-smoke.ts +236 -0
  43. package/src/release/policy.ts +1780 -0
  44. package/src/release/semver.ts +104 -0
  45. package/src/release/smoke.ts +56 -0
  46. package/src/release/stable-version-release.ts +80 -0
  47. package/src/release/wayfinder-dogfood-smoke.ts +762 -0
  48. package/src/release/zero-line-transition.ts +68 -0
  49. package/src/retired-topo-command.ts +36 -0
  50. package/src/run-adapter-check.ts +76 -0
  51. package/src/run-argv.ts +133 -0
  52. package/src/run-collision.ts +126 -0
  53. package/src/run-completions-install.ts +179 -0
  54. package/src/run-example.ts +149 -0
  55. package/src/run-examples.ts +148 -0
  56. package/src/run-quiet.ts +75 -0
  57. package/src/run-regrade-progress.ts +47 -0
  58. package/src/run-release-check.ts +74 -0
  59. package/src/run-schema.ts +74 -0
  60. package/src/run-trace.ts +273 -0
  61. package/src/run-warden.ts +39 -0
  62. package/src/run-watch-project.ts +52 -0
  63. package/src/run-watch.ts +381 -0
  64. package/src/run-wayfind-outline.ts +170 -0
  65. package/src/scaffold-version-sync.ts +183 -0
  66. package/src/scaffold-versions.generated.ts +12 -0
  67. package/src/trails/adapter-check.ts +244 -0
  68. package/src/trails/add-surface.ts +816 -0
  69. package/src/trails/add-trail.ts +141 -0
  70. package/src/trails/add-verify.ts +252 -0
  71. package/src/trails/compile.ts +118 -0
  72. package/src/trails/completions-complete.ts +236 -0
  73. package/src/trails/completions.ts +47 -0
  74. package/src/trails/config-explain.ts +43 -0
  75. package/src/trails/create-adapter.ts +785 -0
  76. package/src/trails/create-scaffold.ts +1215 -0
  77. package/src/trails/create-versions.ts +62 -0
  78. package/src/trails/create.ts +652 -0
  79. package/src/trails/deprecate.ts +59 -0
  80. package/src/trails/dev-clean.ts +80 -0
  81. package/src/trails/dev-reset.ts +48 -0
  82. package/src/trails/dev-stats.ts +71 -0
  83. package/src/trails/dev-support.ts +360 -0
  84. package/src/trails/doctor.ts +77 -0
  85. package/src/trails/draft-promote.ts +949 -0
  86. package/src/trails/guide.ts +106 -0
  87. package/src/trails/load-app.ts +1145 -0
  88. package/src/trails/operator-context.ts +66 -0
  89. package/src/trails/project-context-output.ts +304 -0
  90. package/src/trails/project-context.ts +613 -0
  91. package/src/trails/project.ts +65 -0
  92. package/src/trails/regrade.ts +4951 -0
  93. package/src/trails/release-check.ts +113 -0
  94. package/src/trails/release-smoke.ts +49 -0
  95. package/src/trails/revise.ts +53 -0
  96. package/src/trails/root-dir.ts +21 -0
  97. package/src/trails/run-example.ts +592 -0
  98. package/src/trails/run-examples.ts +149 -0
  99. package/src/trails/run.ts +496 -0
  100. package/src/trails/scaffold-json.ts +60 -0
  101. package/src/trails/scaffold-topo-identity.ts +479 -0
  102. package/src/trails/survey.ts +990 -0
  103. package/src/trails/topo-activation.ts +14 -0
  104. package/src/trails/topo-constants.ts +2 -0
  105. package/src/trails/topo-history.ts +47 -0
  106. package/src/trails/topo-output-schemas.ts +259 -0
  107. package/src/trails/topo-pin.ts +38 -0
  108. package/src/trails/topo-read-support.ts +368 -0
  109. package/src/trails/topo-reports.ts +809 -0
  110. package/src/trails/topo-store-support.ts +323 -0
  111. package/src/trails/topo-support.ts +247 -0
  112. package/src/trails/topo-unpin.ts +61 -0
  113. package/src/trails/topo.ts +92 -0
  114. package/src/trails/validate.ts +348 -0
  115. package/src/trails/version-lifecycle-support.ts +936 -0
  116. package/src/trails/warden-guide.ts +134 -0
  117. package/src/trails/warden.ts +598 -0
  118. package/src/trails/wayfind-diff.ts +716 -0
  119. package/src/trails/wayfind-outline.ts +876 -0
  120. package/src/trails/wayfind.ts +1319 -0
  121. package/src/versions.ts +31 -0
@@ -0,0 +1,716 @@
1
+ import { join } from 'node:path';
2
+
3
+ import {
4
+ DerivationError,
5
+ NotFoundError,
6
+ Result,
7
+ ValidationError,
8
+ trail,
9
+ } from '@ontrails/core';
10
+ import type { Result as TrailResult, TrailContext } from '@ontrails/core';
11
+ import {
12
+ deriveTopoGraphDiff,
13
+ deriveTopoGraphHash,
14
+ deriveWorkspaceView,
15
+ loadWayfinderArtifacts,
16
+ wayfinderDriftFromArtifactStatus,
17
+ } from '@ontrails/topography';
18
+ import type {
19
+ LockManifest,
20
+ LockManifestSummary,
21
+ TopoGraph,
22
+ WayfinderArtifactLoaderOptions,
23
+ WayfinderStaleReason,
24
+ WorkspaceView,
25
+ } from '@ontrails/topography';
26
+ import { z } from 'zod';
27
+
28
+ import {
29
+ assertObservableProjectApps,
30
+ assertSelectedArtifactBinding,
31
+ resolveOperatorProjectContext,
32
+ } from './project-context.js';
33
+ import type {
34
+ OperatorAppProjectContext,
35
+ OperatorWorkspaceProjectContext,
36
+ } from './project-context.js';
37
+ import {
38
+ operatorProjectContextOutput,
39
+ operatorProjectContextOutputSchema,
40
+ } from './project-context-output.js';
41
+
42
+ const artifactSourceSchema = z.object({
43
+ kind: z.literal('topoGraph'),
44
+ path: z.string(),
45
+ schemaVersion: z.number(),
46
+ });
47
+
48
+ const artifactDriftSchema = z.object({
49
+ artifacts: z
50
+ .array(z.enum(['lockManifest', 'topoGraph', 'topoStore']))
51
+ .readonly()
52
+ .optional(),
53
+ reasons: z.array(z.record(z.string(), z.unknown())).readonly().optional(),
54
+ status: z.enum(['absent', 'aligned', 'drifted']),
55
+ });
56
+
57
+ const diffEntrySchema = z.object({
58
+ change: z.enum(['added', 'removed', 'modified']),
59
+ details: z.array(z.string()).readonly(),
60
+ id: z.string(),
61
+ kind: z.enum(['entity', 'trailhead', 'resource', 'signal', 'trail']),
62
+ severity: z.enum(['info', 'warning', 'breaking']),
63
+ });
64
+
65
+ const topoDiffSchema = z.object({
66
+ breaking: z.array(diffEntrySchema).readonly(),
67
+ entries: z.array(diffEntrySchema).readonly(),
68
+ hasBreaking: z.boolean(),
69
+ info: z.array(diffEntrySchema).readonly(),
70
+ warnings: z.array(diffEntrySchema).readonly(),
71
+ });
72
+
73
+ const artifactEnvelopeSchema = z.object({
74
+ drift: artifactDriftSchema,
75
+ source: artifactSourceSchema,
76
+ });
77
+
78
+ const wayfindDiffInputSchema = z
79
+ .object({
80
+ againstDir: z
81
+ .string()
82
+ .optional()
83
+ .describe('Baseline artifact directory containing trails.lock'),
84
+ againstRootDir: z
85
+ .string()
86
+ .optional()
87
+ .describe('Baseline project root directory'),
88
+ againstTrailsDbPath: z
89
+ .string()
90
+ .optional()
91
+ .describe('Baseline trails.db path'),
92
+ app: z.string().optional().describe('Configured workspace app ID'),
93
+ dir: z
94
+ .string()
95
+ .optional()
96
+ .describe('Current artifact directory containing trails.lock'),
97
+ rootDir: z.string().optional().describe('Current project root directory'),
98
+ trailsDbPath: z.string().optional().describe('Current trails.db path'),
99
+ })
100
+ .strict()
101
+ .refine(
102
+ (input) =>
103
+ input.againstDir !== undefined || input.againstRootDir !== undefined,
104
+ {
105
+ message: 'Provide againstDir or againstRootDir for the baseline graph.',
106
+ path: ['againstDir'],
107
+ }
108
+ )
109
+ .refine(
110
+ (input) =>
111
+ input.againstDir === undefined || input.againstRootDir === undefined,
112
+ {
113
+ message: 'Provide only one of againstDir or againstRootDir.',
114
+ path: ['againstDir'],
115
+ }
116
+ );
117
+
118
+ const appWayfindDiffOutputSchema = artifactEnvelopeSchema.extend({
119
+ against: artifactEnvelopeSchema,
120
+ diff: topoDiffSchema,
121
+ kind: z.literal('app'),
122
+ project: operatorProjectContextOutputSchema,
123
+ });
124
+
125
+ const workspaceViewEvidenceSchema = z.object({
126
+ apps: z
127
+ .array(
128
+ z.object({
129
+ actualAppId: z.string().optional(),
130
+ binding: z.enum(['matched', 'mismatched', 'unavailable']),
131
+ coaching: z.string().optional(),
132
+ detail: z.string().optional(),
133
+ freshness: z.enum(['fresh', 'stale', 'unknown', 'unavailable']),
134
+ id: z.string(),
135
+ lockPath: z.string(),
136
+ provenance: z.literal('configured-app-lock'),
137
+ root: z.string(),
138
+ selected: z.boolean(),
139
+ status: z.enum(['available', 'invalid', 'missing', 'unavailable']),
140
+ })
141
+ )
142
+ .readonly(),
143
+ collectionSkips: z
144
+ .array(
145
+ z.object({
146
+ path: z.string(),
147
+ provenance: z.literal('source-collection'),
148
+ reason: z.string(),
149
+ })
150
+ )
151
+ .readonly(),
152
+ configuredAppIds: z.array(z.string()).readonly(),
153
+ configuredCompleteness: z.enum(['complete', 'partial']),
154
+ selectedAppIds: z.array(z.string()).readonly(),
155
+ selectedCompleteness: z.enum(['complete', 'partial']),
156
+ unownedLocks: z
157
+ .array(
158
+ z.object({
159
+ coaching: z.string(),
160
+ kind: z.enum([
161
+ 'forbidden-workspace-aggregate',
162
+ 'unconfigured-app-lock',
163
+ ]),
164
+ path: z.string(),
165
+ provenance: z.literal('source-collection'),
166
+ })
167
+ )
168
+ .readonly(),
169
+ });
170
+
171
+ const workspaceWayfindDiffOutputSchema = z.object({
172
+ against: z.object({
173
+ evidence: workspaceViewEvidenceSchema,
174
+ project: operatorProjectContextOutputSchema,
175
+ workspaceViewHash: z.string(),
176
+ }),
177
+ apps: z.array(
178
+ z.object({
179
+ againstTopoGraphHash: z.string(),
180
+ appId: z.string(),
181
+ currentTopoGraphHash: z.string(),
182
+ diff: topoDiffSchema,
183
+ })
184
+ ),
185
+ current: z.object({
186
+ evidence: workspaceViewEvidenceSchema,
187
+ workspaceViewHash: z.string(),
188
+ }),
189
+ kind: z.literal('workspace'),
190
+ project: operatorProjectContextOutputSchema,
191
+ });
192
+
193
+ const wayfindDiffOutputSchema = z.discriminatedUnion('kind', [
194
+ appWayfindDiffOutputSchema,
195
+ workspaceWayfindDiffOutputSchema,
196
+ ]);
197
+
198
+ type WayfindDiffOutput = z.output<typeof wayfindDiffOutputSchema>;
199
+ type WayfindDiffInput = z.output<typeof wayfindDiffInputSchema>;
200
+
201
+ interface LoadedDiffGraph {
202
+ readonly appId: string | undefined;
203
+ readonly envelope: z.output<typeof artifactEnvelopeSchema>;
204
+ readonly graph: TopoGraph;
205
+ }
206
+
207
+ const diffArtifactPath = (
208
+ options: WayfinderArtifactLoaderOptions,
209
+ cwd: string | undefined
210
+ ): string =>
211
+ join(options.dir ?? options.rootDir ?? cwd ?? process.cwd(), 'trails.lock');
212
+
213
+ const countEntries = (
214
+ topoGraph: TopoGraph,
215
+ kind: TopoGraph['entries'][number]['kind']
216
+ ): number => topoGraph.entries.filter((entry) => entry.kind === kind).length;
217
+
218
+ const summaryMatchesGraph = (
219
+ summary: LockManifestSummary,
220
+ topoGraph: TopoGraph
221
+ ): boolean =>
222
+ summary.entities === countEntries(topoGraph, 'entity') &&
223
+ summary.resources === countEntries(topoGraph, 'resource') &&
224
+ summary.signals === countEntries(topoGraph, 'signal') &&
225
+ summary.trails === countEntries(topoGraph, 'trail');
226
+
227
+ /**
228
+ * Report how a saved lock contradicts the evidence its own envelope records.
229
+ *
230
+ * Wayfinder's loader raises the same contradictions as
231
+ * `lock-manifest-hash-mismatch` and `lock-manifest-summary-mismatch` stale
232
+ * reasons, but only once a Topography store is readable beside the lock. Diff
233
+ * baselines are routinely artifact-only directories (`--against-dir`), so the
234
+ * envelope is checked directly here instead. Reasons that only mean the lock
235
+ * lags its source stay diffable; a lock whose recorded hash or summary cannot
236
+ * describe its own graph does not.
237
+ */
238
+ const lockIntegrityReason = (
239
+ lockManifest: LockManifest | null,
240
+ topoGraph: TopoGraph
241
+ ): WayfinderStaleReason['reason'] | undefined => {
242
+ if (lockManifest === null) {
243
+ return undefined;
244
+ }
245
+ const topoArtifact = lockManifest.artifacts.find(
246
+ (artifact) => artifact.role === 'topo'
247
+ );
248
+ if (
249
+ topoArtifact !== undefined &&
250
+ topoArtifact.sha256 !== deriveTopoGraphHash(topoGraph)
251
+ ) {
252
+ return 'lock-manifest-hash-mismatch';
253
+ }
254
+ return summaryMatchesGraph(lockManifest.summary, topoGraph)
255
+ ? undefined
256
+ : 'lock-manifest-summary-mismatch';
257
+ };
258
+
259
+ const loadDiffGraph = async (
260
+ options: WayfinderArtifactLoaderOptions,
261
+ cwd: string | undefined
262
+ ): Promise<TrailResult<LoadedDiffGraph, Error>> => {
263
+ const path = diffArtifactPath(options, cwd);
264
+ let load: Awaited<ReturnType<typeof loadWayfinderArtifacts>>;
265
+ try {
266
+ load = await loadWayfinderArtifacts(options);
267
+ } catch (error) {
268
+ return Result.err(
269
+ new DerivationError('Unable to load Wayfinder artifacts for diff.', {
270
+ cause: error instanceof Error ? error : new Error(String(error)),
271
+ context: { artifact: 'topoGraph', path },
272
+ })
273
+ );
274
+ }
275
+ if (
276
+ load.artifactStatus.status === 'schema-version-drift' &&
277
+ load.artifactStatus.artifact === 'topoGraph'
278
+ ) {
279
+ return Result.err(
280
+ new DerivationError(load.artifactStatus.message, {
281
+ context: {
282
+ artifact: load.artifactStatus.artifact,
283
+ artifactStatus: load.artifactStatus.status,
284
+ path,
285
+ },
286
+ })
287
+ );
288
+ }
289
+ if (load.topoGraph === null) {
290
+ return Result.err(
291
+ new NotFoundError(
292
+ `No Wayfinder TopoGraph artifact found at ${path}. Run \`trails compile\` for the selected app first.`
293
+ )
294
+ );
295
+ }
296
+ const integrityReason = lockIntegrityReason(
297
+ load.lockManifest,
298
+ load.topoGraph
299
+ );
300
+ if (integrityReason !== undefined) {
301
+ return Result.err(
302
+ new DerivationError(
303
+ `Saved Wayfinder artifact at ${path} does not match its recorded lock evidence. Run \`trails compile\` for the selected app before diffing.`,
304
+ {
305
+ context: {
306
+ artifact: 'topoGraph',
307
+ artifactStatus: load.artifactStatus.status,
308
+ path,
309
+ reason: integrityReason,
310
+ },
311
+ }
312
+ )
313
+ );
314
+ }
315
+ return Result.ok({
316
+ appId: load.lockManifest?.scope['app'],
317
+ envelope: {
318
+ drift: wayfinderDriftFromArtifactStatus(load.artifactStatus),
319
+ source: {
320
+ kind: 'topoGraph',
321
+ path,
322
+ schemaVersion: load.topoGraph.topoGraphSchemaVersion,
323
+ },
324
+ },
325
+ graph: load.topoGraph,
326
+ });
327
+ };
328
+
329
+ const assertDiffGraphAppBinding = (
330
+ loaded: LoadedDiffGraph,
331
+ expectedAppId: string,
332
+ artifact: 'baseline' | 'current'
333
+ ): TrailResult<void, ValidationError> => {
334
+ if (loaded.appId === expectedAppId) {
335
+ return Result.ok();
336
+ }
337
+ return Result.err(
338
+ new ValidationError(
339
+ `Saved ${artifact} artifact for configured app "${expectedAppId}" does not match its Config-owned identity.`,
340
+ {
341
+ context: {
342
+ actualAppId: loaded.appId,
343
+ artifact,
344
+ expectedAppId,
345
+ reason: 'invalid-binding',
346
+ },
347
+ }
348
+ )
349
+ );
350
+ };
351
+
352
+ const currentDiffOptions = (
353
+ input: WayfindDiffInput,
354
+ context: OperatorAppProjectContext
355
+ ): TrailResult<WayfinderArtifactLoaderOptions, ValidationError> => {
356
+ if (input.dir !== undefined) {
357
+ return Result.err(
358
+ new ValidationError(
359
+ 'Diff derives its current artifact from project selection. Remove --dir and use --root-dir to select the current project.',
360
+ {
361
+ context: {
362
+ appId: context.app.id,
363
+ projectRoot: context.projectRoot,
364
+ reason: 'invalid-binding',
365
+ },
366
+ }
367
+ )
368
+ );
369
+ }
370
+ if (
371
+ context.selectedExtent === 'configured-app' &&
372
+ input.trailsDbPath !== undefined
373
+ ) {
374
+ return Result.err(
375
+ new ValidationError(
376
+ 'Configured app diff derives its current artifact from --root-dir and --app. Remove --trails-db-path so it cannot bypass project selection.',
377
+ {
378
+ context: {
379
+ appId: context.app.id,
380
+ projectRoot: context.projectRoot,
381
+ reason: 'invalid-binding',
382
+ },
383
+ }
384
+ )
385
+ );
386
+ }
387
+ return Result.ok({
388
+ rootDir: context.app.rootDir,
389
+ ...(input.trailsDbPath === undefined ? {} : { path: input.trailsDbPath }),
390
+ });
391
+ };
392
+
393
+ const resolveBaselineApp = async (
394
+ input: WayfindDiffInput,
395
+ current: OperatorAppProjectContext,
396
+ cwd: string | undefined
397
+ ): Promise<
398
+ TrailResult<
399
+ | {
400
+ readonly context: OperatorAppProjectContext;
401
+ readonly options: WayfinderArtifactLoaderOptions;
402
+ }
403
+ | { readonly options: WayfinderArtifactLoaderOptions },
404
+ Error
405
+ >
406
+ > => {
407
+ if (input.againstDir !== undefined) {
408
+ return Result.ok({
409
+ options: {
410
+ dir: input.againstDir,
411
+ ...(input.againstTrailsDbPath === undefined
412
+ ? {}
413
+ : { path: input.againstTrailsDbPath }),
414
+ },
415
+ });
416
+ }
417
+ const baseline = await resolveOperatorProjectContext(
418
+ {
419
+ ...(current.app.id === undefined ? {} : { app: current.app.id }),
420
+ rootDir: input.againstRootDir,
421
+ },
422
+ { cwd }
423
+ );
424
+ if (baseline.isErr()) {
425
+ return baseline;
426
+ }
427
+ if (baseline.value.selectedExtent === 'workspace') {
428
+ return Result.err(
429
+ new ValidationError(
430
+ 'One-app diff cannot compare against a workspace extent. Select the same configured app on both project roots.',
431
+ {
432
+ context: {
433
+ appId: current.app.id,
434
+ baselineProjectRoot: baseline.value.projectRoot,
435
+ reason: 'invalid-binding',
436
+ },
437
+ }
438
+ )
439
+ );
440
+ }
441
+ const observable = await assertObservableProjectApps(baseline.value);
442
+ if (observable.isErr()) {
443
+ return observable;
444
+ }
445
+ const baselineBinding = await assertSelectedArtifactBinding(baseline.value);
446
+ if (baselineBinding.isErr()) {
447
+ return baselineBinding;
448
+ }
449
+ return Result.ok({
450
+ context: baseline.value,
451
+ options: {
452
+ rootDir: baseline.value.app.rootDir,
453
+ ...(input.againstTrailsDbPath === undefined
454
+ ? {}
455
+ : { path: input.againstTrailsDbPath }),
456
+ },
457
+ });
458
+ };
459
+
460
+ const executeAppWayfindDiff = async (
461
+ input: WayfindDiffInput,
462
+ context: OperatorAppProjectContext,
463
+ ctx: TrailContext
464
+ ): Promise<Result<WayfindDiffOutput, Error>> => {
465
+ const currentBinding = await assertSelectedArtifactBinding(context);
466
+ if (currentBinding.isErr()) {
467
+ return currentBinding;
468
+ }
469
+ const options = currentDiffOptions(input, context);
470
+ if (options.isErr()) {
471
+ return options;
472
+ }
473
+ const baseline = await resolveBaselineApp(input, context, ctx.cwd);
474
+ if (baseline.isErr()) {
475
+ return baseline;
476
+ }
477
+ const currentGraph = await loadDiffGraph(options.value, ctx.cwd);
478
+ if (currentGraph.isErr()) {
479
+ return currentGraph;
480
+ }
481
+ const baselineGraph = await loadDiffGraph(baseline.value.options, ctx.cwd);
482
+ if (baselineGraph.isErr()) {
483
+ return baselineGraph;
484
+ }
485
+ if (context.app.configured && context.app.id !== undefined) {
486
+ const currentGraphBinding = assertDiffGraphAppBinding(
487
+ currentGraph.value,
488
+ context.app.id,
489
+ 'current'
490
+ );
491
+ if (currentGraphBinding.isErr()) {
492
+ return currentGraphBinding;
493
+ }
494
+ const baselineGraphBinding = assertDiffGraphAppBinding(
495
+ baselineGraph.value,
496
+ context.app.id,
497
+ 'baseline'
498
+ );
499
+ if (baselineGraphBinding.isErr()) {
500
+ return baselineGraphBinding;
501
+ }
502
+ }
503
+ return Result.ok({
504
+ ...currentGraph.value.envelope,
505
+ against: baselineGraph.value.envelope,
506
+ diff: deriveTopoGraphDiff(
507
+ baselineGraph.value.graph,
508
+ currentGraph.value.graph
509
+ ),
510
+ kind: 'app',
511
+ project: operatorProjectContextOutput(context),
512
+ });
513
+ };
514
+
515
+ const requireCompleteWorkspaceView = (
516
+ view: WorkspaceView,
517
+ projectRoot: string,
518
+ role: 'baseline' | 'current'
519
+ ): TrailResult<WorkspaceView & { readonly workspaceViewHash: string }, Error> =>
520
+ view.workspaceViewHash !== null &&
521
+ view.evidence.configuredCompleteness === 'complete'
522
+ ? Result.ok(view as WorkspaceView & { readonly workspaceViewHash: string })
523
+ : Result.err(
524
+ new ValidationError(
525
+ `Semantic workspace diff requires a complete ${role} app-partitioned view.`,
526
+ {
527
+ context: {
528
+ evidence: view.evidence,
529
+ projectRoot,
530
+ reason: 'workspace-incomplete',
531
+ role,
532
+ },
533
+ }
534
+ )
535
+ );
536
+
537
+ const matchingWorkspaceAppIds = (
538
+ current: OperatorWorkspaceProjectContext,
539
+ baseline: OperatorWorkspaceProjectContext
540
+ ): TrailResult<readonly string[], ValidationError> => {
541
+ const currentIds = current.apps.map((app) => app.id as string).toSorted();
542
+ const baselineIds = baseline.apps.map((app) => app.id as string).toSorted();
543
+ if (JSON.stringify(currentIds) !== JSON.stringify(baselineIds)) {
544
+ return Result.err(
545
+ new ValidationError(
546
+ 'Semantic workspace diff requires the same configured app IDs on both project roots.',
547
+ {
548
+ context: {
549
+ baselineAppIds: baselineIds,
550
+ baselineProjectRoot: baseline.projectRoot,
551
+ currentAppIds: currentIds,
552
+ currentProjectRoot: current.projectRoot,
553
+ reason: 'invalid-binding',
554
+ },
555
+ }
556
+ )
557
+ );
558
+ }
559
+ return Result.ok(currentIds);
560
+ };
561
+
562
+ const executeWorkspaceWayfindDiff = async (
563
+ input: WayfindDiffInput,
564
+ context: OperatorWorkspaceProjectContext,
565
+ ctx: TrailContext
566
+ ): Promise<Result<WayfindDiffOutput, Error>> => {
567
+ if (
568
+ input.againstRootDir === undefined ||
569
+ input.againstDir !== undefined ||
570
+ input.dir !== undefined ||
571
+ input.trailsDbPath !== undefined ||
572
+ input.againstTrailsDbPath !== undefined
573
+ ) {
574
+ return Result.err(
575
+ new ValidationError(
576
+ 'Workspace diff compares two complete project roots. Use --against-root-dir and omit artifact-directory or trails.db overrides.',
577
+ {
578
+ context: {
579
+ projectRoot: context.projectRoot,
580
+ reason: 'invalid-binding',
581
+ },
582
+ }
583
+ )
584
+ );
585
+ }
586
+ const baseline = await resolveOperatorProjectContext(
587
+ { rootDir: input.againstRootDir },
588
+ { cwd: ctx.cwd }
589
+ );
590
+ if (baseline.isErr()) {
591
+ return baseline;
592
+ }
593
+ if (baseline.value.selectedExtent !== 'workspace') {
594
+ return Result.err(
595
+ new ValidationError(
596
+ 'Workspace diff requires a configured workspace at --against-root-dir.',
597
+ {
598
+ context: {
599
+ baselineProjectRoot: baseline.value.projectRoot,
600
+ reason: 'invalid-binding',
601
+ },
602
+ }
603
+ )
604
+ );
605
+ }
606
+ const appIds = matchingWorkspaceAppIds(context, baseline.value);
607
+ if (appIds.isErr()) {
608
+ return appIds;
609
+ }
610
+ const [currentView, baselineView] = await Promise.all([
611
+ deriveWorkspaceView({ identity: context.identity }),
612
+ deriveWorkspaceView({ identity: baseline.value.identity }),
613
+ ]);
614
+ const completeCurrent = requireCompleteWorkspaceView(
615
+ currentView,
616
+ context.projectRoot,
617
+ 'current'
618
+ );
619
+ if (completeCurrent.isErr()) {
620
+ return completeCurrent;
621
+ }
622
+ const completeBaseline = requireCompleteWorkspaceView(
623
+ baselineView,
624
+ baseline.value.projectRoot,
625
+ 'baseline'
626
+ );
627
+ if (completeBaseline.isErr()) {
628
+ return completeBaseline;
629
+ }
630
+ const currentById = new Map(
631
+ currentView.content.apps.map((app) => [app.id, app])
632
+ );
633
+ const baselineById = new Map(
634
+ baselineView.content.apps.map((app) => [app.id, app])
635
+ );
636
+ const apps: {
637
+ againstTopoGraphHash: string;
638
+ appId: string;
639
+ currentTopoGraphHash: string;
640
+ diff: ReturnType<typeof deriveTopoGraphDiff>;
641
+ }[] = [];
642
+ for (const appId of appIds.value) {
643
+ const currentApp = currentById.get(appId);
644
+ const baselineApp = baselineById.get(appId);
645
+ if (currentApp === undefined || baselineApp === undefined) {
646
+ return Result.err(
647
+ new ValidationError(
648
+ `Complete workspace diff view is missing configured app ${appId}.`
649
+ )
650
+ );
651
+ }
652
+ apps.push({
653
+ againstTopoGraphHash: baselineApp.topoGraphHash,
654
+ appId,
655
+ currentTopoGraphHash: currentApp.topoGraphHash,
656
+ diff: deriveTopoGraphDiff(baselineApp.topoGraph, currentApp.topoGraph),
657
+ });
658
+ }
659
+ return Result.ok({
660
+ against: {
661
+ evidence: completeBaseline.value.evidence,
662
+ project: operatorProjectContextOutput(baseline.value),
663
+ workspaceViewHash: completeBaseline.value.workspaceViewHash,
664
+ },
665
+ apps,
666
+ current: {
667
+ evidence: completeCurrent.value.evidence,
668
+ workspaceViewHash: completeCurrent.value.workspaceViewHash,
669
+ },
670
+ kind: 'workspace',
671
+ project: operatorProjectContextOutput(context),
672
+ });
673
+ };
674
+
675
+ export const wayfindDiffTrail = trail('wayfind.diff', {
676
+ cli: {
677
+ path: 'wayfind diff',
678
+ },
679
+ description:
680
+ 'Diff one selected app or two complete app-partitioned workspace views',
681
+ examples: [
682
+ {
683
+ input: { againstRootDir: '../baseline', app: 'trails' },
684
+ name: 'Diff one configured app across project roots',
685
+ },
686
+ ],
687
+ implementation: async (
688
+ input,
689
+ ctx
690
+ ): Promise<Result<WayfindDiffOutput, Error>> => {
691
+ const context = await resolveOperatorProjectContext(input, {
692
+ cwd: ctx.cwd,
693
+ });
694
+ if (context.isErr()) {
695
+ return context;
696
+ }
697
+ const observable = await assertObservableProjectApps(context.value);
698
+ if (observable.isErr()) {
699
+ return observable;
700
+ }
701
+ if (context.value.selectedExtent === 'workspace') {
702
+ const workspaceResult = await executeWorkspaceWayfindDiff(
703
+ input,
704
+ context.value,
705
+ ctx
706
+ );
707
+ return workspaceResult;
708
+ }
709
+ const appResult = await executeAppWayfindDiff(input, context.value, ctx);
710
+ return appResult;
711
+ },
712
+ input: wayfindDiffInputSchema,
713
+ intent: 'read',
714
+ output: wayfindDiffOutputSchema,
715
+ visibility: 'internal',
716
+ });