@ontrails/core 1.0.0-beta.32 → 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 +45 -0
- package/package.json +1 -1
- package/src/context.ts +18 -4
- package/src/derive.ts +21 -0
- package/src/index.ts +48 -3
- 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-overlay.ts +694 -0
- package/src/trail.ts +78 -0
- package/src/trails-db.ts +28 -22
- package/src/validate-topo.ts +181 -40
- package/src/webhook.ts +194 -11
- package/src/workspace.ts +39 -33
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
|
};
|
package/src/validate-topo.ts
CHANGED
|
@@ -34,11 +34,49 @@ import { validateWebhookSource } from './webhook.js';
|
|
|
34
34
|
// Issue shape
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
|
|
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
|
+
|
|
37
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;
|
|
@@ -54,6 +92,22 @@ export interface TopoIssue extends TopoDiagnostic {
|
|
|
54
92
|
|
|
55
93
|
export type TopoSchemaIssue = ActivationSchemaIssue;
|
|
56
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
|
+
|
|
57
111
|
// ---------------------------------------------------------------------------
|
|
58
112
|
// Validators
|
|
59
113
|
// ---------------------------------------------------------------------------
|
|
@@ -147,11 +201,20 @@ const checkComposes = (
|
|
|
147
201
|
trailId: id,
|
|
148
202
|
});
|
|
149
203
|
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
150
|
-
issues.push(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
);
|
|
155
218
|
}
|
|
156
219
|
}
|
|
157
220
|
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
@@ -174,11 +237,21 @@ const checkComposes = (
|
|
|
174
237
|
trailId: id,
|
|
175
238
|
});
|
|
176
239
|
} else if (!topo.has(composedId) && !isDraftId(composedId)) {
|
|
177
|
-
issues.push(
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
);
|
|
182
255
|
}
|
|
183
256
|
}
|
|
184
257
|
}
|
|
@@ -199,11 +272,20 @@ const checkResources = (
|
|
|
199
272
|
!topo.hasResource(declaredResource.id) &&
|
|
200
273
|
!isDraftId(declaredResource.id)
|
|
201
274
|
) {
|
|
202
|
-
issues.push(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
);
|
|
207
289
|
}
|
|
208
290
|
}
|
|
209
291
|
for (const [rawVersion, entry] of Object.entries(trail.versions ?? {})) {
|
|
@@ -221,11 +303,21 @@ const checkResources = (
|
|
|
221
303
|
!topo.hasResource(declaredResource.id) &&
|
|
222
304
|
!isDraftId(declaredResource.id)
|
|
223
305
|
) {
|
|
224
|
-
issues.push(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
);
|
|
229
321
|
}
|
|
230
322
|
}
|
|
231
323
|
}
|
|
@@ -310,11 +402,19 @@ const checkSignalOrigins = (
|
|
|
310
402
|
}
|
|
311
403
|
for (const originId of evt.from) {
|
|
312
404
|
if (!topo.has(originId) && !isDraftId(originId)) {
|
|
313
|
-
issues.push(
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
);
|
|
318
418
|
}
|
|
319
419
|
}
|
|
320
420
|
}
|
|
@@ -330,21 +430,39 @@ const checkSignalReferences = (
|
|
|
330
430
|
for (const [id, trail] of trails) {
|
|
331
431
|
for (const signalId of trail.fires ?? []) {
|
|
332
432
|
if (!signals.has(signalId) && !isDraftId(signalId)) {
|
|
333
|
-
issues.push(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
);
|
|
338
447
|
}
|
|
339
448
|
}
|
|
340
449
|
|
|
341
450
|
for (const signalId of trail.on ?? []) {
|
|
342
451
|
if (!signals.has(signalId) && !isDraftId(signalId)) {
|
|
343
|
-
issues.push(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
+
);
|
|
348
466
|
}
|
|
349
467
|
}
|
|
350
468
|
}
|
|
@@ -517,11 +635,19 @@ const checkContourReferences = (
|
|
|
517
635
|
for (const [name, contourDef] of contours) {
|
|
518
636
|
for (const ref of getContourReferences(contourDef)) {
|
|
519
637
|
if (!topo.hasContour(ref.contour) && !isDraftId(ref.contour)) {
|
|
520
|
-
issues.push(
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
+
);
|
|
525
651
|
}
|
|
526
652
|
}
|
|
527
653
|
}
|
|
@@ -533,6 +659,21 @@ const checkContourReferences = (
|
|
|
533
659
|
// Public API
|
|
534
660
|
// ---------------------------------------------------------------------------
|
|
535
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
|
+
|
|
536
677
|
/**
|
|
537
678
|
* Validate the structural integrity of a Topo graph.
|
|
538
679
|
*
|