@ontrails/core 0.2.0

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 (86) hide show
  1. package/CHANGELOG.md +849 -0
  2. package/README.md +190 -0
  3. package/package.json +36 -0
  4. package/src/activation-provenance.ts +116 -0
  5. package/src/activation-source-compatibility.ts +430 -0
  6. package/src/activation-source-derivation.ts +227 -0
  7. package/src/activation-source.ts +93 -0
  8. package/src/blob-ref.ts +90 -0
  9. package/src/branded.ts +135 -0
  10. package/src/collections.ts +99 -0
  11. package/src/compose-batch.ts +69 -0
  12. package/src/compose-schema.ts +36 -0
  13. package/src/context.ts +66 -0
  14. package/src/derive.ts +485 -0
  15. package/src/detours.ts +8 -0
  16. package/src/diagnostics.ts +21 -0
  17. package/src/draft.ts +350 -0
  18. package/src/entity.ts +346 -0
  19. package/src/error-rendering.ts +87 -0
  20. package/src/errors.ts +483 -0
  21. package/src/execute.ts +1577 -0
  22. package/src/fetch.ts +138 -0
  23. package/src/fire.ts +1172 -0
  24. package/src/glob.ts +81 -0
  25. package/src/guards.ts +37 -0
  26. package/src/index.ts +704 -0
  27. package/src/internal/fork-ctx.ts +69 -0
  28. package/src/layer-field-rendering.ts +193 -0
  29. package/src/layer.ts +81 -0
  30. package/src/observe.ts +361 -0
  31. package/src/path-scope.ts +66 -0
  32. package/src/path-security.ts +98 -0
  33. package/src/patterns/bulk.ts +16 -0
  34. package/src/patterns/change.ts +12 -0
  35. package/src/patterns/date-range.ts +12 -0
  36. package/src/patterns/index.ts +8 -0
  37. package/src/patterns/pagination.ts +22 -0
  38. package/src/patterns/progress.ts +13 -0
  39. package/src/patterns/sorting.ts +14 -0
  40. package/src/patterns/status.ts +11 -0
  41. package/src/patterns/timestamps.ts +12 -0
  42. package/src/permits.ts +12 -0
  43. package/src/queue.ts +163 -0
  44. package/src/redaction/index.ts +3 -0
  45. package/src/redaction/patterns.ts +50 -0
  46. package/src/redaction/redactor.ts +178 -0
  47. package/src/resilience.ts +234 -0
  48. package/src/resource-config.ts +804 -0
  49. package/src/resource.ts +194 -0
  50. package/src/result.ts +212 -0
  51. package/src/run.ts +76 -0
  52. package/src/runtime-builtins.ts +69 -0
  53. package/src/schedule-runtime.ts +689 -0
  54. package/src/schedule.ts +326 -0
  55. package/src/serialization.ts +265 -0
  56. package/src/sha256.ts +136 -0
  57. package/src/signal-diagnostics.ts +633 -0
  58. package/src/signal-ref.ts +111 -0
  59. package/src/signal.ts +104 -0
  60. package/src/store/accessor-protocol.ts +56 -0
  61. package/src/store/index.ts +4 -0
  62. package/src/structured-examples.ts +248 -0
  63. package/src/surface-derivation.ts +91 -0
  64. package/src/surface-filter.ts +101 -0
  65. package/src/surface-overlay.ts +694 -0
  66. package/src/surface-versioning.ts +42 -0
  67. package/src/topo.ts +835 -0
  68. package/src/tracing.ts +346 -0
  69. package/src/trail-id-glob.ts +15 -0
  70. package/src/trail.ts +1351 -0
  71. package/src/trails/derive-trail.ts +835 -0
  72. package/src/trails/index.ts +9 -0
  73. package/src/trails/ingest.ts +152 -0
  74. package/src/trails-db.ts +212 -0
  75. package/src/transport-error-map.ts +163 -0
  76. package/src/type-utils.ts +87 -0
  77. package/src/types.ts +300 -0
  78. package/src/validate-established-topo.ts +73 -0
  79. package/src/validate-topo.ts +725 -0
  80. package/src/validation.ts +330 -0
  81. package/src/version-marker.ts +716 -0
  82. package/src/version-resolution.ts +308 -0
  83. package/src/version-runtime.ts +120 -0
  84. package/src/webhook.ts +461 -0
  85. package/src/workspace.ts +244 -0
  86. package/src/zod-wrappers.ts +72 -0
@@ -0,0 +1,694 @@
1
+ /**
2
+ * The app-authored `surfaces` overlay.
3
+ *
4
+ * `surfaceOverlay()` authors named bindings from a surface's namespace onto
5
+ * trails — the shared vocabulary that subsumes CLI aliases and MCP trailheads.
6
+ * A scalar binding is a synonym for one trail selector; a list binding is a
7
+ * grouped entry over several selectors. The overlay lands in `trails.lock`
8
+ * under the well-known `surfaces` namespace, and consumption helpers enforce
9
+ * the provenance boundary: surfaces obey app-authored overlays only, so an
10
+ * adapter can never inject a binding a surface obeys.
11
+ */
12
+
13
+ import { z } from 'zod';
14
+
15
+ import type { CliCommandAliasInput } from './derive.js';
16
+ import { ValidationError } from './errors.js';
17
+ import type { Topo } from './topo.js';
18
+ import { matchesTrailIdGlob } from './trail-id-glob.js';
19
+
20
+ /**
21
+ * The well-known lock overlay namespace owned by app-authored surface
22
+ * bindings.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { SURFACES_OVERLAY_NAMESPACE } from '@ontrails/core';
27
+ *
28
+ * const facts = lock.topoGraph.overlays?.[SURFACES_OVERLAY_NAMESPACE];
29
+ * ```
30
+ */
31
+ export const SURFACES_OVERLAY_NAMESPACE = 'surfaces' as const;
32
+
33
+ /**
34
+ * Who authored an overlay envelope.
35
+ *
36
+ * `'adapter-derived'` marks facts an adapter renders from the topo;
37
+ * `'app-authored'` marks bindings the app wrote by hand. Surfaces obey
38
+ * app-authored overlays only — adapters contribute facts, never bindings.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import type { OverlayProvenance } from '@ontrails/core';
43
+ *
44
+ * const provenance: OverlayProvenance = 'app-authored';
45
+ * ```
46
+ */
47
+ export type OverlayProvenance = 'adapter-derived' | 'app-authored';
48
+
49
+ /**
50
+ * One trail selector inside a surface binding: an exact trail id
51
+ * (`'gear.list'`) or a dotted trail-id glob (`'snippet.*'`).
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import type { SurfaceBindingRef } from '@ontrails/core';
56
+ *
57
+ * const exact: SurfaceBindingRef = 'gear.list';
58
+ * const glob: SurfaceBindingRef = 'snippet.*';
59
+ * ```
60
+ */
61
+ export type SurfaceBindingRef = string;
62
+
63
+ /**
64
+ * The authored value of one surface binding. A scalar ref is a synonym for
65
+ * one trail selector; a list of refs is a grouped entry. Value shape is the
66
+ * discriminator — a singleton list is still a group.
67
+ *
68
+ * @example
69
+ * ```ts
70
+ * import type { SurfaceBindingValue } from '@ontrails/core';
71
+ *
72
+ * const synonym: SurfaceBindingValue = 'gear.list';
73
+ * const group: SurfaceBindingValue = ['gear.create', 'gear.list'];
74
+ * ```
75
+ */
76
+ export type SurfaceBindingValue =
77
+ | SurfaceBindingRef
78
+ | readonly SurfaceBindingRef[];
79
+
80
+ /**
81
+ * Named bindings for one surface: binding name to trail selector(s).
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * import type { SurfaceBindings } from '@ontrails/core';
86
+ *
87
+ * const cli: SurfaceBindings = {
88
+ * gear: ['gear.create', 'gear.list'],
89
+ * ls: 'gear.list',
90
+ * };
91
+ * ```
92
+ */
93
+ export type SurfaceBindings = Readonly<Record<string, SurfaceBindingValue>>;
94
+
95
+ /**
96
+ * The full `surfaces` overlay payload: per-surface binding maps keyed by the
97
+ * surfaces that can obey bindings today (`cli`, `http`, `mcp`, `ws`).
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import type { SurfaceOverlayBindings } from '@ontrails/core';
102
+ *
103
+ * const bindings: SurfaceOverlayBindings = {
104
+ * cli: { ls: 'gear.list' },
105
+ * mcp: { snippets: ['snippet.create', 'snippet.get'] },
106
+ * };
107
+ * ```
108
+ */
109
+ export interface SurfaceOverlayBindings {
110
+ readonly cli?: SurfaceBindings | undefined;
111
+ readonly http?: SurfaceBindings | undefined;
112
+ readonly mcp?: SurfaceBindings | undefined;
113
+ readonly ws?: SurfaceBindings | undefined;
114
+ }
115
+
116
+ const surfaceBindingRefSchema = z.string().min(1);
117
+
118
+ const surfaceBindingValueSchema = z.union([
119
+ surfaceBindingRefSchema,
120
+ z.array(surfaceBindingRefSchema).min(1).readonly(),
121
+ ]);
122
+
123
+ const surfaceBindingsRecordSchema = z.record(
124
+ z.string().min(1),
125
+ surfaceBindingValueSchema
126
+ );
127
+
128
+ /**
129
+ * Schema for the `surfaces` overlay payload.
130
+ *
131
+ * Strict: only the `cli`, `http`, `mcp`, and `ws` surface keys are accepted.
132
+ * Each surface maps non-empty binding names to a non-empty selector string or
133
+ * a non-empty list of non-empty selector strings — a group with zero members
134
+ * is rejected because it promises an entry that binds nothing.
135
+ *
136
+ * @example
137
+ * ```ts
138
+ * import { surfaceOverlayBindingsSchema } from '@ontrails/core';
139
+ *
140
+ * const parsed = surfaceOverlayBindingsSchema.safeParse({
141
+ * cli: { ls: 'gear.list' },
142
+ * });
143
+ * // parsed.success === true
144
+ * ```
145
+ */
146
+ export const surfaceOverlayBindingsSchema = z
147
+ .object({
148
+ cli: surfaceBindingsRecordSchema.optional(),
149
+ http: surfaceBindingsRecordSchema.optional(),
150
+ mcp: surfaceBindingsRecordSchema.optional(),
151
+ ws: surfaceBindingsRecordSchema.optional(),
152
+ })
153
+ .strict();
154
+
155
+ const summarizeIssues = (error: z.ZodError): string =>
156
+ error.issues
157
+ .map((issue) =>
158
+ issue.path.length === 0
159
+ ? issue.message
160
+ : `${issue.path.map(String).join('.')}: ${issue.message}`
161
+ )
162
+ .join('; ');
163
+
164
+ const parseSurfaceOverlayBindings = (
165
+ value: unknown,
166
+ remediation: string
167
+ ): SurfaceOverlayBindings => {
168
+ const parsed = surfaceOverlayBindingsSchema.safeParse(value);
169
+ if (!parsed.success) {
170
+ throw new ValidationError(
171
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay bindings are invalid (${summarizeIssues(parsed.error)}). ${remediation}`
172
+ );
173
+ }
174
+ return parsed.data;
175
+ };
176
+
177
+ /**
178
+ * The classified shape of one surface binding value.
179
+ *
180
+ * Value shape is the discriminator, not cardinality: a scalar ref is a
181
+ * synonym, and any list — including a singleton list — is a group.
182
+ *
183
+ * @example
184
+ * ```ts
185
+ * import type { SurfaceBindingShape } from '@ontrails/core';
186
+ *
187
+ * const shape: SurfaceBindingShape = { kind: 'synonym', trail: 'gear.list' };
188
+ * ```
189
+ */
190
+ export type SurfaceBindingShape =
191
+ | { readonly kind: 'synonym'; readonly trail: SurfaceBindingRef }
192
+ | { readonly kind: 'group'; readonly members: readonly SurfaceBindingRef[] };
193
+
194
+ /**
195
+ * Classify a surface binding value by its authored shape.
196
+ *
197
+ * A scalar ref classifies as a synonym. A list classifies as a group — a
198
+ * singleton list stays a group, because value shape (not member count) is
199
+ * the discriminator between "another name for this trail" and "a grouped
200
+ * entry over these trails".
201
+ *
202
+ * @example
203
+ * ```ts
204
+ * import { classifySurfaceBinding } from '@ontrails/core';
205
+ *
206
+ * classifySurfaceBinding('gear.list');
207
+ * // => { kind: 'synonym', trail: 'gear.list' }
208
+ * classifySurfaceBinding(['gear.list']);
209
+ * // => { kind: 'group', members: ['gear.list'] }
210
+ * ```
211
+ */
212
+ export const classifySurfaceBinding = (
213
+ value: SurfaceBindingValue
214
+ ): SurfaceBindingShape =>
215
+ typeof value === 'string'
216
+ ? { kind: 'synonym', trail: value }
217
+ : { kind: 'group', members: value };
218
+
219
+ /**
220
+ * The app-authored `surfaces` overlay envelope.
221
+ *
222
+ * Structurally compatible with the adapter-kit `Overlay` contract — the
223
+ * compile path collects it like any other overlay registration — while also
224
+ * exposing {@link SurfaceOverlay.bindings} for direct reads without a derive
225
+ * round-trip.
226
+ *
227
+ * @example
228
+ * ```ts
229
+ * import type { SurfaceOverlay } from '@ontrails/core';
230
+ * import { surfaceOverlay } from '@ontrails/core';
231
+ *
232
+ * const overlay: SurfaceOverlay = surfaceOverlay({
233
+ * cli: { ls: 'gear.list' },
234
+ * });
235
+ * ```
236
+ */
237
+ export interface SurfaceOverlay {
238
+ /** Always the well-known `surfaces` namespace. */
239
+ readonly namespace: typeof SURFACES_OVERLAY_NAMESPACE;
240
+ /** Always app-authored — the provenance surfaces obey. */
241
+ readonly provenance: 'app-authored';
242
+ /** The bindings schema, enforced again on the compile path. */
243
+ readonly schema: z.ZodType;
244
+ /**
245
+ * Derive the overlay facts. Ignores the topo — bindings are authored —
246
+ * so the parameter is optional; the signature stays structurally
247
+ * assignable to the adapter-kit `Overlay` contract's `(topo) => unknown`.
248
+ */
249
+ readonly derive: (topo?: Topo) => SurfaceOverlayBindings;
250
+ /** The validated bindings, for direct reads. */
251
+ readonly bindings: SurfaceOverlayBindings;
252
+ }
253
+
254
+ /**
255
+ * Author the `surfaces` overlay from per-surface bindings.
256
+ *
257
+ * Validates the bindings eagerly and throws a `ValidationError` with the
258
+ * schema issues when they are invalid, so a bad binding fails where it was
259
+ * authored instead of at compile time. The returned envelope's `derive`
260
+ * returns the validated bindings and ignores the topo.
261
+ *
262
+ * @example
263
+ * ```ts
264
+ * import { surfaceOverlay } from '@ontrails/core';
265
+ *
266
+ * export const trailsOverlays = [
267
+ * surfaceOverlay({
268
+ * cli: { gear: ['gear.create', 'gear.list'], ls: 'gear.list' },
269
+ * mcp: { snippets: ['snippet.create', 'snippet.get', 'snippet.fork'] },
270
+ * }),
271
+ * ];
272
+ * ```
273
+ */
274
+ export const surfaceOverlay = (
275
+ bindings: SurfaceOverlayBindings
276
+ ): SurfaceOverlay => {
277
+ const validated = parseSurfaceOverlayBindings(
278
+ bindings,
279
+ 'Fix the bindings passed to surfaceOverlay() and rerun `trails compile`.'
280
+ );
281
+ return {
282
+ bindings: validated,
283
+ derive: () => validated,
284
+ namespace: SURFACES_OVERLAY_NAMESPACE,
285
+ provenance: 'app-authored',
286
+ schema: surfaceOverlayBindingsSchema,
287
+ };
288
+ };
289
+
290
+ /**
291
+ * The minimal structural shape of an overlay envelope the surface-overlay
292
+ * consumption helpers can inspect.
293
+ *
294
+ * `derive` is declared with an optional topo because app-authored surface
295
+ * overlays derive independently of the topo; adapter envelopes whose derive
296
+ * requires the topo still match structurally, and the provenance gate
297
+ * rejects them before their derive is ever invoked.
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * import type { OverlayEnvelopeLike } from '@ontrails/core';
302
+ * import { resolveSurfaceOverlayBindings } from '@ontrails/core';
303
+ *
304
+ * const bindings = resolveSurfaceOverlayBindings(
305
+ * trailsOverlays as readonly OverlayEnvelopeLike[]
306
+ * );
307
+ * ```
308
+ */
309
+ export interface OverlayEnvelopeLike {
310
+ /** The lock overlay namespace the envelope owns. */
311
+ readonly namespace: string;
312
+ /** Who authored the envelope. Absent means adapter-derived. */
313
+ readonly provenance?: OverlayProvenance | undefined;
314
+ /** The envelope's fact schema. */
315
+ readonly schema: z.ZodType;
316
+ /** Derive the envelope's facts. */
317
+ derive(topo?: Topo): unknown;
318
+ /**
319
+ * Static surface bindings, when the envelope carries them directly.
320
+ *
321
+ * `surfaceOverlay()` envelopes expose their validated bindings here;
322
+ * consumption prefers this over invoking {@link derive}, so a `surfaces`
323
+ * envelope must never derive facts that differ from its authored bindings.
324
+ */
325
+ readonly bindings?: unknown;
326
+ }
327
+
328
+ /**
329
+ * Resolve the `surfaces` bindings from a collection of overlay envelopes.
330
+ *
331
+ * This is the provenance boundary: surfaces obey app-authored overlays only —
332
+ * adapters contribute facts, never bindings. A `surfaces` envelope without
333
+ * `provenance: 'app-authored'` (absent provenance is adapter-derived) throws
334
+ * a `ValidationError` naming `surfaceOverlay()` as the fix, as does a
335
+ * duplicate `surfaces` namespace or a derive result that fails the bindings
336
+ * schema. Returns `undefined` when no `surfaces` envelope is present.
337
+ *
338
+ * @example
339
+ * ```ts
340
+ * import { resolveSurfaceOverlayBindings, surfaceOverlay } from '@ontrails/core';
341
+ *
342
+ * const bindings = resolveSurfaceOverlayBindings([
343
+ * surfaceOverlay({ cli: { ls: 'gear.list' } }),
344
+ * ]);
345
+ * // => { cli: { ls: 'gear.list' } }
346
+ * ```
347
+ */
348
+ export const resolveSurfaceOverlayBindings = (
349
+ overlays: readonly OverlayEnvelopeLike[] | undefined
350
+ ): SurfaceOverlayBindings | undefined => {
351
+ const matches = (overlays ?? []).filter(
352
+ (overlay) => overlay.namespace === SURFACES_OVERLAY_NAMESPACE
353
+ );
354
+ const [first] = matches;
355
+ if (first === undefined) {
356
+ return undefined;
357
+ }
358
+ if (matches.length > 1) {
359
+ throw new ValidationError(
360
+ `Duplicate "${SURFACES_OVERLAY_NAMESPACE}" overlay namespace. Author one surfaceOverlay() in the app module and remove the duplicate registration.`
361
+ );
362
+ }
363
+ if (first.provenance !== 'app-authored') {
364
+ throw new ValidationError(
365
+ `The "${SURFACES_OVERLAY_NAMESPACE}" namespace obeys app-authored overlays only — adapters contribute facts, never bindings. Author the bindings with surfaceOverlay() in the app module.`
366
+ );
367
+ }
368
+ return parseSurfaceOverlayBindings(
369
+ first.bindings ?? first.derive(),
370
+ 'Author the bindings with surfaceOverlay() in the app module.'
371
+ );
372
+ };
373
+
374
+ /**
375
+ * Per-trail CLI alias inputs expanded from the `surfaces` overlay's `cli`
376
+ * bindings: trail id to the absolute command paths that alias it.
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * import type { CliSurfaceBindingAliases } from '@ontrails/core';
381
+ *
382
+ * const aliases: CliSurfaceBindingAliases = {
383
+ * 'gear.list': [['gear', 'ls']],
384
+ * };
385
+ * ```
386
+ */
387
+ export type CliSurfaceBindingAliases = Readonly<
388
+ Record<string, readonly CliCommandAliasInput[]>
389
+ >;
390
+
391
+ const CLI_BINDING_FIX =
392
+ 'Fix the binding in surfaceOverlay({ cli }) in the app module.';
393
+
394
+ const MCP_BINDING_FIX =
395
+ 'Fix the binding in surfaceOverlay({ mcp }) in the app module.';
396
+
397
+ const assertCliBindingName = (name: string): readonly string[] => {
398
+ const segments = name.split('.');
399
+ if (segments.some((segment) => segment.length === 0 || /\s/.test(segment))) {
400
+ throw new ValidationError(
401
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay cli binding "${name}" is not a valid command path — every dot-separated segment must be non-empty and contain no whitespace. ${CLI_BINDING_FIX}`
402
+ );
403
+ }
404
+ return segments;
405
+ };
406
+
407
+ const expandSelector = (
408
+ trailIds: readonly string[],
409
+ selector: SurfaceBindingRef
410
+ ): readonly string[] =>
411
+ trailIds
412
+ .filter((trailId) => matchesTrailIdGlob(trailId, selector))
413
+ .toSorted();
414
+
415
+ const expandSynonymBinding = (
416
+ trailIds: readonly string[],
417
+ surfaceKey: string,
418
+ name: string,
419
+ selector: SurfaceBindingRef,
420
+ groupNoun: string,
421
+ fix: string
422
+ ): string => {
423
+ const matches = expandSelector(trailIds, selector);
424
+ const [first] = matches;
425
+ if (first === undefined) {
426
+ throw new ValidationError(
427
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} binding "${name}" resolves to no trails: selector "${selector}" matched none. ${fix}`
428
+ );
429
+ }
430
+ if (matches.length > 1) {
431
+ throw new ValidationError(
432
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} binding "${name}" resolves to ${matches.length} trails (${matches.join(', ')}). A scalar binding is a synonym for exactly one trail — use a list value to expose a ${groupNoun} instead. ${fix}`
433
+ );
434
+ }
435
+ return first;
436
+ };
437
+
438
+ const expandGroupBinding = (
439
+ trailIds: readonly string[],
440
+ surfaceKey: string,
441
+ name: string,
442
+ selectors: readonly SurfaceBindingRef[],
443
+ fix: string
444
+ ): readonly string[] => {
445
+ const members = [
446
+ ...new Set(
447
+ selectors.flatMap((selector) => expandSelector(trailIds, selector))
448
+ ),
449
+ ].toSorted();
450
+ if (members.length === 0) {
451
+ throw new ValidationError(
452
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay ${surfaceKey} group binding "${name}" resolves to no trails: selectors ${selectors.map((selector) => `"${selector}"`).join(', ')} matched none. ${fix}`
453
+ );
454
+ }
455
+ return members;
456
+ };
457
+
458
+ /**
459
+ * Expand the `surfaces` overlay's `cli` bindings into per-trail CLI alias
460
+ * inputs, validating every binding against the topo's trail ids.
461
+ *
462
+ * A scalar binding is a transparent synonym: its name splits on `.` into an
463
+ * absolute command path aliasing exactly one trail — zero or multiple matches
464
+ * are a `ValidationError` naming the binding and its matches. A list binding
465
+ * is a command group: each expanded member trail gets an alias route at
466
+ * `[...groupName.split('.'), ...memberTrailId.split('.')]`, and a group whose
467
+ * member union is empty is a `ValidationError`. Binding names and member ids
468
+ * are processed in sorted order so the expansion is deterministic. Returns
469
+ * `undefined` when there are no `cli` bindings.
470
+ *
471
+ * @example
472
+ * ```ts
473
+ * import { expandCliSurfaceBindings } from '@ontrails/core';
474
+ *
475
+ * expandCliSurfaceBindings(
476
+ * { 'gear.ls': 'gear.list', gear: ['gear.create', 'gear.list'] },
477
+ * ['gear.create', 'gear.list']
478
+ * );
479
+ * // => {
480
+ * // 'gear.create': [['gear', 'gear', 'create']],
481
+ * // 'gear.list': [['gear', 'gear', 'list'], ['gear', 'ls']],
482
+ * // }
483
+ * ```
484
+ */
485
+ export const expandCliSurfaceBindings = (
486
+ bindings: SurfaceBindings | undefined,
487
+ trailIds: readonly string[]
488
+ ): CliSurfaceBindingAliases | undefined => {
489
+ if (bindings === undefined) {
490
+ return undefined;
491
+ }
492
+ const names = Object.keys(bindings).toSorted();
493
+ if (names.length === 0) {
494
+ return undefined;
495
+ }
496
+
497
+ const aliasesByTrail = new Map<string, (readonly string[])[]>();
498
+ const addAlias = (trailId: string, path: readonly string[]): void => {
499
+ const existing = aliasesByTrail.get(trailId);
500
+ if (existing === undefined) {
501
+ aliasesByTrail.set(trailId, [path]);
502
+ return;
503
+ }
504
+ existing.push(path);
505
+ };
506
+
507
+ for (const name of names) {
508
+ const value = bindings[name];
509
+ if (value === undefined) {
510
+ continue;
511
+ }
512
+ const pathSegments = assertCliBindingName(name);
513
+ const shape = classifySurfaceBinding(value);
514
+ if (shape.kind === 'synonym') {
515
+ addAlias(
516
+ expandSynonymBinding(
517
+ trailIds,
518
+ 'cli',
519
+ name,
520
+ shape.trail,
521
+ 'command group',
522
+ CLI_BINDING_FIX
523
+ ),
524
+ pathSegments
525
+ );
526
+ continue;
527
+ }
528
+ for (const member of expandGroupBinding(
529
+ trailIds,
530
+ 'cli',
531
+ name,
532
+ shape.members,
533
+ CLI_BINDING_FIX
534
+ )) {
535
+ addAlias(member, [...pathSegments, ...member.split('.')]);
536
+ }
537
+ }
538
+
539
+ return Object.fromEntries(aliasesByTrail);
540
+ };
541
+
542
+ /**
543
+ * The expanded shape of the `surfaces` overlay's `mcp` bindings.
544
+ *
545
+ * `synonyms` maps MCP-safe binding names to the single trail id each one
546
+ * aliases; `groups` maps binding names to the sorted, deduplicated member
547
+ * trail ids of each grouped entry (an MCP trailhead).
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * import type { McpSurfaceBindingExpansion } from '@ontrails/core';
552
+ *
553
+ * const expansion: McpSurfaceBindingExpansion = {
554
+ * groups: { snippets: ['snippet.create', 'snippet.get'] },
555
+ * synonyms: { gear_ls: 'gear.list' },
556
+ * };
557
+ * ```
558
+ */
559
+ export interface McpSurfaceBindingExpansion {
560
+ /** Grouped entries: binding name to sorted expanded member trail ids. */
561
+ readonly groups: Readonly<Record<string, readonly string[]>>;
562
+ /** Tool synonyms: binding name to the one trail id it aliases. */
563
+ readonly synonyms: Readonly<Record<string, string>>;
564
+ }
565
+
566
+ const MCP_SAFE_BINDING_NAME_PATTERN = /^[a-z0-9_]+$/;
567
+
568
+ const assertMcpSynonymBindingName = (name: string): void => {
569
+ if (!MCP_SAFE_BINDING_NAME_PATTERN.test(name)) {
570
+ throw new ValidationError(
571
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay mcp binding "${name}" is not an MCP-safe tool name — synonym binding names become tool names verbatim and must use lowercase letters, digits, and underscores only. ${MCP_BINDING_FIX}`
572
+ );
573
+ }
574
+ };
575
+
576
+ /**
577
+ * Derive the deterministic default description for an MCP grouped entry
578
+ * rendered from the `surfaces` overlay.
579
+ *
580
+ * Both the MCP surface (the runtime tool description) and Topography (the
581
+ * lock's trailhead entry description) read this helper, so the authored
582
+ * binding renders one description everywhere.
583
+ *
584
+ * @example
585
+ * ```ts
586
+ * import { deriveMcpTrailheadDescription } from '@ontrails/core';
587
+ *
588
+ * deriveMcpTrailheadDescription(['gear.create', 'gear.list']);
589
+ * // => 'Grouped MCP entry over: gear.create, gear.list.'
590
+ * ```
591
+ */
592
+ export const deriveMcpTrailheadDescription = (
593
+ memberIds: readonly string[]
594
+ ): string => `Grouped MCP entry over: ${memberIds.join(', ')}.`;
595
+
596
+ /**
597
+ * Expand the `surfaces` overlay's `mcp` bindings against the topo's trail
598
+ * ids, validating every binding.
599
+ *
600
+ * A scalar binding is a tool synonym: its name is published verbatim as an
601
+ * additional MCP tool name, so it must be MCP-safe (`[a-z0-9_]+`) and must
602
+ * expand to exactly one trail — zero or multiple matches are a
603
+ * `ValidationError` naming the binding. A list binding is a grouped entry
604
+ * (an MCP trailhead): its members are the sorted union of the expanded
605
+ * selectors, and an empty union is a `ValidationError`. Binding names are
606
+ * processed in sorted order so the expansion is deterministic. Returns
607
+ * `undefined` when there are no `mcp` bindings.
608
+ *
609
+ * @example
610
+ * ```ts
611
+ * import { expandMcpSurfaceBindings } from '@ontrails/core';
612
+ *
613
+ * expandMcpSurfaceBindings(
614
+ * { gear_ls: 'gear.list', gear: ['gear.*'] },
615
+ * ['gear.create', 'gear.list']
616
+ * );
617
+ * // => {
618
+ * // groups: { gear: ['gear.create', 'gear.list'] },
619
+ * // synonyms: { gear_ls: 'gear.list' },
620
+ * // }
621
+ * ```
622
+ */
623
+ export const expandMcpSurfaceBindings = (
624
+ bindings: SurfaceBindings | undefined,
625
+ trailIds: readonly string[]
626
+ ): McpSurfaceBindingExpansion | undefined => {
627
+ if (bindings === undefined) {
628
+ return undefined;
629
+ }
630
+ const names = Object.keys(bindings).toSorted();
631
+ if (names.length === 0) {
632
+ return undefined;
633
+ }
634
+
635
+ const groups: Record<string, readonly string[]> = {};
636
+ const synonyms: Record<string, string> = {};
637
+ for (const name of names) {
638
+ const value = bindings[name];
639
+ if (value === undefined) {
640
+ continue;
641
+ }
642
+ const shape = classifySurfaceBinding(value);
643
+ if (shape.kind === 'synonym') {
644
+ assertMcpSynonymBindingName(name);
645
+ synonyms[name] = expandSynonymBinding(
646
+ trailIds,
647
+ 'mcp',
648
+ name,
649
+ shape.trail,
650
+ 'grouped entry',
651
+ MCP_BINDING_FIX
652
+ );
653
+ continue;
654
+ }
655
+ groups[name] = expandGroupBinding(
656
+ trailIds,
657
+ 'mcp',
658
+ name,
659
+ shape.members,
660
+ MCP_BINDING_FIX
661
+ );
662
+ }
663
+
664
+ return { groups, synonyms };
665
+ };
666
+
667
+ /**
668
+ * Read and validate the `surfaces` bindings from a lock's overlays record.
669
+ *
670
+ * The compile-side gate guarantees only app-authored envelopes can own the
671
+ * `surfaces` namespace in a committed lock, so this reader validates shape
672
+ * only — provenance was already enforced before the facts were embedded.
673
+ * Returns `undefined` when the record has no `surfaces` key; throws a
674
+ * `ValidationError` when the embedded facts fail the bindings schema.
675
+ *
676
+ * @example
677
+ * ```ts
678
+ * import { surfaceBindingsFromLockOverlays } from '@ontrails/core';
679
+ *
680
+ * const bindings = surfaceBindingsFromLockOverlays(lock.topoGraph.overlays);
681
+ * ```
682
+ */
683
+ export const surfaceBindingsFromLockOverlays = (
684
+ overlays: Readonly<Record<string, unknown>> | undefined
685
+ ): SurfaceOverlayBindings | undefined => {
686
+ const facts = overlays?.[SURFACES_OVERLAY_NAMESPACE];
687
+ if (facts === undefined) {
688
+ return undefined;
689
+ }
690
+ return parseSurfaceOverlayBindings(
691
+ facts,
692
+ 'Regenerate the lock with `trails compile` from the authored surfaceOverlay() bindings.'
693
+ );
694
+ };