@ontrails/core 1.0.0-beta.13 → 1.0.0-beta.14

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 (59) hide show
  1. package/.turbo/turbo-lint.log +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +2 -1
  4. package/dist/derive.d.ts +6 -0
  5. package/dist/derive.d.ts.map +1 -1
  6. package/dist/derive.js +29 -5
  7. package/dist/derive.js.map +1 -1
  8. package/dist/draft.d.ts +28 -0
  9. package/dist/draft.d.ts.map +1 -0
  10. package/dist/draft.js +156 -0
  11. package/dist/draft.js.map +1 -0
  12. package/dist/index.d.ts +6 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/topo-saves.d.ts +47 -0
  17. package/dist/internal/topo-saves.d.ts.map +1 -0
  18. package/dist/internal/topo-saves.js +310 -0
  19. package/dist/internal/topo-saves.js.map +1 -0
  20. package/dist/internal/topo-store-read.d.ts +67 -0
  21. package/dist/internal/topo-store-read.d.ts.map +1 -0
  22. package/dist/internal/topo-store-read.js +222 -0
  23. package/dist/internal/topo-store-read.js.map +1 -0
  24. package/dist/internal/topo-store.d.ts +12 -0
  25. package/dist/internal/topo-store.d.ts.map +1 -0
  26. package/dist/internal/topo-store.js +571 -0
  27. package/dist/internal/topo-store.js.map +1 -0
  28. package/dist/internal/trails-db.d.ts +16 -0
  29. package/dist/internal/trails-db.d.ts.map +1 -0
  30. package/dist/internal/trails-db.js +118 -0
  31. package/dist/internal/trails-db.js.map +1 -0
  32. package/dist/topo-store.d.ts +48 -0
  33. package/dist/topo-store.d.ts.map +1 -0
  34. package/dist/topo-store.js +175 -0
  35. package/dist/topo-store.js.map +1 -0
  36. package/dist/validate-established-topo.d.ts +76 -0
  37. package/dist/validate-established-topo.d.ts.map +1 -0
  38. package/dist/validate-established-topo.js +43 -0
  39. package/dist/validate-established-topo.js.map +1 -0
  40. package/dist/validate-topo.d.ts.map +1 -1
  41. package/dist/validate-topo.js +5 -3
  42. package/dist/validate-topo.js.map +1 -1
  43. package/package.json +4 -1
  44. package/src/__tests__/derive.test.ts +58 -1
  45. package/src/__tests__/topo-store-read.test.ts +251 -0
  46. package/src/__tests__/topo-store.test.ts +469 -0
  47. package/src/__tests__/trails-db.test.ts +191 -0
  48. package/src/__tests__/validate-topo.test.ts +167 -0
  49. package/src/derive.ts +39 -8
  50. package/src/draft.ts +334 -0
  51. package/src/index.ts +30 -1
  52. package/src/internal/topo-saves.ts +429 -0
  53. package/src/internal/topo-store-read.ts +473 -0
  54. package/src/internal/topo-store.ts +1087 -0
  55. package/src/internal/trails-db.ts +189 -0
  56. package/src/topo-store.ts +301 -0
  57. package/src/validate-established-topo.ts +63 -0
  58. package/src/validate-topo.ts +7 -3
  59. package/tsconfig.tsbuildinfo +1 -1
@@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import { z } from 'zod';
4
4
 
5
+ import { analyzeDraftState, isDraftId } from '../draft.js';
6
+ import { validateEstablishedTopo } from '../validate-established-topo.js';
5
7
  import { provision } from '../provision.js';
6
8
  import { Result } from '../result.js';
7
9
  import { topo } from '../topo.js';
@@ -78,6 +80,17 @@ describe('validateTopo', () => {
78
80
  });
79
81
 
80
82
  describe('trail crossing', () => {
83
+ test('draft crossings are allowed in the authored graph', () => {
84
+ const app = topo('app', {
85
+ exportTrail: mockTrail('entity.export', {
86
+ crosses: ['_draft.entity.prepare'],
87
+ }),
88
+ });
89
+
90
+ const result = validateTopo(app);
91
+ expect(result.isOk()).toBe(true);
92
+ });
93
+
81
94
  test('trail crossing a non-existent trail fails', () => {
82
95
  const app = topo('app', {
83
96
  onboard: mockTrail('entity.onboard', {
@@ -150,6 +163,18 @@ describe('validateTopo', () => {
150
163
  });
151
164
 
152
165
  describe('provision declarations', () => {
166
+ test('draft provision references are allowed in the authored graph', () => {
167
+ const db = mockProvision('_draft.db.main');
168
+ const app = topo('app', {
169
+ show: mockTrail('entity.show', {
170
+ provisions: [db],
171
+ }),
172
+ });
173
+
174
+ const result = validateTopo(app);
175
+ expect(result.isOk()).toBe(true);
176
+ });
177
+
153
178
  test('trail declaring a registered provision passes', () => {
154
179
  const db = mockProvision('db.main');
155
180
  const app = topo('app', {
@@ -319,4 +344,146 @@ describe('validateTopo', () => {
319
344
  const issues = extractIssues(result);
320
345
  expect(issues).toHaveLength(4);
321
346
  });
347
+
348
+ describe('signal origins', () => {
349
+ test('draft signal origins are allowed in the authored graph', () => {
350
+ const app = topo('app', {
351
+ updated: mockEvent('entity.updated', ['_draft.entity.prepare']),
352
+ });
353
+
354
+ const result = validateTopo(app);
355
+ expect(result.isOk()).toBe(true);
356
+ });
357
+ });
358
+ });
359
+
360
+ describe('draft state analysis', () => {
361
+ test('detects declared draft ids', () => {
362
+ const app = topo('app', {
363
+ draftTrail: mockTrail('_draft.entity.prepare'),
364
+ });
365
+
366
+ const analysis = analyzeDraftState(app);
367
+
368
+ expect(analysis.declaredDraftIds.has('_draft.entity.prepare')).toBe(true);
369
+ expect(analysis.contaminatedIds.has('_draft.entity.prepare')).toBe(true);
370
+ expect(analysis.findings.map((finding) => finding.rule)).toContain(
371
+ 'draft-id'
372
+ );
373
+ });
374
+
375
+ test('propagates contamination through dependencies', () => {
376
+ const app = topo('app', {
377
+ exportTrail: mockTrail('entity.export', {
378
+ crosses: ['entity.prepare'],
379
+ }),
380
+ prepareTrail: mockTrail('entity.prepare', {
381
+ crosses: ['_draft.entity.store'],
382
+ }),
383
+ });
384
+
385
+ const analysis = analyzeDraftState(app);
386
+ const exportFinding = analysis.findings.find(
387
+ (finding) => finding.id === 'entity.export'
388
+ );
389
+
390
+ expect(analysis.contaminatedIds.has('entity.prepare')).toBe(true);
391
+ expect(analysis.contaminatedIds.has('entity.export')).toBe(true);
392
+ expect(exportFinding).toBeDefined();
393
+ expect(exportFinding?.rule).toBe('draft-contamination');
394
+ });
395
+ });
396
+
397
+ describe('validateEstablishedTopo', () => {
398
+ test('passes for an established topo', () => {
399
+ const app = topo('app', {
400
+ show: mockTrail('entity.show'),
401
+ });
402
+
403
+ const result = validateEstablishedTopo(app);
404
+ expect(result.isOk()).toBe(true);
405
+ });
406
+
407
+ test('fails when draft declarations remain', () => {
408
+ const app = topo('app', {
409
+ draftTrail: mockTrail('_draft.entity.prepare'),
410
+ });
411
+
412
+ const result = validateEstablishedTopo(app);
413
+ expect(result.isErr()).toBe(true);
414
+
415
+ const issues = extractIssues(result);
416
+ expect(issues).toHaveLength(1);
417
+ expect(issues[0]?.rule).toBe('draft-id');
418
+ });
419
+
420
+ test('fails when established trails depend on draft state', () => {
421
+ const app = topo('app', {
422
+ exportTrail: mockTrail('entity.export', {
423
+ crosses: ['_draft.entity.prepare'],
424
+ }),
425
+ });
426
+
427
+ const result = validateEstablishedTopo(app);
428
+ expect(result.isErr()).toBe(true);
429
+
430
+ const issues = extractIssues(result);
431
+ expect(issues).toHaveLength(1);
432
+ expect(issues[0]?.rule).toBe('draft-contamination');
433
+ expect(issues[0]?.message).toContain('entity.export');
434
+ });
435
+
436
+ test('fails when authored structural validation still fails', () => {
437
+ const app = topo('app', {
438
+ exportTrail: mockTrail('entity.export', {
439
+ crosses: ['entity.missing'],
440
+ }),
441
+ });
442
+
443
+ const result = validateEstablishedTopo(app);
444
+ expect(result.isErr()).toBe(true);
445
+
446
+ const issues = extractIssues(result);
447
+ expect(issues).toHaveLength(1);
448
+ expect(issues[0]?.rule).toBe('cross-exists');
449
+ });
450
+
451
+ test('fails when provision declarations are not established in the topo', () => {
452
+ const app = topo('app', {
453
+ show: mockTrail('entity.show', {
454
+ provisions: [mockProvision('db.main')],
455
+ }),
456
+ });
457
+
458
+ const result = validateEstablishedTopo(app);
459
+ expect(result.isErr()).toBe(true);
460
+
461
+ const issues = extractIssues(result);
462
+ expect(issues).toHaveLength(1);
463
+ expect(issues[0]?.rule).toBe('provision-exists');
464
+ });
465
+
466
+ test('allows authoring-only example issues outside projection checks', () => {
467
+ const app = topo('app', {
468
+ show: mockTrail('entity.show', {
469
+ examples: [
470
+ {
471
+ expected: { ok: true },
472
+ input: { name: 'test' },
473
+ name: 'Has expected',
474
+ },
475
+ ],
476
+ }),
477
+ });
478
+
479
+ const result = validateEstablishedTopo(app);
480
+ expect(result.isOk()).toBe(true);
481
+ });
482
+ });
483
+
484
+ describe('isDraftId', () => {
485
+ test('recognizes the reserved draft prefix', () => {
486
+ expect(isDraftId('_draft.entity.show')).toBe(true);
487
+ expect(isDraftId('entity.show')).toBe(false);
488
+ });
322
489
  });
package/src/derive.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { z } from 'zod';
9
9
 
10
+ import { ValidationError } from './errors.js';
11
+
10
12
  // ---------------------------------------------------------------------------
11
13
  // Public types
12
14
  // ---------------------------------------------------------------------------
@@ -140,14 +142,21 @@ interface DerivedFieldType {
140
142
  type: Field['type'];
141
143
  }
142
144
 
143
- const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
145
+ const fieldTypeByDef: Record<
146
+ string,
147
+ (s: ZodInternals) => DerivedFieldType | null
148
+ > = {
144
149
  array: (s) => {
145
150
  const element = s._zod.def['element'] as unknown as ZodInternals;
146
- const elementType = element._zod.def['type'] as string;
151
+ const { inner } = unwrap(element);
152
+ const elementType = inner._zod.def['type'] as string;
147
153
  if (elementType === 'enum') {
148
- const entries = element._zod.def['entries'] as Record<string, string>;
154
+ const entries = inner._zod.def['entries'] as Record<string, string>;
149
155
  return { options: Object.values(entries), type: 'multiselect' };
150
156
  }
157
+ if (elementType !== 'number' && elementType !== 'string') {
158
+ return null;
159
+ }
151
160
  return {
152
161
  options: undefined,
153
162
  type: elementType === 'number' ? 'number[]' : 'string[]',
@@ -163,10 +172,10 @@ const fieldTypeByDef: Record<string, (s: ZodInternals) => DerivedFieldType> = {
163
172
  };
164
173
 
165
174
  /** Derive field type and raw options from the unwrapped Zod def. */
166
- const deriveFieldType = (s: ZodInternals): DerivedFieldType => {
175
+ const deriveFieldType = (s: ZodInternals): DerivedFieldType | null => {
167
176
  const defType = s._zod.def['type'] as string;
168
177
  const derive = fieldTypeByDef[defType];
169
- return derive ? derive(s) : { options: undefined, type: 'string' };
178
+ return derive ? derive(s) : null;
170
179
  };
171
180
 
172
181
  /** Build options array, merging with overrides when present. */
@@ -189,6 +198,22 @@ const buildOptions = (
189
198
  });
190
199
  };
191
200
 
201
+ /**
202
+ * Derive the canonical ordered CLI path from a trail ID.
203
+ *
204
+ * @throws {ValidationError} if the trail ID contains empty segments (e.g. consecutive dots).
205
+ */
206
+ export const deriveCliPath = (trailId: string): string[] => {
207
+ const segments = trailId.split('.');
208
+ const emptyIndex = segments.findIndex((s) => s.length === 0);
209
+ if (emptyIndex !== -1) {
210
+ throw new ValidationError(
211
+ `Trail ID "${trailId}" contains an empty segment at position ${emptyIndex}`
212
+ );
213
+ }
214
+ return segments;
215
+ };
216
+
192
217
  // ---------------------------------------------------------------------------
193
218
  // Public API
194
219
  // ---------------------------------------------------------------------------
@@ -198,9 +223,13 @@ const deriveField = (
198
223
  key: string,
199
224
  value: ZodInternals,
200
225
  overrides?: Record<string, FieldOverride>
201
- ): Field => {
226
+ ): Field | null => {
202
227
  const { inner, required, defaultValue, description } = unwrap(value);
203
- const { type, options: rawOptions } = deriveFieldType(inner);
228
+ const derived = deriveFieldType(inner);
229
+ if (!derived) {
230
+ return null;
231
+ }
232
+ const { type, options: rawOptions } = derived;
204
233
  const override = overrides?.[key];
205
234
  const label = override?.label ?? description ?? humanize(key);
206
235
  const options = buildOptions(rawOptions, override?.options);
@@ -229,5 +258,7 @@ export const deriveFields = (
229
258
  const fields = Object.entries(shape).map(([key, value]) =>
230
259
  deriveField(key, value, overrides)
231
260
  );
232
- return fields.toSorted((a, b) => a.name.localeCompare(b.name));
261
+ return fields
262
+ .filter((field): field is Field => field !== null)
263
+ .toSorted((a, b) => a.name.localeCompare(b.name));
233
264
  };
package/src/draft.ts ADDED
@@ -0,0 +1,334 @@
1
+ import { ValidationError } from './errors.js';
2
+ import type { AnySignal } from './event.js';
3
+ import type { AnyProvision } from './provision.js';
4
+ import { Result } from './result.js';
5
+ import type { Topo } from './topo.js';
6
+ import type { AnyTrail } from './trail.js';
7
+
8
+ export const DRAFT_ID_PREFIX = '_draft.';
9
+
10
+ export type DraftDependencyKind =
11
+ | 'cross'
12
+ | 'detour'
13
+ | 'provision'
14
+ | 'replaced-by'
15
+ | 'signal-from';
16
+
17
+ export interface DraftDependency {
18
+ readonly fromId: string;
19
+ readonly kind: DraftDependencyKind;
20
+ readonly toId: string;
21
+ }
22
+
23
+ export interface DraftFinding {
24
+ readonly id: string;
25
+ readonly kind: 'provision' | 'signal' | 'trail' | 'unknown';
26
+ readonly message: string;
27
+ readonly rule: 'draft-contamination' | 'draft-id';
28
+ readonly via?: DraftDependencyKind | undefined;
29
+ readonly dependsOn?: string | undefined;
30
+ }
31
+
32
+ export interface DraftStateAnalysis {
33
+ readonly contaminatedIds: ReadonlySet<string>;
34
+ readonly declaredDraftIds: ReadonlySet<string>;
35
+ readonly dependencies: readonly DraftDependency[];
36
+ readonly findings: readonly DraftFinding[];
37
+ }
38
+
39
+ interface DraftReason {
40
+ readonly dependsOn: string;
41
+ readonly via: DraftDependencyKind;
42
+ }
43
+
44
+ type TopoNode = AnyProvision | AnySignal | AnyTrail;
45
+
46
+ const replacedByTarget = (value: TopoNode): string | undefined => {
47
+ const raw = value as unknown as { replacedBy?: unknown };
48
+ return typeof raw.replacedBy === 'string' ? raw.replacedBy : undefined;
49
+ };
50
+
51
+ export const isDraftId = (id: string): boolean =>
52
+ id.startsWith(DRAFT_ID_PREFIX);
53
+
54
+ const dependenciesFromIds = (
55
+ fromId: string,
56
+ toIds: readonly string[],
57
+ kind: DraftDependencyKind
58
+ ): DraftDependency[] =>
59
+ toIds.map((toId) => ({
60
+ fromId,
61
+ kind,
62
+ toId,
63
+ }));
64
+
65
+ const dependencyFromTarget = (
66
+ fromId: string,
67
+ toId: string | undefined,
68
+ kind: DraftDependencyKind
69
+ ): DraftDependency[] =>
70
+ toId === undefined
71
+ ? []
72
+ : [
73
+ {
74
+ fromId,
75
+ kind,
76
+ toId,
77
+ },
78
+ ];
79
+
80
+ const dependenciesFromDetours = (
81
+ fromId: string,
82
+ detours: Record<string, string[]>
83
+ ): DraftDependency[] =>
84
+ Object.values(detours).flatMap((targets) =>
85
+ dependenciesFromIds(fromId, targets, 'detour')
86
+ );
87
+
88
+ const trailDependencies = (trail: AnyTrail): DraftDependency[] => {
89
+ const { detours } = trail as unknown as {
90
+ detours?: Record<string, string[]>;
91
+ };
92
+
93
+ return [
94
+ ...dependenciesFromIds(trail.id, trail.crosses, 'cross'),
95
+ ...dependenciesFromIds(
96
+ trail.id,
97
+ trail.provisions.map(({ id }) => id),
98
+ 'provision'
99
+ ),
100
+ ...(detours === undefined
101
+ ? []
102
+ : dependenciesFromDetours(trail.id, detours)),
103
+ ...dependencyFromTarget(trail.id, replacedByTarget(trail), 'replaced-by'),
104
+ ];
105
+ };
106
+
107
+ const signalDependencies = (signal: AnySignal): DraftDependency[] => [
108
+ ...dependenciesFromIds(signal.id, signal.from ?? [], 'signal-from'),
109
+ ...dependencyFromTarget(signal.id, replacedByTarget(signal), 'replaced-by'),
110
+ ];
111
+
112
+ const provisionDependencies = (provision: AnyProvision): DraftDependency[] =>
113
+ dependencyFromTarget(
114
+ provision.id,
115
+ replacedByTarget(provision),
116
+ 'replaced-by'
117
+ );
118
+
119
+ const nodeKind = (
120
+ id: string,
121
+ trails: ReadonlyMap<string, AnyTrail>,
122
+ signals: ReadonlyMap<string, AnySignal>,
123
+ provisions: ReadonlyMap<string, AnyProvision>
124
+ ): DraftFinding['kind'] => {
125
+ if (trails.has(id)) {
126
+ return 'trail';
127
+ }
128
+ if (signals.has(id)) {
129
+ return 'signal';
130
+ }
131
+ if (provisions.has(id)) {
132
+ return 'provision';
133
+ }
134
+ return 'unknown';
135
+ };
136
+
137
+ const displayKind = (kind: DraftFinding['kind']): string =>
138
+ kind === 'unknown' ? 'Node' : kind[0]?.toUpperCase() + kind.slice(1);
139
+
140
+ const draftIdsFromKeys = (keys: Iterable<string>): string[] =>
141
+ [...keys].filter(isDraftId);
142
+
143
+ const collectDeclaredDraftIds = (topo: Topo): ReadonlySet<string> =>
144
+ new Set([
145
+ ...draftIdsFromKeys(topo.trails.keys()),
146
+ ...draftIdsFromKeys(topo.signals.keys()),
147
+ ...draftIdsFromKeys(topo.provisions.keys()),
148
+ ]);
149
+
150
+ const findingForDraftId = (
151
+ id: string,
152
+ kind: DraftFinding['kind']
153
+ ): DraftFinding => ({
154
+ id,
155
+ kind,
156
+ message: `${displayKind(id ? kind : 'unknown')} "${id}" is draft and cannot appear in the established graph.`,
157
+ rule: 'draft-id',
158
+ });
159
+
160
+ const findingForContamination = (
161
+ id: string,
162
+ kind: DraftFinding['kind'],
163
+ reason: DraftReason
164
+ ): DraftFinding => {
165
+ const dependencyLabel = isDraftId(reason.dependsOn)
166
+ ? `draft "${reason.dependsOn}"`
167
+ : `draft-contaminated "${reason.dependsOn}"`;
168
+
169
+ return {
170
+ dependsOn: reason.dependsOn,
171
+ id,
172
+ kind,
173
+ message:
174
+ `Established ${kind} "${id}" depends on ${dependencyLabel} ` +
175
+ `via ${reason.via} and cannot appear in the established graph.`,
176
+ rule: 'draft-contamination',
177
+ via: reason.via,
178
+ };
179
+ };
180
+
181
+ const contaminationReason = (
182
+ dependency: DraftDependency,
183
+ contaminatedIds: ReadonlySet<string>
184
+ ): DraftReason | undefined => {
185
+ if (contaminatedIds.has(dependency.fromId)) {
186
+ return undefined;
187
+ }
188
+
189
+ if (!isDraftId(dependency.toId) && !contaminatedIds.has(dependency.toId)) {
190
+ return undefined;
191
+ }
192
+
193
+ return {
194
+ dependsOn: dependency.toId,
195
+ via: dependency.kind,
196
+ };
197
+ };
198
+
199
+ const markContaminatedDependency = (
200
+ dependency: DraftDependency,
201
+ contaminatedIds: Set<string>,
202
+ reasons: Map<string, DraftReason>
203
+ ): boolean => {
204
+ const reason = contaminationReason(dependency, contaminatedIds);
205
+
206
+ if (reason === undefined) {
207
+ return false;
208
+ }
209
+
210
+ contaminatedIds.add(dependency.fromId);
211
+ reasons.set(dependency.fromId, reason);
212
+ return true;
213
+ };
214
+
215
+ const propagateContaminatedIds = (
216
+ declaredDraftIds: ReadonlySet<string>,
217
+ dependencies: readonly DraftDependency[]
218
+ ): { contaminatedIds: Set<string>; reasons: Map<string, DraftReason> } => {
219
+ const contaminatedIds = new Set<string>(declaredDraftIds);
220
+ const reasons = new Map<string, DraftReason>();
221
+
222
+ const visit = (): void => {
223
+ if (
224
+ dependencies.some((dependency) =>
225
+ markContaminatedDependency(dependency, contaminatedIds, reasons)
226
+ )
227
+ ) {
228
+ visit();
229
+ }
230
+ };
231
+
232
+ visit();
233
+ return { contaminatedIds, reasons };
234
+ };
235
+
236
+ const contaminationFindingForId = (
237
+ id: string,
238
+ declaredDraftIds: ReadonlySet<string>,
239
+ reasons: ReadonlyMap<string, DraftReason>,
240
+ trails: ReadonlyMap<string, AnyTrail>,
241
+ signals: ReadonlyMap<string, AnySignal>,
242
+ provisions: ReadonlyMap<string, AnyProvision>
243
+ ): DraftFinding | undefined => {
244
+ if (declaredDraftIds.has(id)) {
245
+ return undefined;
246
+ }
247
+
248
+ const reason = reasons.get(id);
249
+ if (reason === undefined) {
250
+ return undefined;
251
+ }
252
+
253
+ return findingForContamination(
254
+ id,
255
+ nodeKind(id, trails, signals, provisions),
256
+ reason
257
+ );
258
+ };
259
+
260
+ const collectFindings = (
261
+ declaredDraftIds: ReadonlySet<string>,
262
+ contaminatedIds: ReadonlySet<string>,
263
+ reasons: ReadonlyMap<string, DraftReason>,
264
+ trails: ReadonlyMap<string, AnyTrail>,
265
+ signals: ReadonlyMap<string, AnySignal>,
266
+ provisions: ReadonlyMap<string, AnyProvision>
267
+ ): DraftFinding[] => [
268
+ ...[...declaredDraftIds]
269
+ .toSorted()
270
+ .map((id) =>
271
+ findingForDraftId(id, nodeKind(id, trails, signals, provisions))
272
+ ),
273
+ ...[...contaminatedIds].toSorted().flatMap((id) => {
274
+ const finding = contaminationFindingForId(
275
+ id,
276
+ declaredDraftIds,
277
+ reasons,
278
+ trails,
279
+ signals,
280
+ provisions
281
+ );
282
+
283
+ return finding === undefined ? [] : [finding];
284
+ }),
285
+ ];
286
+
287
+ const collectDependencies = (topo: Topo): DraftDependency[] => [
288
+ ...[...topo.trails.values()].flatMap(trailDependencies),
289
+ ...[...topo.signals.values()].flatMap(signalDependencies),
290
+ ...[...topo.provisions.values()].flatMap(provisionDependencies),
291
+ ];
292
+
293
+ export const analyzeDraftState = (topo: Topo): DraftStateAnalysis => {
294
+ const declaredDraftIds = collectDeclaredDraftIds(topo);
295
+ const dependencies = collectDependencies(topo);
296
+ const { contaminatedIds, reasons } = propagateContaminatedIds(
297
+ declaredDraftIds,
298
+ dependencies
299
+ );
300
+ const findings = collectFindings(
301
+ declaredDraftIds,
302
+ contaminatedIds,
303
+ reasons,
304
+ topo.trails,
305
+ topo.signals,
306
+ topo.provisions
307
+ );
308
+
309
+ return {
310
+ contaminatedIds,
311
+ declaredDraftIds,
312
+ dependencies,
313
+ findings,
314
+ };
315
+ };
316
+
317
+ export const validateEstablishedTopo = (
318
+ topo: Topo
319
+ ): Result<void, ValidationError> => {
320
+ const analysis = analyzeDraftState(topo);
321
+
322
+ if (analysis.findings.length === 0) {
323
+ return Result.ok();
324
+ }
325
+
326
+ return Result.err(
327
+ new ValidationError(
328
+ `Established topo validation failed with ${analysis.findings.length} draft issue(s)`,
329
+ {
330
+ context: { issues: analysis.findings },
331
+ }
332
+ )
333
+ );
334
+ };
package/src/index.ts CHANGED
@@ -82,17 +82,46 @@ export type { AnyEvent, Event, EventSpec } from './event.js';
82
82
  // Topo
83
83
  export { topo } from './topo.js';
84
84
  export type { Topo } from './topo.js';
85
+ export {
86
+ createMockTopoStore,
87
+ createTopoStore,
88
+ topoStore,
89
+ } from './topo-store.js';
90
+ export type {
91
+ MockTopoStoreSeed,
92
+ ReadOnlyTopoStore,
93
+ TopoStoreExportRecord,
94
+ TopoStoreProvisionRecord,
95
+ TopoStoreRef,
96
+ TopoStoreTrailDetailRecord,
97
+ TopoStoreTrailRecord,
98
+ } from './topo-store.js';
99
+
100
+ // Draft state
101
+ export {
102
+ DRAFT_ID_PREFIX,
103
+ analyzeDraftState,
104
+ isDraftId,
105
+ validateEstablishedTopo as validateDraftFreeTopo,
106
+ } from './draft.js';
107
+ export type {
108
+ DraftDependency,
109
+ DraftDependencyKind,
110
+ DraftFinding,
111
+ DraftStateAnalysis,
112
+ } from './draft.js';
85
113
 
86
114
  // Topo validation
87
115
  export { validateTopo } from './validate-topo.js';
88
116
  export type { TopoIssue } from './validate-topo.js';
117
+ export { validateEstablishedTopo } from './validate-established-topo.js';
89
118
 
90
119
  // Gate
91
120
  export { composeGates } from './gate.js';
92
121
  export type { Gate } from './gate.js';
93
122
 
94
123
  // Derive
95
- export { deriveFields } from './derive.js';
124
+ export { deriveCliPath, deriveFields } from './derive.js';
96
125
  export type { Field, FieldOverride } from './derive.js';
97
126
 
98
127
  // Execute