@ontrails/trails 1.0.0-beta.18 → 1.0.0-beta.19

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 (45) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +7 -10
  3. package/package.json +13 -12
  4. package/src/app.ts +14 -4
  5. package/src/cli.ts +16 -0
  6. package/src/lifecycle-source-io.ts +33 -0
  7. package/src/project-writes.ts +62 -5
  8. package/src/retired-topo-command.ts +36 -0
  9. package/src/run-adapter-check.ts +76 -0
  10. package/src/run-collision.ts +1 -0
  11. package/src/trails/adapter-check.ts +244 -0
  12. package/src/trails/add-surface.ts +18 -18
  13. package/src/trails/add-trail.ts +3 -2
  14. package/src/trails/add-verify.ts +30 -6
  15. package/src/trails/{topo-compile.ts → compile.ts} +16 -8
  16. package/src/trails/completions-complete.ts +1 -1
  17. package/src/trails/create-adapter.ts +1084 -0
  18. package/src/trails/create-scaffold.ts +243 -29
  19. package/src/trails/create.ts +118 -17
  20. package/src/trails/deprecate.ts +59 -0
  21. package/src/trails/dev-clean.ts +2 -2
  22. package/src/trails/dev-reset.ts +2 -2
  23. package/src/trails/dev-stats.ts +1 -1
  24. package/src/trails/doctor.ts +56 -0
  25. package/src/trails/draft-promote.ts +1 -0
  26. package/src/trails/guide.ts +2 -2
  27. package/src/trails/revise.ts +53 -0
  28. package/src/trails/run-example.ts +12 -7
  29. package/src/trails/run-examples.ts +3 -3
  30. package/src/trails/run.ts +7 -4
  31. package/src/trails/survey.ts +332 -25
  32. package/src/trails/topo-history.ts +1 -1
  33. package/src/trails/topo-output-schemas.ts +30 -1
  34. package/src/trails/topo-pin.ts +3 -2
  35. package/src/trails/topo-read-support.ts +49 -8
  36. package/src/trails/topo-reports.ts +39 -22
  37. package/src/trails/topo-store-support.ts +62 -16
  38. package/src/trails/topo-support.ts +1 -1
  39. package/src/trails/topo-unpin.ts +2 -2
  40. package/src/trails/topo.ts +2 -2
  41. package/src/trails/{topo-verify.ts → validate.ts} +7 -7
  42. package/src/trails/version-lifecycle-support.ts +945 -0
  43. package/src/trails/warden-guide.ts +8 -0
  44. package/src/trails/warden.ts +18 -2
  45. package/src/versions.ts +4 -1
@@ -20,6 +20,7 @@ import {
20
20
  createTopoStore,
21
21
  deriveTopoGraphDiff,
22
22
  deriveTopoGraph,
23
+ resolveTopoGraphVersionReference,
23
24
  TOPO_GRAPH_SCHEMA_VERSION,
24
25
  readTopoGraph,
25
26
  } from '@ontrails/topographer';
@@ -84,6 +85,21 @@ interface SurveyDiffReport {
84
85
  readonly warnings: readonly DiffEntry[];
85
86
  }
86
87
 
88
+ interface DiffInput {
89
+ readonly against?: string | undefined;
90
+ readonly breakingOnly?: boolean | undefined;
91
+ readonly breaks?: boolean | undefined;
92
+ readonly forces?: boolean | undefined;
93
+ readonly module?: string | undefined;
94
+ readonly rootDir?: string | undefined;
95
+ readonly target?: string | undefined;
96
+ }
97
+
98
+ interface ParsedDiffTarget {
99
+ readonly id: string;
100
+ readonly versions?: ReadonlySet<number> | undefined;
101
+ }
102
+
87
103
  const formatDiff = (diff: DiffResult, against: string): SurveyDiffReport => ({
88
104
  against,
89
105
  breaking: diff.breaking,
@@ -93,6 +109,252 @@ const formatDiff = (diff: DiffResult, against: string): SurveyDiffReport => ({
93
109
  warnings: diff.warnings,
94
110
  });
95
111
 
112
+ const partitionDiffEntries = (entries: readonly DiffEntry[]): DiffResult => {
113
+ const sorted = [...entries].toSorted((left, right) =>
114
+ left.id.localeCompare(right.id)
115
+ );
116
+ const breaking = sorted.filter((entry) => entry.severity === 'breaking');
117
+ const warnings = sorted.filter((entry) => entry.severity === 'warning');
118
+ const info = sorted.filter((entry) => entry.severity === 'info');
119
+
120
+ return {
121
+ breaking,
122
+ entries: sorted,
123
+ hasBreaking: breaking.length > 0,
124
+ info,
125
+ warnings,
126
+ };
127
+ };
128
+
129
+ const parseVersionRange = (
130
+ reference: string
131
+ ): ReadonlySet<number> | undefined => {
132
+ const match = /^(\d+)\.\.(\d+)$/.exec(reference);
133
+ if (match === null) {
134
+ return undefined;
135
+ }
136
+ const start = Number(match[1]);
137
+ const end = Number(match[2]);
138
+ if (start < 1 || end < start) {
139
+ throw new ValidationError(
140
+ `Diff version range must use ascending positive versions: ${reference}`
141
+ );
142
+ }
143
+
144
+ return new Set(
145
+ Array.from({ length: end - start + 1 }, (_value, index) => start + index)
146
+ );
147
+ };
148
+
149
+ const findDiffTargetEntry = (
150
+ previous: TopoGraph,
151
+ current: TopoGraph,
152
+ id: string
153
+ ) =>
154
+ current.entries.find((entry) => entry.id === id) ??
155
+ previous.entries.find((entry) => entry.id === id);
156
+
157
+ const parseDiffTarget = (
158
+ previous: TopoGraph,
159
+ current: TopoGraph,
160
+ target: string | undefined
161
+ ): Result<ParsedDiffTarget | undefined, Error> => {
162
+ if (target === undefined || target.length === 0) {
163
+ return Result.ok();
164
+ }
165
+
166
+ const separator = target.lastIndexOf('@');
167
+ const id = separator === -1 ? target : target.slice(0, separator);
168
+ const reference =
169
+ separator === -1 ? undefined : target.slice(separator + 1).trim();
170
+ if (id.length === 0 || reference === '') {
171
+ return Result.err(
172
+ new ValidationError('Diff target must use trail.id or trail.id@version')
173
+ );
174
+ }
175
+
176
+ const entry = findDiffTargetEntry(previous, current, id);
177
+ if (entry === undefined) {
178
+ return Result.err(new NotFoundError(`Trail not found for diff: ${id}`));
179
+ }
180
+
181
+ if (reference === undefined) {
182
+ return Result.ok({ id });
183
+ }
184
+
185
+ try {
186
+ const range = parseVersionRange(reference);
187
+ if (range !== undefined) {
188
+ return Result.ok({ id, versions: range });
189
+ }
190
+
191
+ return Result.ok({
192
+ id,
193
+ versions: new Set([
194
+ resolveTopoGraphVersionReference(entry, reference).version,
195
+ ]),
196
+ });
197
+ } catch (error: unknown) {
198
+ return Result.err(
199
+ error instanceof Error ? error : new Error(String(error))
200
+ );
201
+ }
202
+ };
203
+
204
+ const detailVersions = (detail: string): readonly number[] => {
205
+ const match = /^(?:Live version|Version) (\d+)\b/.exec(detail);
206
+ if (match !== null) {
207
+ return [Number(match[1])];
208
+ }
209
+
210
+ const supportMatch = /^Supported versions (?:added|removed): (.+)$/.exec(
211
+ detail
212
+ );
213
+ if (supportMatch === null) {
214
+ return [];
215
+ }
216
+
217
+ return (supportMatch[1] ?? '')
218
+ .split(',')
219
+ .map((part) => Number(part.trim()))
220
+ .filter((version) => Number.isInteger(version) && version > 0);
221
+ };
222
+
223
+ type DiffSeverity = DiffEntry['severity'];
224
+
225
+ const severityRank: Record<DiffSeverity, number> = {
226
+ breaking: 2,
227
+ info: 0,
228
+ warning: 1,
229
+ };
230
+
231
+ const higherSeverity = (
232
+ left: DiffSeverity,
233
+ right: DiffSeverity
234
+ ): DiffSeverity => (severityRank[right] > severityRank[left] ? right : left);
235
+
236
+ const versionStatus = (detail: string): string | undefined =>
237
+ /^Version \d+ (?:added|removed) \(([^)]+)\)$/.exec(detail)?.[1];
238
+
239
+ const visibleDetailSeverity = (detail: string): DiffSeverity => {
240
+ if (detail.startsWith('Force event ')) {
241
+ return 'warning';
242
+ }
243
+ if (detail.startsWith('Supported versions removed: ')) {
244
+ return 'breaking';
245
+ }
246
+ if (detail.startsWith('Supported versions added: ')) {
247
+ return 'info';
248
+ }
249
+ if (
250
+ /^Live version \d+ (?:added without examples|example coverage removed)$/.test(
251
+ detail
252
+ )
253
+ ) {
254
+ return 'warning';
255
+ }
256
+ if (detail.startsWith('Live version ') && detail.includes(' examples: ')) {
257
+ return 'info';
258
+ }
259
+ if (/^Version \d+ status changed: .+ -> archived$/.test(detail)) {
260
+ return 'warning';
261
+ }
262
+ if (detail.startsWith('Version ') && detail.includes(' status changed: ')) {
263
+ return 'info';
264
+ }
265
+
266
+ const status = versionStatus(detail);
267
+ if (detail.startsWith('Version ') && detail.includes(' removed (')) {
268
+ return status === 'archived' ? 'warning' : 'breaking';
269
+ }
270
+ if (detail.startsWith('Version ') && detail.includes(' added (')) {
271
+ return status === 'archived' ? 'info' : 'warning';
272
+ }
273
+ if (
274
+ /^Version \d+ (?:kind changed:|Required (?:input|contour) field ".+" added|(?:Input|Output|Contour) field ".+" (?:removed|type changed:|changed from optional to required))/.test(
275
+ detail
276
+ )
277
+ ) {
278
+ return 'breaking';
279
+ }
280
+ if (
281
+ /^Version \d+ (?:marker changed:|Optional (?:input|contour) field ".+" added|Output field ".+" added)/.test(
282
+ detail
283
+ )
284
+ ) {
285
+ return 'info';
286
+ }
287
+
288
+ return 'info';
289
+ };
290
+
291
+ const visibleDetailsSeverity = (details: readonly string[]): DiffSeverity => {
292
+ let severity: DiffSeverity = 'info';
293
+ for (const detail of details) {
294
+ severity = higherSeverity(severity, visibleDetailSeverity(detail));
295
+ }
296
+ return severity;
297
+ };
298
+
299
+ const detailsChanged = (
300
+ previous: readonly string[],
301
+ next: readonly string[]
302
+ ): boolean =>
303
+ previous.length !== next.length ||
304
+ previous.some((detail, index) => detail !== next[index]);
305
+
306
+ const filterDetails = (
307
+ details: readonly string[],
308
+ target: ParsedDiffTarget | undefined,
309
+ forcesOnly: boolean
310
+ ): readonly string[] => {
311
+ const visible = forcesOnly
312
+ ? details.filter((detail) => detail.startsWith('Force event '))
313
+ : [...details];
314
+ if (target?.versions === undefined || forcesOnly) {
315
+ return visible;
316
+ }
317
+
318
+ return visible.filter((detail) => {
319
+ const versions = detailVersions(detail);
320
+ return versions.some((version) => target.versions?.has(version));
321
+ });
322
+ };
323
+
324
+ const filterDiff = (
325
+ diff: DiffResult,
326
+ target: ParsedDiffTarget | undefined,
327
+ options: Pick<DiffInput, 'breakingOnly' | 'breaks' | 'forces'>
328
+ ): DiffResult => {
329
+ const entries = diff.entries.flatMap((entry): DiffEntry[] => {
330
+ if (target !== undefined && entry.id !== target.id) {
331
+ return [];
332
+ }
333
+ const details = filterDetails(
334
+ entry.details,
335
+ target,
336
+ options.forces === true
337
+ );
338
+ if (details.length === 0) {
339
+ return [];
340
+ }
341
+ return [
342
+ {
343
+ ...entry,
344
+ details,
345
+ severity: detailsChanged(entry.details, details)
346
+ ? visibleDetailsSeverity(details)
347
+ : entry.severity,
348
+ },
349
+ ];
350
+ });
351
+
352
+ const partitioned = partitionDiffEntries(entries);
353
+ return options.breakingOnly === true || options.breaks === true
354
+ ? partitionDiffEntries(partitioned.breaking)
355
+ : partitioned;
356
+ };
357
+
96
358
  const createDiffExampleInput = (): {
97
359
  readonly against: string;
98
360
  readonly module: string;
@@ -191,7 +453,7 @@ const readAgainstTopoGraph = async (
191
453
  return map === null
192
454
  ? Result.err(
193
455
  new NotFoundError(
194
- 'No saved TopoGraph found. Run `trails topo compile` first.'
456
+ 'No saved TopoGraph found. Run `trails compile` first.'
195
457
  )
196
458
  )
197
459
  : Result.ok({ against: 'saved', map });
@@ -218,21 +480,25 @@ const readAgainstTopoGraph = async (
218
480
  const buildSurveyDiff = async (
219
481
  app: Topo,
220
482
  rootDir: string,
221
- breakingOnly: boolean,
222
- against?: string | undefined
483
+ input: DiffInput
223
484
  ): Promise<Result<SurveyDiffReport, Error>> => {
224
485
  const currentMap = deriveTopoGraph(app);
225
- const previous = await readAgainstTopoGraph(rootDir, against);
486
+ const previous = await readAgainstTopoGraph(rootDir, input.against);
226
487
  if (previous.isErr()) {
227
488
  return previous;
228
489
  }
229
490
 
230
- const diff = deriveTopoGraphDiff(previous.value.map, currentMap);
231
- return Result.ok(
232
- breakingOnly
233
- ? formatDiff({ ...diff, info: [], warnings: [] }, previous.value.against)
234
- : formatDiff(diff, previous.value.against)
491
+ const target = parseDiffTarget(previous.value.map, currentMap, input.target);
492
+ if (target.isErr()) {
493
+ return target;
494
+ }
495
+
496
+ const diff = filterDiff(
497
+ deriveTopoGraphDiff(previous.value.map, currentMap),
498
+ target.value,
499
+ input
235
500
  );
501
+ return Result.ok(formatDiff(diff, previous.value.against));
236
502
  };
237
503
 
238
504
  const buildSurveyLookup = (
@@ -404,6 +670,32 @@ const diffOutput = z.object({
404
670
  warnings: z.array(diffEntryOutput),
405
671
  });
406
672
 
673
+ const diffInputSchema = z.object({
674
+ against: z
675
+ .string()
676
+ .min(1)
677
+ .optional()
678
+ .describe(
679
+ 'Saved TopoGraph target: "saved", a workspace path (topo.lock, .json file, or directory with topo.lock), then a pin/snapshot id'
680
+ ),
681
+ breakingOnly: z
682
+ .boolean()
683
+ .default(false)
684
+ .describe('Legacy alias for --breaks; only show breaking changes'),
685
+ breaks: z.boolean().default(false).describe('Only show breaking changes'),
686
+ forces: z
687
+ .boolean()
688
+ .default(false)
689
+ .describe('Only show graph force audit events'),
690
+ module: z.string().optional().describe('Path to the app module'),
691
+ rootDir: z.string().optional().describe('Workspace root directory'),
692
+ target: z
693
+ .string()
694
+ .min(1)
695
+ .optional()
696
+ .describe('Trail or trail version target, such as user.create@1..2'),
697
+ });
698
+
407
699
  const surveyMatchOutput = z.discriminatedUnion('kind', [
408
700
  z.object({
409
701
  detail: trailDetailOutput,
@@ -537,7 +829,7 @@ export const surveySurfacesTrail = trail('survey.surfaces', {
537
829
  export const surveyDiffTrail = trail('survey.diff', {
538
830
  blaze: async (input, ctx) =>
539
831
  withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
540
- buildSurveyDiff(app, rootDir, input.breakingOnly, input.against)
832
+ buildSurveyDiff(app, rootDir, input)
541
833
  ),
542
834
  description: 'Diff the current topo against a saved TopoGraph',
543
835
  examples: [
@@ -562,21 +854,36 @@ export const surveyDiffTrail = trail('survey.diff', {
562
854
  name: 'Reject empty breaking-only target',
563
855
  },
564
856
  ],
565
- input: z.object({
566
- against: z
567
- .string()
568
- .min(1)
569
- .optional()
570
- .describe(
571
- 'Saved TopoGraph target: "saved", a workspace path (topo.lock, .json file, or directory with topo.lock), then a pin/snapshot id'
572
- ),
573
- breakingOnly: z
574
- .boolean()
575
- .default(false)
576
- .describe('Only show breaking changes'),
577
- module: z.string().optional().describe('Path to the app module'),
578
- rootDir: z.string().optional().describe('Workspace root directory'),
579
- }),
857
+ input: diffInputSchema,
858
+ intent: 'read',
859
+ output: diffOutput,
860
+ });
861
+
862
+ export const diffTrail = trail('diff', {
863
+ args: ['target'],
864
+ blaze: async (input, ctx) =>
865
+ withResolvedSurveyApp(input, ctx.cwd, (app, rootDir) =>
866
+ buildSurveyDiff(app, rootDir, input)
867
+ ),
868
+ description: 'Diff the current topo against a saved TopoGraph',
869
+ examples: [
870
+ {
871
+ description: 'Compare current topo to a saved TopoGraph directory',
872
+ input: createDiffExampleInput(),
873
+ name: 'Diff against baseline',
874
+ },
875
+ {
876
+ description: 'Show only breaking contract drift',
877
+ input: { ...createDiffExampleInput(), breaks: true },
878
+ name: 'Breaking changes',
879
+ },
880
+ {
881
+ description: 'Show graph-only force audit events',
882
+ input: { ...createDiffExampleInput(), forces: true },
883
+ name: 'Force audit events',
884
+ },
885
+ ],
886
+ input: diffInputSchema,
580
887
  intent: 'read',
581
888
  output: diffOutput,
582
889
  });
@@ -13,7 +13,7 @@ export const topoHistoryTrail = trail('topo.history', {
13
13
  blaze: (input, ctx) => {
14
14
  const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
15
15
  if (rootDirResult.isErr()) {
16
- return Result.err(rootDirResult.error);
16
+ return rootDirResult;
17
17
  }
18
18
  const rootDir = rootDirResult.value;
19
19
  return Result.ok(listTopoHistory({ limit: input.limit, rootDir }));
@@ -137,6 +137,32 @@ export const shippedSurfaceInventoryOutput = z.object({
137
137
  .readonly(),
138
138
  });
139
139
 
140
+ const trailVersionEntryOutput = z.object({
141
+ composes: z.array(z.string()).readonly().optional(),
142
+ detours: z
143
+ .array(
144
+ z.object({
145
+ maxAttempts: z.number(),
146
+ on: z.string(),
147
+ })
148
+ )
149
+ .readonly()
150
+ .optional(),
151
+ exampleCount: z.number(),
152
+ examples: z.array(z.unknown()).readonly().optional(),
153
+ input: jsonSchemaOutput,
154
+ kind: z.enum(['revision', 'fork']),
155
+ marker: z.string(),
156
+ output: jsonSchemaOutput,
157
+ resources: z.array(z.string()).readonly().optional(),
158
+ status: z
159
+ .object({
160
+ state: z.enum(['deprecated', 'archived']),
161
+ })
162
+ .catchall(z.unknown())
163
+ .optional(),
164
+ });
165
+
140
166
  export const trailDetailOutput = z.object({
141
167
  activatedBy: z.array(z.string()).readonly(),
142
168
  activates: z.array(z.string()).readonly(),
@@ -163,9 +189,9 @@ export const trailDetailOutput = z.object({
163
189
  topo: z.array(z.string()).readonly(),
164
190
  trail: z.array(z.string()).readonly(),
165
191
  }),
192
+ composes: z.array(z.string()).readonly(),
166
193
  contourDetails: z.array(contourDetailOutput).readonly(),
167
194
  contours: z.array(z.string()).readonly(),
168
- crosses: z.array(z.string()).readonly(),
169
195
  description: z.string().nullable(),
170
196
  detours: z
171
197
  .array(
@@ -190,8 +216,11 @@ export const trailDetailOutput = z.object({
190
216
  pattern: z.string().nullable(),
191
217
  resources: z.array(z.string()).readonly(),
192
218
  safety: z.string(),
219
+ supports: z.array(z.number()).readonly(),
193
220
  surfaceProjections: z.array(surfaceProjectionOutput).readonly(),
194
221
  surfaces: z.array(z.string()).readonly(),
222
+ version: z.number().nullable(),
223
+ versions: z.record(z.string(), trailVersionEntryOutput),
195
224
  });
196
225
 
197
226
  export const resourceDetailOutput = z.object({
@@ -13,12 +13,12 @@ export const topoPinTrail = trail('topo.pin', {
13
13
  blaze: async (input, ctx) => {
14
14
  const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
15
15
  if (rootDirResult.isErr()) {
16
- return Result.err(rootDirResult.error);
16
+ return rootDirResult;
17
17
  }
18
18
  const rootDir = rootDirResult.value;
19
19
  const leaseResult = await tryLoadFreshAppLease(input.module, rootDir);
20
20
  if (leaseResult.isErr()) {
21
- return Result.err(leaseResult.error);
21
+ return leaseResult;
22
22
  }
23
23
  const lease = leaseResult.value;
24
24
  try {
@@ -48,4 +48,5 @@ export const topoPinTrail = trail('topo.pin', {
48
48
  output: z.object({
49
49
  snapshot: topoSnapshotOutput,
50
50
  }),
51
+ permit: { scopes: ['topo:write'] },
51
52
  });
@@ -18,7 +18,15 @@ import {
18
18
  SURFACE_LAYER_NAMES_KEY,
19
19
  ValidationError,
20
20
  } from '@ontrails/core';
21
- import { deriveTopoGraph, readLockManifest } from '@ontrails/topographer';
21
+ import {
22
+ deriveTopoGraph,
23
+ deriveTopoGraphDiff,
24
+ deriveTopoGraphHash,
25
+ readLockManifest,
26
+ readTopoGraph,
27
+ stripTopoGraphForces,
28
+ } from '@ontrails/topographer';
29
+ import type { TopoGraph } from '@ontrails/topographer';
22
30
 
23
31
  import type {
24
32
  BriefReport,
@@ -28,6 +36,7 @@ import type {
28
36
  TrailDetailReport,
29
37
  } from './topo-reports.js';
30
38
  import {
39
+ countTrailExamples,
31
40
  deriveBriefReport,
32
41
  deriveResourceDetail,
33
42
  deriveSignalDetail,
@@ -36,7 +45,7 @@ import {
36
45
  } from './topo-reports.js';
37
46
  import type { ActivationGraphReport } from './topo-activation.js';
38
47
  import { deriveActivationGraph } from './topo-activation.js';
39
- import type { TopoSummaryReport, TopoVerifyReport } from './topo-support.js';
48
+ import type { TopoSummaryReport, TopoValidateReport } from './topo-support.js';
40
49
  import { deriveRootDir, LOCK_PATH } from './topo-support.js';
41
50
  import { deriveCurrentTopoExport } from './topo-store-support.js';
42
51
 
@@ -129,7 +138,7 @@ export const buildCurrentGuideEntries = (
129
138
  .list()
130
139
  .map((trail) => ({
131
140
  description: trail.description ?? '(no description)',
132
- exampleCount: trail.examples?.length ?? 0,
141
+ exampleCount: countTrailExamples(trail),
133
142
  id: trail.id,
134
143
  kind: 'trail' as const,
135
144
  }))
@@ -209,10 +218,10 @@ export const buildCurrentTopoMatches = (
209
218
  return matches;
210
219
  };
211
220
 
212
- export const verifyCurrentTopo = async (
221
+ export const validateCurrentTopo = async (
213
222
  app: Topo,
214
223
  options?: { readonly rootDir?: string }
215
- ): Promise<Result<TopoVerifyReport, Error>> => {
224
+ ): Promise<Result<TopoValidateReport, Error>> => {
216
225
  const rootDir = deriveRootDir(options?.rootDir);
217
226
  let lockManifest: Awaited<ReturnType<typeof readLockManifest>>;
218
227
  try {
@@ -234,7 +243,7 @@ export const verifyCurrentTopo = async (
234
243
  if (lockManifest === null) {
235
244
  return Result.err(
236
245
  new NotFoundError(
237
- 'No committed trails.lock found. Run `trails topo compile` first.'
246
+ 'No committed trails.lock found. Run `trails compile` first.'
238
247
  )
239
248
  );
240
249
  }
@@ -243,6 +252,9 @@ export const verifyCurrentTopo = async (
243
252
  if (currentExport.isErr()) {
244
253
  return currentExport;
245
254
  }
255
+ const currentTopo = JSON.parse(
256
+ currentExport.value.topoGraphJson
257
+ ) as TopoGraph;
246
258
  const currentHash = currentExport.value.topoGraphHash;
247
259
  const topoArtifact = lockManifest.artifacts.find(
248
260
  (artifact) => artifact.role === 'topo' && artifact.path === 'topo.lock'
@@ -250,15 +262,44 @@ export const verifyCurrentTopo = async (
250
262
  if (topoArtifact === undefined) {
251
263
  return Result.err(
252
264
  new NotFoundError(
253
- 'No topo.lock artifact found in trails.lock. Run `trails topo compile` first.'
265
+ 'No topo.lock artifact found in trails.lock. Run `trails compile` first.'
254
266
  )
255
267
  );
256
268
  }
257
269
 
258
270
  if (topoArtifact.sha256 !== currentHash) {
271
+ const committedTopo = await readTopoGraph({
272
+ dir: deriveTrailsDir({ rootDir }),
273
+ });
274
+ if (committedTopo !== null) {
275
+ const committedHash = deriveTopoGraphHash(committedTopo);
276
+ const forceStrippedHash = deriveTopoGraphHash(
277
+ stripTopoGraphForces(committedTopo)
278
+ );
279
+ if (
280
+ committedHash === topoArtifact.sha256 &&
281
+ forceStrippedHash === currentHash
282
+ ) {
283
+ return Result.ok({
284
+ committedHash: topoArtifact.sha256,
285
+ currentHash,
286
+ lockPath: LOCK_PATH,
287
+ stale: false,
288
+ });
289
+ }
290
+ }
291
+ const breakingSummary =
292
+ committedTopo === null
293
+ ? ''
294
+ : (() => {
295
+ const diff = deriveTopoGraphDiff(committedTopo, currentTopo);
296
+ return diff.breaking.length > 0
297
+ ? ` Breaking changes detected: ${diff.breaking.length}.`
298
+ : '';
299
+ })();
259
300
  return Result.err(
260
301
  new ConflictError(
261
- 'trails.lock is stale. Run `trails topo compile` to refresh it.'
302
+ `trails.lock is stale. Run \`trails compile\` to refresh it.${breakingSummary}`
262
303
  )
263
304
  );
264
305
  }