@ontrails/core 1.0.0-beta.30 → 1.0.0-beta.39
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 +63 -0
- package/package.json +1 -1
- package/src/context.ts +18 -4
- package/src/derive.ts +30 -3
- package/src/diagnostics.ts +21 -0
- package/src/draft.ts +18 -10
- package/src/glob.ts +81 -0
- package/src/index.ts +79 -4
- package/src/path-scope.ts +66 -0
- package/src/path-security.ts +10 -2
- package/src/resource-config.ts +16 -4
- package/src/runtime-builtins.ts +69 -0
- package/src/sha256.ts +136 -0
- package/src/signal-diagnostics.ts +4 -4
- package/src/surface-filter.ts +3 -78
- package/src/surface-overlay.ts +694 -0
- package/src/trail-id-glob.ts +15 -0
- package/src/trail.ts +78 -0
- package/src/trails-db.ts +28 -22
- package/src/validate-established-topo.ts +2 -2
- package/src/validate-topo.ts +215 -64
- package/src/webhook.ts +194 -11
- package/src/workspace.ts +182 -15
package/src/trail.ts
CHANGED
|
@@ -315,6 +315,84 @@ export type TrailVersions<
|
|
|
315
315
|
>
|
|
316
316
|
>;
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Spec for {@link forkVersion}: a fork entry whose blaze signature is owned
|
|
320
|
+
* by the entry's own schemas instead of falling back to `unknown`.
|
|
321
|
+
*/
|
|
322
|
+
export interface TrailVersionForkSpec<
|
|
323
|
+
TInputSchema extends z.ZodType,
|
|
324
|
+
TOutputSchema extends z.ZodType,
|
|
325
|
+
TComposeInputSchema extends z.ZodType | undefined = undefined,
|
|
326
|
+
> {
|
|
327
|
+
/** The historical blaze, typed by this entry's schemas. */
|
|
328
|
+
readonly blaze: Implementation<
|
|
329
|
+
BlazeInput<
|
|
330
|
+
z.output<TInputSchema>,
|
|
331
|
+
ComposeSchemaOutput<TComposeInputSchema>
|
|
332
|
+
>,
|
|
333
|
+
z.output<TOutputSchema>
|
|
334
|
+
>;
|
|
335
|
+
readonly composeInput?: TComposeInputSchema | undefined;
|
|
336
|
+
readonly composes?: readonly (string | AnyTrail)[] | undefined;
|
|
337
|
+
readonly detours?:
|
|
338
|
+
| readonly Detour<
|
|
339
|
+
z.output<TInputSchema>,
|
|
340
|
+
z.output<TOutputSchema>,
|
|
341
|
+
TrailsError
|
|
342
|
+
>[]
|
|
343
|
+
| undefined;
|
|
344
|
+
readonly examples?:
|
|
345
|
+
| readonly TrailExample<z.input<TInputSchema>, z.output<TOutputSchema>>[]
|
|
346
|
+
| undefined;
|
|
347
|
+
readonly input: TInputSchema;
|
|
348
|
+
readonly output: TOutputSchema;
|
|
349
|
+
readonly resources?: readonly AnyResource[] | undefined;
|
|
350
|
+
readonly status?: TrailVersionStatus | undefined;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Author a fork version entry with a blaze typed by the entry's own schemas.
|
|
355
|
+
*
|
|
356
|
+
* `TrailVersions` fixes every entry's generics to `unknown`, so a fork blaze
|
|
357
|
+
* written inline receives `unknown` input and authors end up re-parsing the
|
|
358
|
+
* already-validated value just to narrow it. This helper threads the entry's
|
|
359
|
+
* `input`/`output` schemas into the blaze signature and erases the generics
|
|
360
|
+
* on the way out. The erasure is sound because the fork pipeline validates
|
|
361
|
+
* raw input against this entry's own `input` schema before dispatching to
|
|
362
|
+
* the entry blaze (see `createForkTrailVersion` in execute.ts).
|
|
363
|
+
*
|
|
364
|
+
* @example
|
|
365
|
+
* ```ts
|
|
366
|
+
* const gearV1Input = z.object({ name: z.string(), weightOz: z.number() });
|
|
367
|
+
* const gearV1Output = z.object({ id: z.string(), weightOz: z.number() });
|
|
368
|
+
*
|
|
369
|
+
* const gearCreate = trail('gear.create', {
|
|
370
|
+
* // ... current v2 contract ...
|
|
371
|
+
* version: 2,
|
|
372
|
+
* versions: {
|
|
373
|
+
* 1: forkVersion({
|
|
374
|
+
* blaze: (input) =>
|
|
375
|
+
* // input is { name: string; weightOz: number } — no re-parse
|
|
376
|
+
* Result.ok({ id: input.name, weightOz: input.weightOz }),
|
|
377
|
+
* input: gearV1Input,
|
|
378
|
+
* output: gearV1Output,
|
|
379
|
+
* }),
|
|
380
|
+
* },
|
|
381
|
+
* });
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
export const forkVersion = <
|
|
385
|
+
TInputSchema extends z.ZodType,
|
|
386
|
+
TOutputSchema extends z.ZodType,
|
|
387
|
+
TComposeInputSchema extends z.ZodType | undefined = undefined,
|
|
388
|
+
>(
|
|
389
|
+
spec: TrailVersionForkSpec<TInputSchema, TOutputSchema, TComposeInputSchema>
|
|
390
|
+
): TrailVersionForkEntry =>
|
|
391
|
+
// Erasing the per-entry generics narrows function parameters, which TS
|
|
392
|
+
// cannot express without a conversion. Safe: the fork pipeline re-validates
|
|
393
|
+
// input against `spec.input` before the blaze runs.
|
|
394
|
+
spec as unknown as TrailVersionForkEntry;
|
|
395
|
+
|
|
318
396
|
export const getTrailVersionEntryKind = (
|
|
319
397
|
entry: TrailVersionEntry
|
|
320
398
|
): TrailVersionEntryKind => {
|
package/src/trails-db.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { Database } from 'bun:sqlite';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
6
2
|
|
|
7
3
|
import { NotFoundError } from './errors.js';
|
|
4
|
+
import { loadRuntimeBuiltin } from './runtime-builtins.js';
|
|
5
|
+
import { sha256Hex } from './sha256.js';
|
|
6
|
+
|
|
7
|
+
// Altitude ruling (TRL-1198, ADR-0051 lens): trails-db stays core-owned
|
|
8
|
+
// shared framework infrastructure (ADR-0014) and stays on the barrel —
|
|
9
|
+
// topographer, tracing, warden, wayfinder, and the operator app all
|
|
10
|
+
// consume it from `@ontrails/core`. What it may NOT do is assume runtime
|
|
11
|
+
// capabilities eagerly: `bun:sqlite` and the node builtins load lazily at
|
|
12
|
+
// first use so the barrel's module graph stays execution-portable.
|
|
13
|
+
const sqlite = () => loadRuntimeBuiltin('bun:sqlite');
|
|
14
|
+
const fs = () => loadRuntimeBuiltin('node:fs');
|
|
15
|
+
const os = () => loadRuntimeBuiltin('node:os');
|
|
16
|
+
const nodePath = () => loadRuntimeBuiltin('node:path');
|
|
8
17
|
|
|
9
18
|
const TRAILS_DIR = '.trails';
|
|
10
19
|
const TRAILS_DB_FILE = 'trails.db';
|
|
@@ -46,7 +55,7 @@ interface SchemaVersionRow {
|
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const deriveRootDir = (rootDir?: string): string =>
|
|
49
|
-
resolve(rootDir ?? process.cwd());
|
|
58
|
+
nodePath().resolve(rootDir ?? process.cwd());
|
|
50
59
|
|
|
51
60
|
const sanitizeProjectKeyName = (name: string): string => {
|
|
52
61
|
const normalized = name.replaceAll(/[^a-zA-Z0-9._-]+/g, '-');
|
|
@@ -54,33 +63,30 @@ const sanitizeProjectKeyName = (name: string): string => {
|
|
|
54
63
|
};
|
|
55
64
|
|
|
56
65
|
const projectHash = (rootDir: string): string =>
|
|
57
|
-
|
|
58
|
-
.update(rootDir)
|
|
59
|
-
.digest('hex')
|
|
60
|
-
.slice(0, PROJECT_KEY_HASH_LENGTH);
|
|
66
|
+
sha256Hex(rootDir).slice(0, PROJECT_KEY_HASH_LENGTH);
|
|
61
67
|
|
|
62
68
|
export const deriveTrailsProjectKey = (
|
|
63
69
|
options?: TrailsDbLocationOptions
|
|
64
70
|
): string => {
|
|
65
71
|
const rootDir = deriveRootDir(options?.rootDir);
|
|
66
|
-
return `${sanitizeProjectKeyName(basename(rootDir))}-${projectHash(rootDir)}`;
|
|
72
|
+
return `${sanitizeProjectKeyName(nodePath().basename(rootDir))}-${projectHash(rootDir)}`;
|
|
67
73
|
};
|
|
68
74
|
|
|
69
75
|
export const deriveTrailsStateHome = (
|
|
70
76
|
options?: TrailsDbLocationOptions
|
|
71
77
|
): string => {
|
|
72
78
|
const env = options?.env ?? process.env;
|
|
73
|
-
return resolve(
|
|
79
|
+
return nodePath().resolve(
|
|
74
80
|
env['TRAILS_STATE_HOME'] ??
|
|
75
81
|
env['XDG_STATE_HOME'] ??
|
|
76
|
-
join(homedir(), '.local', 'state')
|
|
82
|
+
nodePath().join(os().homedir(), '.local', 'state')
|
|
77
83
|
);
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
export const deriveTrailsStateDir = (
|
|
81
87
|
options?: TrailsDbLocationOptions
|
|
82
88
|
): string =>
|
|
83
|
-
join(
|
|
89
|
+
nodePath().join(
|
|
84
90
|
deriveTrailsStateHome(options),
|
|
85
91
|
TRAILS_STORE_DIR,
|
|
86
92
|
TRAILS_PROJECTS_DIR,
|
|
@@ -88,15 +94,15 @@ export const deriveTrailsStateDir = (
|
|
|
88
94
|
);
|
|
89
95
|
|
|
90
96
|
export const deriveTrailsDir = (options?: TrailsDbLocationOptions): string =>
|
|
91
|
-
join(deriveRootDir(options?.rootDir), TRAILS_DIR);
|
|
97
|
+
nodePath().join(deriveRootDir(options?.rootDir), TRAILS_DIR);
|
|
92
98
|
|
|
93
99
|
export const deriveTrailsDbPath = (options?: TrailsDbLocationOptions): string =>
|
|
94
100
|
options?.path
|
|
95
|
-
? resolve(options.path)
|
|
96
|
-
: join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
|
|
101
|
+
? nodePath().resolve(options.path)
|
|
102
|
+
: nodePath().join(deriveTrailsStateDir(options), TRAILS_DB_FILE);
|
|
97
103
|
|
|
98
104
|
const ensureDbParentDir = (dbPath: string): void => {
|
|
99
|
-
mkdirSync(dirname(dbPath), { recursive: true });
|
|
105
|
+
fs().mkdirSync(nodePath().dirname(dbPath), { recursive: true });
|
|
100
106
|
};
|
|
101
107
|
|
|
102
108
|
/**
|
|
@@ -108,7 +114,7 @@ const ensureDbParentDir = (dbPath: string): void => {
|
|
|
108
114
|
*/
|
|
109
115
|
export const ensureTrailsWorkspace = (rootDir: string): void => {
|
|
110
116
|
const trailsDir = deriveTrailsDir({ rootDir });
|
|
111
|
-
mkdirSync(trailsDir, { recursive: true });
|
|
117
|
+
fs().mkdirSync(trailsDir, { recursive: true });
|
|
112
118
|
};
|
|
113
119
|
|
|
114
120
|
const initializeWritePragmas = (db: Database): void => {
|
|
@@ -168,7 +174,7 @@ export const openWriteTrailsDb = (
|
|
|
168
174
|
|
|
169
175
|
ensureDbParentDir(dbPath);
|
|
170
176
|
|
|
171
|
-
const db = new Database(dbPath, { create: true });
|
|
177
|
+
const db = new (sqlite().Database)(dbPath, { create: true });
|
|
172
178
|
initializeWritePragmas(db);
|
|
173
179
|
ensureSchemaVersionTable(db);
|
|
174
180
|
return db;
|
|
@@ -178,12 +184,12 @@ export const openReadTrailsDb = (
|
|
|
178
184
|
options?: TrailsDbLocationOptions
|
|
179
185
|
): Database => {
|
|
180
186
|
const dbPath = deriveTrailsDbPath(options);
|
|
181
|
-
if (!existsSync(dbPath)) {
|
|
187
|
+
if (!fs().existsSync(dbPath)) {
|
|
182
188
|
throw new NotFoundError(
|
|
183
189
|
`Trails database not found at "${dbPath}". Run a write operation first to initialize it.`
|
|
184
190
|
);
|
|
185
191
|
}
|
|
186
|
-
const db = new Database(dbPath, { readonly: true });
|
|
192
|
+
const db = new (sqlite().Database)(dbPath, { readonly: true });
|
|
187
193
|
initializeReadPragmas(db);
|
|
188
194
|
return db;
|
|
189
195
|
};
|
|
@@ -2,7 +2,7 @@ import { ValidationError } from './errors.js';
|
|
|
2
2
|
import { Result } from './result.js';
|
|
3
3
|
import type { Topo } from './topo.js';
|
|
4
4
|
import { validateDraftFreeTopo } from './draft.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { TopoDiagnostic } from './validate-topo.js';
|
|
6
6
|
import { validateTopo } from './validate-topo.js';
|
|
7
7
|
|
|
8
8
|
const PROJECTION_BLOCKING_RULES = new Set([
|
|
@@ -27,7 +27,7 @@ const keepProjectionBlockingIssues = (
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const issues = (
|
|
30
|
-
result.error.context as { issues?: readonly
|
|
30
|
+
result.error.context as { issues?: readonly TopoDiagnostic[] } | undefined
|
|
31
31
|
)?.issues;
|
|
32
32
|
const remainingIssues = issues?.filter((issue) =>
|
|
33
33
|
PROJECTION_BLOCKING_RULES.has(issue.rule)
|
package/src/validate-topo.ts
CHANGED
|
@@ -34,18 +34,80 @@ import { validateWebhookSource } from './webhook.js';
|
|
|
34
34
|
// Issue shape
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
37
|
-
export
|
|
37
|
+
export type TopoDiagnosticCode = 'topo.missing-reference';
|
|
38
|
+
|
|
39
|
+
export type TopoReferenceKind =
|
|
40
|
+
| 'compose'
|
|
41
|
+
| 'contour-reference'
|
|
42
|
+
| 'resource'
|
|
43
|
+
| 'signal-fire'
|
|
44
|
+
| 'signal-on'
|
|
45
|
+
| 'signal-origin';
|
|
46
|
+
|
|
47
|
+
export type TopoReferenceOwnerKind =
|
|
48
|
+
| 'contour'
|
|
49
|
+
| 'signal'
|
|
50
|
+
| 'trail'
|
|
51
|
+
| 'trail-version';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stable machine-readable payload for dangling topo references.
|
|
55
|
+
*
|
|
56
|
+
* Consumers such as Regrade should branch on `code` and `reference` instead
|
|
57
|
+
* of parsing `message`, which remains a human-readable diagnostic.
|
|
58
|
+
*/
|
|
59
|
+
export interface TopoMissingReference {
|
|
60
|
+
readonly fromId: string;
|
|
61
|
+
readonly fromKind: TopoReferenceOwnerKind;
|
|
62
|
+
readonly fromTrailId?: string;
|
|
63
|
+
readonly missingId: string;
|
|
64
|
+
readonly referenceKind: TopoReferenceKind;
|
|
65
|
+
readonly version?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TopoDiagnostic {
|
|
69
|
+
/**
|
|
70
|
+
* Stable machine-readable code. Human-facing messages may change for
|
|
71
|
+
* clarity; downstream automation should depend on this code and the typed
|
|
72
|
+
* payload fields.
|
|
73
|
+
*/
|
|
74
|
+
readonly code?: TopoDiagnosticCode;
|
|
38
75
|
readonly trailId: string;
|
|
39
76
|
readonly rule: string;
|
|
40
77
|
readonly message: string;
|
|
41
78
|
readonly inputPath?: readonly (string | number)[];
|
|
79
|
+
readonly reference?: TopoMissingReference;
|
|
42
80
|
readonly schemaIssues?: readonly TopoSchemaIssue[];
|
|
43
81
|
readonly sourceId?: string;
|
|
44
82
|
readonly sourceKind?: string;
|
|
45
83
|
}
|
|
46
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @deprecated Use {@link TopoDiagnostic}. Kept as a source-compatible alias
|
|
87
|
+
* during the v1 vocabulary cutover.
|
|
88
|
+
*/
|
|
89
|
+
export interface TopoIssue extends TopoDiagnostic {
|
|
90
|
+
readonly trailId: TopoDiagnostic['trailId'];
|
|
91
|
+
}
|
|
92
|
+
|
|
47
93
|
export type TopoSchemaIssue = ActivationSchemaIssue;
|
|
48
94
|
|
|
95
|
+
const isTopoDiagnostic = (value: unknown): value is TopoDiagnostic =>
|
|
96
|
+
typeof value === 'object' &&
|
|
97
|
+
value !== null &&
|
|
98
|
+
typeof (value as { message?: unknown }).message === 'string' &&
|
|
99
|
+
typeof (value as { rule?: unknown }).rule === 'string' &&
|
|
100
|
+
typeof (value as { trailId?: unknown }).trailId === 'string';
|
|
101
|
+
|
|
102
|
+
const missingReferenceDiagnostic = (
|
|
103
|
+
issue: Omit<TopoDiagnostic, 'code' | 'reference'> & {
|
|
104
|
+
readonly reference: TopoMissingReference;
|
|
105
|
+
}
|
|
106
|
+
): TopoDiagnostic => ({
|
|
107
|
+
...issue,
|
|
108
|
+
code: 'topo.missing-reference',
|
|
109
|
+
});
|
|
110
|
+
|
|
49
111
|
// ---------------------------------------------------------------------------
|
|
50
112
|
// Validators
|
|
51
113
|
// ---------------------------------------------------------------------------
|
|
@@ -92,8 +154,8 @@ const buildComposeGraph = (
|
|
|
92
154
|
/** Detect multi-node cycles in the trail composing graph via DFS. */
|
|
93
155
|
const detectComposeCycles = (
|
|
94
156
|
trails: ReadonlyMap<string, AnyTrail>
|
|
95
|
-
):
|
|
96
|
-
const issues:
|
|
157
|
+
): TopoDiagnostic[] => {
|
|
158
|
+
const issues: TopoDiagnostic[] = [];
|
|
97
159
|
const { color, graph } = buildComposeGraph(trails);
|
|
98
160
|
|
|
99
161
|
const dfs = (node: string, path: string[]): void => {
|
|
@@ -128,8 +190,8 @@ const detectComposeCycles = (
|
|
|
128
190
|
const checkComposes = (
|
|
129
191
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
130
192
|
topo: Topo
|
|
131
|
-
):
|
|
132
|
-
const issues:
|
|
193
|
+
): TopoDiagnostic[] => {
|
|
194
|
+
const issues: TopoDiagnostic[] = [];
|
|
133
195
|
for (const [id, trail] of trails) {
|
|
134
196
|
for (const composedId of trail.composes) {
|
|
135
197
|
if (composedId === id) {
|
|
@@ -139,11 +201,20 @@ const checkComposes = (
|
|
|
139
201
|
trailId: id,
|
|
140
202
|
});
|
|
141
203
|
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
142
|
-
issues.push(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
204
|
+
issues.push(
|
|
205
|
+
missingReferenceDiagnostic({
|
|
206
|
+
message: `Composes "${composedId}" which is not in the topo`,
|
|
207
|
+
reference: {
|
|
208
|
+
fromId: id,
|
|
209
|
+
fromKind: 'trail',
|
|
210
|
+
fromTrailId: id,
|
|
211
|
+
missingId: composedId,
|
|
212
|
+
referenceKind: 'compose',
|
|
213
|
+
},
|
|
214
|
+
rule: 'compose-exists',
|
|
215
|
+
trailId: id,
|
|
216
|
+
})
|
|
217
|
+
);
|
|
147
218
|
}
|
|
148
219
|
}
|
|
149
220
|
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
@@ -166,11 +237,21 @@ const checkComposes = (
|
|
|
166
237
|
trailId: id,
|
|
167
238
|
});
|
|
168
239
|
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
169
|
-
issues.push(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
240
|
+
issues.push(
|
|
241
|
+
missingReferenceDiagnostic({
|
|
242
|
+
message: `Version ${version} composes "${composedId}" which is not in the topo`,
|
|
243
|
+
reference: {
|
|
244
|
+
fromId: id,
|
|
245
|
+
fromKind: 'trail-version',
|
|
246
|
+
fromTrailId: id,
|
|
247
|
+
missingId: composedId,
|
|
248
|
+
referenceKind: 'compose',
|
|
249
|
+
version,
|
|
250
|
+
},
|
|
251
|
+
rule: 'compose-exists',
|
|
252
|
+
trailId: id,
|
|
253
|
+
})
|
|
254
|
+
);
|
|
174
255
|
}
|
|
175
256
|
}
|
|
176
257
|
}
|
|
@@ -182,8 +263,8 @@ const checkComposes = (
|
|
|
182
263
|
const checkResources = (
|
|
183
264
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
184
265
|
topo: Topo
|
|
185
|
-
):
|
|
186
|
-
const issues:
|
|
266
|
+
): TopoDiagnostic[] => {
|
|
267
|
+
const issues: TopoDiagnostic[] = [];
|
|
187
268
|
|
|
188
269
|
for (const [id, trail] of trails) {
|
|
189
270
|
for (const declaredResource of trail.resources) {
|
|
@@ -191,11 +272,20 @@ const checkResources = (
|
|
|
191
272
|
!topo.hasResource(declaredResource.id) &&
|
|
192
273
|
!isDraftId(declaredResource.id)
|
|
193
274
|
) {
|
|
194
|
-
issues.push(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
275
|
+
issues.push(
|
|
276
|
+
missingReferenceDiagnostic({
|
|
277
|
+
message: `Resource "${declaredResource.id}" is not in the topo`,
|
|
278
|
+
reference: {
|
|
279
|
+
fromId: id,
|
|
280
|
+
fromKind: 'trail',
|
|
281
|
+
fromTrailId: id,
|
|
282
|
+
missingId: declaredResource.id,
|
|
283
|
+
referenceKind: 'resource',
|
|
284
|
+
},
|
|
285
|
+
rule: 'resource-exists',
|
|
286
|
+
trailId: id,
|
|
287
|
+
})
|
|
288
|
+
);
|
|
199
289
|
}
|
|
200
290
|
}
|
|
201
291
|
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
@@ -213,11 +303,21 @@ const checkResources = (
|
|
|
213
303
|
!topo.hasResource(declaredResource.id) &&
|
|
214
304
|
!isDraftId(declaredResource.id)
|
|
215
305
|
) {
|
|
216
|
-
issues.push(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
306
|
+
issues.push(
|
|
307
|
+
missingReferenceDiagnostic({
|
|
308
|
+
message: `Version ${version} resource "${declaredResource.id}" is not in the topo`,
|
|
309
|
+
reference: {
|
|
310
|
+
fromId: id,
|
|
311
|
+
fromKind: 'trail-version',
|
|
312
|
+
fromTrailId: id,
|
|
313
|
+
missingId: declaredResource.id,
|
|
314
|
+
referenceKind: 'resource',
|
|
315
|
+
version,
|
|
316
|
+
},
|
|
317
|
+
rule: 'resource-exists',
|
|
318
|
+
trailId: id,
|
|
319
|
+
})
|
|
320
|
+
);
|
|
221
321
|
}
|
|
222
322
|
}
|
|
223
323
|
}
|
|
@@ -237,8 +337,8 @@ const checkOneExample = (
|
|
|
237
337
|
inputSchema: { safeParse: (data: unknown) => { success: boolean } },
|
|
238
338
|
hasOutput: boolean,
|
|
239
339
|
label = `Example "${example.name}"`
|
|
240
|
-
):
|
|
241
|
-
const issues:
|
|
340
|
+
): TopoDiagnostic[] => {
|
|
341
|
+
const issues: TopoDiagnostic[] = [];
|
|
242
342
|
const result = validateInput(inputSchema as AnyTrail['input'], example.input);
|
|
243
343
|
if (result.isErr() && example.error !== 'ValidationError') {
|
|
244
344
|
issues.push({
|
|
@@ -257,7 +357,7 @@ const checkOneExample = (
|
|
|
257
357
|
return issues;
|
|
258
358
|
};
|
|
259
359
|
|
|
260
|
-
const checkVersionExamples = (id: string, trail: AnyTrail):
|
|
360
|
+
const checkVersionExamples = (id: string, trail: AnyTrail): TopoDiagnostic[] =>
|
|
261
361
|
Object.entries(trail.versions ?? {}).flatMap(([version, entry]) => {
|
|
262
362
|
if (isArchivedTrailVersionEntry(entry)) {
|
|
263
363
|
return [];
|
|
@@ -274,8 +374,10 @@ const checkVersionExamples = (id: string, trail: AnyTrail): TopoIssue[] =>
|
|
|
274
374
|
);
|
|
275
375
|
});
|
|
276
376
|
|
|
277
|
-
const checkExamples = (
|
|
278
|
-
|
|
377
|
+
const checkExamples = (
|
|
378
|
+
trails: ReadonlyMap<string, AnyTrail>
|
|
379
|
+
): TopoDiagnostic[] => {
|
|
380
|
+
const issues: TopoDiagnostic[] = [];
|
|
279
381
|
for (const [id, trail] of trails) {
|
|
280
382
|
if (trail.examples) {
|
|
281
383
|
for (const example of trail.examples) {
|
|
@@ -292,19 +394,27 @@ const checkExamples = (trails: ReadonlyMap<string, AnyTrail>): TopoIssue[] => {
|
|
|
292
394
|
const checkSignalOrigins = (
|
|
293
395
|
signals: ReadonlyMap<string, AnySignal>,
|
|
294
396
|
topo: Topo
|
|
295
|
-
):
|
|
296
|
-
const issues:
|
|
397
|
+
): TopoDiagnostic[] => {
|
|
398
|
+
const issues: TopoDiagnostic[] = [];
|
|
297
399
|
for (const [id, evt] of signals) {
|
|
298
400
|
if (!evt.from) {
|
|
299
401
|
continue;
|
|
300
402
|
}
|
|
301
403
|
for (const originId of evt.from) {
|
|
302
404
|
if (!topo.has(originId) && !isDraftId(originId)) {
|
|
303
|
-
issues.push(
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
405
|
+
issues.push(
|
|
406
|
+
missingReferenceDiagnostic({
|
|
407
|
+
message: `Signal origin "${originId}" is not in the topo`,
|
|
408
|
+
reference: {
|
|
409
|
+
fromId: id,
|
|
410
|
+
fromKind: 'signal',
|
|
411
|
+
missingId: originId,
|
|
412
|
+
referenceKind: 'signal-origin',
|
|
413
|
+
},
|
|
414
|
+
rule: 'signal-origin-exists',
|
|
415
|
+
trailId: id,
|
|
416
|
+
})
|
|
417
|
+
);
|
|
308
418
|
}
|
|
309
419
|
}
|
|
310
420
|
}
|
|
@@ -314,27 +424,45 @@ const checkSignalOrigins = (
|
|
|
314
424
|
const checkSignalReferences = (
|
|
315
425
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
316
426
|
signals: ReadonlyMap<string, AnySignal>
|
|
317
|
-
):
|
|
318
|
-
const issues:
|
|
427
|
+
): TopoDiagnostic[] => {
|
|
428
|
+
const issues: TopoDiagnostic[] = [];
|
|
319
429
|
|
|
320
430
|
for (const [id, trail] of trails) {
|
|
321
431
|
for (const signalId of trail.fires ?? []) {
|
|
322
432
|
if (!signals.has(signalId) && !isDraftId(signalId)) {
|
|
323
|
-
issues.push(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
433
|
+
issues.push(
|
|
434
|
+
missingReferenceDiagnostic({
|
|
435
|
+
message: `Trail fires signal "${signalId}" which is not in the topo`,
|
|
436
|
+
reference: {
|
|
437
|
+
fromId: id,
|
|
438
|
+
fromKind: 'trail',
|
|
439
|
+
fromTrailId: id,
|
|
440
|
+
missingId: signalId,
|
|
441
|
+
referenceKind: 'signal-fire',
|
|
442
|
+
},
|
|
443
|
+
rule: 'signal-fire-exists',
|
|
444
|
+
trailId: id,
|
|
445
|
+
})
|
|
446
|
+
);
|
|
328
447
|
}
|
|
329
448
|
}
|
|
330
449
|
|
|
331
450
|
for (const signalId of trail.on ?? []) {
|
|
332
451
|
if (!signals.has(signalId) && !isDraftId(signalId)) {
|
|
333
|
-
issues.push(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
452
|
+
issues.push(
|
|
453
|
+
missingReferenceDiagnostic({
|
|
454
|
+
message: `Trail declares on signal "${signalId}" which is not in the topo`,
|
|
455
|
+
reference: {
|
|
456
|
+
fromId: id,
|
|
457
|
+
fromKind: 'trail',
|
|
458
|
+
fromTrailId: id,
|
|
459
|
+
missingId: signalId,
|
|
460
|
+
referenceKind: 'signal-on',
|
|
461
|
+
},
|
|
462
|
+
rule: 'signal-on-exists',
|
|
463
|
+
trailId: id,
|
|
464
|
+
})
|
|
465
|
+
);
|
|
338
466
|
}
|
|
339
467
|
}
|
|
340
468
|
}
|
|
@@ -344,8 +472,8 @@ const checkSignalReferences = (
|
|
|
344
472
|
|
|
345
473
|
const checkActivationSources = (
|
|
346
474
|
trails: ReadonlyMap<string, AnyTrail>
|
|
347
|
-
):
|
|
348
|
-
const issues:
|
|
475
|
+
): TopoDiagnostic[] => {
|
|
476
|
+
const issues: TopoDiagnostic[] = [];
|
|
349
477
|
const sourceDeclarations = new Map<
|
|
350
478
|
string,
|
|
351
479
|
{
|
|
@@ -440,7 +568,7 @@ const createSourceCompatibilityIssue = (
|
|
|
440
568
|
trailId: string,
|
|
441
569
|
activation: ActivationEntry,
|
|
442
570
|
schemaIssues: readonly TopoSchemaIssue[]
|
|
443
|
-
):
|
|
571
|
+
): TopoDiagnostic => {
|
|
444
572
|
const [firstIssue] = schemaIssues;
|
|
445
573
|
const inputPath = firstIssue?.path ?? Object.freeze([]);
|
|
446
574
|
return {
|
|
@@ -458,7 +586,7 @@ const checkSourcePayloadCompatibility = (
|
|
|
458
586
|
trail: AnyTrail,
|
|
459
587
|
activation: ActivationEntry,
|
|
460
588
|
signals: ReadonlyMap<string, AnySignal>
|
|
461
|
-
):
|
|
589
|
+
): TopoDiagnostic | undefined => {
|
|
462
590
|
if (
|
|
463
591
|
!isKnownActivationSourceKind(activation.source.kind) ||
|
|
464
592
|
isDraftId(activation.source.id)
|
|
@@ -483,8 +611,8 @@ const checkSourcePayloadCompatibility = (
|
|
|
483
611
|
const checkActivationSourceInputCompatibility = (
|
|
484
612
|
trails: ReadonlyMap<string, AnyTrail>,
|
|
485
613
|
signals: ReadonlyMap<string, AnySignal>
|
|
486
|
-
):
|
|
487
|
-
const issues:
|
|
614
|
+
): TopoDiagnostic[] => {
|
|
615
|
+
const issues: TopoDiagnostic[] = [];
|
|
488
616
|
|
|
489
617
|
for (const trail of trails.values()) {
|
|
490
618
|
for (const activation of trail.activationSources ?? []) {
|
|
@@ -501,17 +629,25 @@ const checkActivationSourceInputCompatibility = (
|
|
|
501
629
|
const checkContourReferences = (
|
|
502
630
|
contours: ReadonlyMap<string, AnyContour>,
|
|
503
631
|
topo: Topo
|
|
504
|
-
):
|
|
505
|
-
const issues:
|
|
632
|
+
): TopoDiagnostic[] => {
|
|
633
|
+
const issues: TopoDiagnostic[] = [];
|
|
506
634
|
|
|
507
635
|
for (const [name, contourDef] of contours) {
|
|
508
636
|
for (const ref of getContourReferences(contourDef)) {
|
|
509
637
|
if (!topo.hasContour(ref.contour) && !isDraftId(ref.contour)) {
|
|
510
|
-
issues.push(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
638
|
+
issues.push(
|
|
639
|
+
missingReferenceDiagnostic({
|
|
640
|
+
message: `Contour "${name}" references "${ref.contour}" which is not in the topo`,
|
|
641
|
+
reference: {
|
|
642
|
+
fromId: name,
|
|
643
|
+
fromKind: 'contour',
|
|
644
|
+
missingId: ref.contour,
|
|
645
|
+
referenceKind: 'contour-reference',
|
|
646
|
+
},
|
|
647
|
+
rule: 'contour-reference-exists',
|
|
648
|
+
trailId: name,
|
|
649
|
+
})
|
|
650
|
+
);
|
|
515
651
|
}
|
|
516
652
|
}
|
|
517
653
|
}
|
|
@@ -523,6 +659,21 @@ const checkContourReferences = (
|
|
|
523
659
|
// Public API
|
|
524
660
|
// ---------------------------------------------------------------------------
|
|
525
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Extract structured topo diagnostics from a validation error.
|
|
664
|
+
*
|
|
665
|
+
* `validateTopo` keeps source compatibility by returning `Result<void,
|
|
666
|
+
* ValidationError>`. Consumers that need machine-readable diagnostics should
|
|
667
|
+
* use this helper instead of parsing the human `message` text.
|
|
668
|
+
*/
|
|
669
|
+
export const getTopoDiagnostics = (
|
|
670
|
+
error: ValidationError
|
|
671
|
+
): readonly TopoDiagnostic[] => {
|
|
672
|
+
const context = error.context as { issues?: unknown } | undefined;
|
|
673
|
+
const issues = context?.issues;
|
|
674
|
+
return Array.isArray(issues) ? issues.filter(isTopoDiagnostic) : [];
|
|
675
|
+
};
|
|
676
|
+
|
|
526
677
|
/**
|
|
527
678
|
* Validate the structural integrity of a Topo graph.
|
|
528
679
|
*
|