@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.
- package/CHANGELOG.md +1906 -0
- package/README.md +48 -0
- package/bin/trails.ts +3 -0
- package/package.json +57 -0
- package/src/app.ts +167 -0
- package/src/clack.ts +111 -0
- package/src/cli.ts +308 -0
- package/src/completions.ts +431 -0
- package/src/lifecycle-source-io.ts +33 -0
- package/src/load-app-mirror.ts +202 -0
- package/src/local-state-io.ts +129 -0
- package/src/mcp-app.ts +42 -0
- package/src/mcp-options.ts +92 -0
- package/src/mcp.ts +8 -0
- package/src/project-writes.ts +377 -0
- package/src/regrade/audit.ts +571 -0
- package/src/regrade/config.ts +152 -0
- package/src/regrade/history.ts +636 -0
- package/src/regrade/lifecycle.ts +76 -0
- package/src/regrade/live-api-preserve.ts +123 -0
- package/src/regrade/plan-artifact.ts +515 -0
- package/src/regrade/plan-derivation.ts +301 -0
- package/src/regrade/prepared-run.ts +259 -0
- package/src/regrade/receipt-history.ts +446 -0
- package/src/regrade/source-transaction.ts +185 -0
- package/src/release/bindings.ts +58 -0
- package/src/release/changeset-packages.ts +99 -0
- package/src/release/check.ts +1191 -0
- package/src/release/cli-bundle.ts +575 -0
- package/src/release/config.ts +73 -0
- package/src/release/contract-facts.ts +425 -0
- package/src/release/homebrew.ts +221 -0
- package/src/release/index.ts +180 -0
- package/src/release/lock-roundtrip-smoke.ts +255 -0
- package/src/release/lock-roundtrip-workspace.ts +107 -0
- package/src/release/native-bun-publish.ts +964 -0
- package/src/release/native-bun-registry.ts +848 -0
- package/src/release/notes-cli.ts +171 -0
- package/src/release/notes.ts +390 -0
- package/src/release/pack-coherence.ts +455 -0
- package/src/release/package-route-facts.ts +146 -0
- package/src/release/packed-artifacts-smoke.ts +236 -0
- package/src/release/policy.ts +1780 -0
- package/src/release/semver.ts +104 -0
- package/src/release/smoke.ts +56 -0
- package/src/release/stable-version-release.ts +80 -0
- package/src/release/wayfinder-dogfood-smoke.ts +762 -0
- package/src/release/zero-line-transition.ts +68 -0
- package/src/retired-topo-command.ts +36 -0
- package/src/run-adapter-check.ts +76 -0
- package/src/run-argv.ts +133 -0
- package/src/run-collision.ts +126 -0
- package/src/run-completions-install.ts +179 -0
- package/src/run-example.ts +149 -0
- package/src/run-examples.ts +148 -0
- package/src/run-quiet.ts +75 -0
- package/src/run-regrade-progress.ts +47 -0
- package/src/run-release-check.ts +74 -0
- package/src/run-schema.ts +74 -0
- package/src/run-trace.ts +273 -0
- package/src/run-warden.ts +39 -0
- package/src/run-watch-project.ts +52 -0
- package/src/run-watch.ts +381 -0
- package/src/run-wayfind-outline.ts +170 -0
- package/src/scaffold-version-sync.ts +183 -0
- package/src/scaffold-versions.generated.ts +12 -0
- package/src/trails/adapter-check.ts +244 -0
- package/src/trails/add-surface.ts +816 -0
- package/src/trails/add-trail.ts +141 -0
- package/src/trails/add-verify.ts +252 -0
- package/src/trails/compile.ts +118 -0
- package/src/trails/completions-complete.ts +236 -0
- package/src/trails/completions.ts +47 -0
- package/src/trails/config-explain.ts +43 -0
- package/src/trails/create-adapter.ts +785 -0
- package/src/trails/create-scaffold.ts +1215 -0
- package/src/trails/create-versions.ts +62 -0
- package/src/trails/create.ts +652 -0
- package/src/trails/deprecate.ts +59 -0
- package/src/trails/dev-clean.ts +80 -0
- package/src/trails/dev-reset.ts +48 -0
- package/src/trails/dev-stats.ts +71 -0
- package/src/trails/dev-support.ts +360 -0
- package/src/trails/doctor.ts +77 -0
- package/src/trails/draft-promote.ts +949 -0
- package/src/trails/guide.ts +106 -0
- package/src/trails/load-app.ts +1145 -0
- package/src/trails/operator-context.ts +66 -0
- package/src/trails/project-context-output.ts +304 -0
- package/src/trails/project-context.ts +613 -0
- package/src/trails/project.ts +65 -0
- package/src/trails/regrade.ts +4951 -0
- package/src/trails/release-check.ts +113 -0
- package/src/trails/release-smoke.ts +49 -0
- package/src/trails/revise.ts +53 -0
- package/src/trails/root-dir.ts +21 -0
- package/src/trails/run-example.ts +592 -0
- package/src/trails/run-examples.ts +149 -0
- package/src/trails/run.ts +496 -0
- package/src/trails/scaffold-json.ts +60 -0
- package/src/trails/scaffold-topo-identity.ts +479 -0
- package/src/trails/survey.ts +990 -0
- package/src/trails/topo-activation.ts +14 -0
- package/src/trails/topo-constants.ts +2 -0
- package/src/trails/topo-history.ts +47 -0
- package/src/trails/topo-output-schemas.ts +259 -0
- package/src/trails/topo-pin.ts +38 -0
- package/src/trails/topo-read-support.ts +368 -0
- package/src/trails/topo-reports.ts +809 -0
- package/src/trails/topo-store-support.ts +323 -0
- package/src/trails/topo-support.ts +247 -0
- package/src/trails/topo-unpin.ts +61 -0
- package/src/trails/topo.ts +92 -0
- package/src/trails/validate.ts +348 -0
- package/src/trails/version-lifecycle-support.ts +936 -0
- package/src/trails/warden-guide.ts +134 -0
- package/src/trails/warden.ts +598 -0
- package/src/trails/wayfind-diff.ts +716 -0
- package/src/trails/wayfind-outline.ts +876 -0
- package/src/trails/wayfind.ts +1319 -0
- 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
|
+
});
|