broapp 0.1.0 → 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.
@@ -49,6 +49,15 @@ export type Result<T> =
49
49
  | { readonly ok: true; readonly value: T }
50
50
  | { readonly ok: false; readonly issues: readonly Issue[] };
51
51
 
52
+ /**
53
+ * A JSON Schema document, as a plain object.
54
+ *
55
+ * Broapp emits a draft 2020-12 subset — enough for a language model provider
56
+ * to describe a tool's arguments, and nothing more. It is deliberately not a
57
+ * typed tree: every consumer so far hands it straight to a provider as JSON.
58
+ */
59
+ export type JsonSchema = Record<string, unknown>;
60
+
52
61
  /** A runtime schema for one JSON value. */
53
62
  export interface Schema<T> {
54
63
  /** Discriminates a Broapp schema from a foreign one at runtime. */
@@ -57,6 +66,8 @@ export interface Schema<T> {
57
66
  check(value: unknown, path?: IssuePath): Result<T>;
58
67
  /** Validate, or throw {@link ValidationError}. */
59
68
  parse(value: unknown): T;
69
+ /** A JSON Schema (draft 2020-12 subset) describing what `parse` accepts. */
70
+ toJsonSchema(): JsonSchema;
60
71
  /** Phantom marker; never present at runtime. */
61
72
  readonly _type?: T;
62
73
  }
@@ -64,7 +75,28 @@ export interface Schema<T> {
64
75
  /** The TypeScript type a schema accepts. */
65
76
  export type Infer<S> = S extends Schema<infer T> ? T : never;
66
77
 
67
- function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Result<T>): Schema<T> {
78
+ /** Collapse an intersection back into one object type, keeping `?` modifiers. */
79
+ type Flatten<T> = { [K in keyof T]: T[K] };
80
+
81
+ /**
82
+ * What an object schema accepts.
83
+ *
84
+ * A field wrapped in `s.optional` may be left out entirely, so its key is
85
+ * optional here rather than merely admitting `undefined`. Without this an
86
+ * `s.optional` field would still have to be spelled out at every construction
87
+ * site, which is the opposite of what the wrapper says.
88
+ */
89
+ export type InferObject<F> = Flatten<
90
+ { [K in keyof F as undefined extends Infer<F[K]> ? never : K]: Infer<F[K]> } & {
91
+ [K in keyof F as undefined extends Infer<F[K]> ? K : never]?: Infer<F[K]>;
92
+ }
93
+ >;
94
+
95
+ function schema<T>(
96
+ kind: string,
97
+ check: (value: unknown, path: IssuePath) => Result<T>,
98
+ toJsonSchema: () => JsonSchema,
99
+ ): Schema<T> {
68
100
  const self: Schema<T> = {
69
101
  kind,
70
102
  check: (value, path = []) => check(value, path),
@@ -73,10 +105,25 @@ function schema<T>(kind: string, check: (value: unknown, path: IssuePath) => Res
73
105
  if (outcome.ok) return outcome.value;
74
106
  throw new ValidationError(outcome.issues);
75
107
  },
108
+ toJsonSchema,
76
109
  };
77
110
  return self;
78
111
  }
79
112
 
113
+ /**
114
+ * Assemble a JSON Schema object, dropping every keyword whose option was not
115
+ * given. An explicit `minLength: undefined` disappears from `JSON.stringify`
116
+ * but is still a key at runtime, and a provider that enumerates keywords would
117
+ * see it — so the key is never created in the first place.
118
+ */
119
+ function keywords(entries: Record<string, unknown>): JsonSchema {
120
+ const out: JsonSchema = {};
121
+ for (const [key, value] of Object.entries(entries)) {
122
+ if (value !== undefined) out[key] = value;
123
+ }
124
+ return out;
125
+ }
126
+
80
127
  function fail<T = never>(path: IssuePath, message: string): Result<T> {
81
128
  return { ok: false, issues: [{ path, message }] };
82
129
  }
@@ -118,7 +165,15 @@ export const s = {
118
165
  if (!anchored.test(value)) return fail(path, 'does not match the required format');
119
166
  }
120
167
  return { ok: true, value };
121
- });
168
+ },
169
+ () =>
170
+ keywords({
171
+ type: 'string',
172
+ minLength: options.min,
173
+ maxLength: options.max,
174
+ pattern: options.pattern?.source,
175
+ }),
176
+ );
122
177
  },
123
178
 
124
179
  number(options: NumberOptions = {}): Schema<number> {
@@ -134,30 +189,46 @@ export const s = {
134
189
  return fail(path, `expected <= ${String(options.max)}`);
135
190
  }
136
191
  return { ok: true, value };
137
- });
192
+ },
193
+ () =>
194
+ keywords({
195
+ type: options.int === true ? 'integer' : 'number',
196
+ minimum: options.min,
197
+ maximum: options.max,
198
+ }),
199
+ );
138
200
  },
139
201
 
140
202
  boolean(): Schema<boolean> {
141
- return schema('boolean', (value, path) =>
142
- typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
203
+ return schema(
204
+ 'boolean',
205
+ (value, path) =>
206
+ typeof value === 'boolean' ? { ok: true, value } : fail(path, 'expected a boolean'),
207
+ () => ({ type: 'boolean' }),
143
208
  );
144
209
  },
145
210
 
146
211
  literal<const T extends string | number | boolean>(expected: T): Schema<T> {
147
- return schema('literal', (value, path) =>
148
- value === expected
149
- ? { ok: true, value: expected }
150
- : fail(path, `expected ${JSON.stringify(expected)}`),
212
+ return schema(
213
+ 'literal',
214
+ (value, path) =>
215
+ value === expected
216
+ ? { ok: true, value: expected }
217
+ : fail(path, `expected ${JSON.stringify(expected)}`),
218
+ () => ({ const: expected }),
151
219
  );
152
220
  },
153
221
 
154
222
  /** A closed set of string values. */
155
223
  enum<const T extends readonly string[]>(values: T): Schema<T[number]> {
156
224
  const allowed = new Set<string>(values);
157
- return schema('enum', (value, path) =>
158
- typeof value === 'string' && allowed.has(value)
159
- ? { ok: true, value: value as T[number] }
160
- : fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
225
+ return schema(
226
+ 'enum',
227
+ (value, path) =>
228
+ typeof value === 'string' && allowed.has(value)
229
+ ? { ok: true, value: value as T[number] }
230
+ : fail(path, `expected one of ${values.map((v) => JSON.stringify(v)).join(', ')}`),
231
+ () => ({ type: 'string', enum: [...values] }),
161
232
  );
162
233
  },
163
234
 
@@ -178,7 +249,15 @@ export const s = {
178
249
  else issues.push(...outcome.issues);
179
250
  }
180
251
  return issues.length > 0 ? { ok: false, issues } : { ok: true, value: out };
181
- });
252
+ },
253
+ () =>
254
+ keywords({
255
+ type: 'array',
256
+ items: item.toJsonSchema(),
257
+ minItems: options.min,
258
+ maxItems: options.max,
259
+ }),
260
+ );
182
261
  },
183
262
 
184
263
  /**
@@ -188,9 +267,7 @@ export const s = {
188
267
  * handler contains only what the schema named, so a property smuggled in by
189
268
  * a caller cannot reach application code by accident.
190
269
  */
191
- object<F extends Record<string, Schema<unknown>>>(
192
- fields: F,
193
- ): Schema<{ [K in keyof F]: Infer<F[K]> }> {
270
+ object<F extends Record<string, Schema<unknown>>>(fields: F): Schema<InferObject<F>> {
194
271
  const entries = Object.entries(fields);
195
272
  return schema('object', (value, path) => {
196
273
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -208,30 +285,50 @@ export const s = {
208
285
  }
209
286
  return issues.length > 0
210
287
  ? { ok: false, issues }
211
- : { ok: true, value: out as { [K in keyof F]: Infer<F[K]> } };
212
- });
288
+ : { ok: true, value: out as InferObject<F> };
289
+ },
290
+ () => ({
291
+ type: 'object',
292
+ properties: Object.fromEntries(entries.map(([key, field]) => [key, field.toJsonSchema()])),
293
+ // An optional field is one the object may leave out, so optionality is
294
+ // expressed here rather than inside the field's own schema.
295
+ required: entries.filter(([, field]) => field.kind !== 'optional').map(([key]) => key),
296
+ additionalProperties: false,
297
+ }),
298
+ );
213
299
  },
214
300
 
215
301
  /** A value that may be absent or `undefined`. */
216
302
  optional<T>(inner: Schema<T>): Schema<T | undefined> {
217
- return schema<T | undefined>('optional', (value, path) =>
218
- value === undefined ? { ok: true, value: undefined } : inner.check(value, path),
303
+ return schema<T | undefined>(
304
+ 'optional',
305
+ (value, path) => (value === undefined ? { ok: true, value: undefined } : inner.check(value, path)),
306
+ // JSON Schema has no "optional" keyword; the enclosing object omits the
307
+ // key from `required` instead.
308
+ () => inner.toJsonSchema(),
219
309
  );
220
310
  },
221
311
 
222
312
  /** A value that may be `null`. */
223
313
  nullable<T>(inner: Schema<T>): Schema<T | null> {
224
- return schema<T | null>('nullable', (value, path) =>
225
- value === null ? { ok: true, value: null } : inner.check(value, path),
314
+ return schema<T | null>(
315
+ 'nullable',
316
+ (value, path) => (value === null ? { ok: true, value: null } : inner.check(value, path)),
317
+ () => ({ anyOf: [inner.toJsonSchema(), { type: 'null' }] }),
226
318
  );
227
319
  },
228
320
 
229
321
  /** Nothing at all. The input type of an operation that takes no argument. */
230
322
  void(): Schema<void> {
231
- return schema('void', (value, path) =>
232
- value === undefined || value === null
233
- ? { ok: true, value: undefined }
234
- : fail(path, 'expected no value'),
323
+ return schema(
324
+ 'void',
325
+ (value, path) =>
326
+ value === undefined || value === null
327
+ ? { ok: true, value: undefined }
328
+ : fail(path, 'expected no value'),
329
+ // A provider that asks for a tool's arguments wants an object, and an
330
+ // operation that takes nothing takes an empty one.
331
+ () => ({ type: 'object', properties: {}, additionalProperties: false }),
235
332
  );
236
333
  },
237
334
 
@@ -243,6 +340,6 @@ export const s = {
243
340
  * data is untrusted.
244
341
  */
245
342
  unknown(): Schema<unknown> {
246
- return schema('unknown', (value) => ({ ok: true, value }));
343
+ return schema('unknown', (value) => ({ ok: true, value }), () => ({}));
247
344
  },
248
345
  } as const;