@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.
- package/.turbo/turbo-lint.log +1 -1
- package/CHANGELOG.md +6 -0
- package/README.md +2 -1
- package/dist/derive.d.ts +6 -0
- package/dist/derive.d.ts.map +1 -1
- package/dist/derive.js +29 -5
- package/dist/derive.js.map +1 -1
- package/dist/draft.d.ts +28 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +156 -0
- package/dist/draft.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/topo-saves.d.ts +47 -0
- package/dist/internal/topo-saves.d.ts.map +1 -0
- package/dist/internal/topo-saves.js +310 -0
- package/dist/internal/topo-saves.js.map +1 -0
- package/dist/internal/topo-store-read.d.ts +67 -0
- package/dist/internal/topo-store-read.d.ts.map +1 -0
- package/dist/internal/topo-store-read.js +222 -0
- package/dist/internal/topo-store-read.js.map +1 -0
- package/dist/internal/topo-store.d.ts +12 -0
- package/dist/internal/topo-store.d.ts.map +1 -0
- package/dist/internal/topo-store.js +571 -0
- package/dist/internal/topo-store.js.map +1 -0
- package/dist/internal/trails-db.d.ts +16 -0
- package/dist/internal/trails-db.d.ts.map +1 -0
- package/dist/internal/trails-db.js +118 -0
- package/dist/internal/trails-db.js.map +1 -0
- package/dist/topo-store.d.ts +48 -0
- package/dist/topo-store.d.ts.map +1 -0
- package/dist/topo-store.js +175 -0
- package/dist/topo-store.js.map +1 -0
- package/dist/validate-established-topo.d.ts +76 -0
- package/dist/validate-established-topo.d.ts.map +1 -0
- package/dist/validate-established-topo.js +43 -0
- package/dist/validate-established-topo.js.map +1 -0
- package/dist/validate-topo.d.ts.map +1 -1
- package/dist/validate-topo.js +5 -3
- package/dist/validate-topo.js.map +1 -1
- package/package.json +4 -1
- package/src/__tests__/derive.test.ts +58 -1
- package/src/__tests__/topo-store-read.test.ts +251 -0
- package/src/__tests__/topo-store.test.ts +469 -0
- package/src/__tests__/trails-db.test.ts +191 -0
- package/src/__tests__/validate-topo.test.ts +167 -0
- package/src/derive.ts +39 -8
- package/src/draft.ts +334 -0
- package/src/index.ts +30 -1
- package/src/internal/topo-saves.ts +429 -0
- package/src/internal/topo-store-read.ts +473 -0
- package/src/internal/topo-store.ts +1087 -0
- package/src/internal/trails-db.ts +189 -0
- package/src/topo-store.ts +301 -0
- package/src/validate-established-topo.ts +63 -0
- package/src/validate-topo.ts +7 -3
- 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<
|
|
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
|
|
151
|
+
const { inner } = unwrap(element);
|
|
152
|
+
const elementType = inner._zod.def['type'] as string;
|
|
147
153
|
if (elementType === 'enum') {
|
|
148
|
-
const entries =
|
|
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) :
|
|
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
|
|
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
|
|
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
|