@modelprofile.com/flexharness 10.12.0 → 10.13.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.
package/ts/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './classes.flexharness.js';
2
2
  export * from './classes.stores.js';
3
+ export { FlexToolCatalog } from './classes.toolcatalog.js';
3
4
  export * from './errors.js';
4
5
  export * from './interfaces.js';
5
6
  export { normalizeJsonValue } from './utils.json.js';
package/ts/interfaces.ts CHANGED
@@ -648,15 +648,179 @@ export interface IFlexToolProviderContext<TScope> extends IFlexSessionGeneration
648
648
  requestPermission(request: IFlexPermissionRequestInput): Promise<void>;
649
649
  }
650
650
 
651
- export interface IFlexToolHandle {
651
+ export interface IFlexToolHandle<TScope = unknown> {
652
652
  tools: TFlexAgentToolSet;
653
+ /**
654
+ * Methods the model finds with `search_tools`, reads with `describe_tools` and calls with
655
+ * `execute`, instead of seeing each one as a tool on every step. Return the same object on
656
+ * every run: FlexHarness prepares a catalog once per object and treats it as immutable.
657
+ */
658
+ catalog?: IFlexToolCatalog<TScope>;
653
659
  close?(): Promise<void> | void;
654
660
  }
655
661
 
656
662
  export interface IFlexToolProvider<TScope> {
657
663
  provideTools(
658
664
  context: IFlexToolProviderContext<TScope>,
659
- ): Promise<IFlexToolHandle | undefined> | IFlexToolHandle | undefined;
665
+ ): Promise<IFlexToolHandle<TScope> | undefined> | IFlexToolHandle<TScope> | undefined;
666
+ }
667
+
668
+ /** The names of the built-in catalog tools, reserved while a run has a catalog. */
669
+ export const FLEX_TOOL_CATALOG_TOOL_NAMES = Object.freeze({
670
+ search: 'search_tools',
671
+ describe: 'describe_tools',
672
+ execute: 'execute',
673
+ } as const);
674
+
675
+ export const FLEX_TOOL_CATALOG_LIMITS = Object.freeze({
676
+ maxMethods: 4096,
677
+ maxTypes: 4096,
678
+ maxMethodPathCharacters: 160,
679
+ maxMethodPathSegments: 8,
680
+ maxIdentifierCharacters: 128,
681
+ maxSummaryCharacters: 120,
682
+ maxDescriptionBytes: 16 * 1024,
683
+ maxDeclarationBytes: 16 * 1024,
684
+ maxTags: 16,
685
+ maxTagCharacters: 48,
686
+ maxExamples: 4,
687
+ maxExampleBytes: 1024,
688
+ maxSchemaBytes: 64 * 1024,
689
+ defaultSearchResults: 10,
690
+ maxSearchResults: 50,
691
+ maxQueryCharacters: 512,
692
+ maxDescribeMethods: 16,
693
+ maxDescribeTypes: 32,
694
+ maxDescribeBytes: 64 * 1024,
695
+ maxParamIssues: 10,
696
+ });
697
+
698
+ export interface IFlexCatalogSearchInput {
699
+ /** Words to rank methods by. */
700
+ query?: string;
701
+ /** A dotted namespace to search in or, without `query`, to list. */
702
+ namespace?: string;
703
+ limit?: number;
704
+ offset?: number;
705
+ }
706
+
707
+ export interface IFlexCatalogDescribeInput {
708
+ /** Dotted method paths. */
709
+ methods?: readonly string[];
710
+ /** Named types to declare again or for the first time. */
711
+ types?: readonly string[];
712
+ }
713
+
714
+ export interface IFlexCatalogDescribeResult {
715
+ /** TypeScript text, or a JSON object in `json-schema` mode. */
716
+ output: TJsonValue;
717
+ /** The named types the output declares. */
718
+ declaredTypes: string[];
719
+ }
720
+
721
+ /** One way `execute`'s params miss the method's schema. */
722
+ export interface IFlexCatalogParamIssue {
723
+ /** Where, such as `params.lines[0].amount`. */
724
+ path: string;
725
+ expected: string;
726
+ /** The given value as compact JSON, or `missing`. */
727
+ got: string;
728
+ }
729
+
730
+ /** What `execute` reports to the model, as JSON, when it cannot call a method. */
731
+ export type TFlexCatalogCallError =
732
+ | {
733
+ error: 'unknown_method';
734
+ method: string;
735
+ didYouMean: string[];
736
+ hint: string;
737
+ }
738
+ | {
739
+ error: 'invalid_params';
740
+ method: string;
741
+ issues: IFlexCatalogParamIssue[];
742
+ /** How many more issues were found than listed. */
743
+ more?: number;
744
+ /** The lookup that returns the method's declaration. */
745
+ see: string;
746
+ };
747
+
748
+ export interface IFlexCatalogToolDefinition {
749
+ description: string;
750
+ /** The tool's input as JSON Schema. */
751
+ inputSchema: plugins.JSONSchema7;
752
+ }
753
+
754
+ /**
755
+ * A parameter or result schema: a plain JSON Schema object, or a zod, Standard Schema or AI SDK
756
+ * schema. `$ref`s to `#/$defs/<Name>` or `#/definitions/<Name>` that the schema does not define name
757
+ * a catalog type.
758
+ */
759
+ export type TFlexCatalogSchema = { readonly [keyword: string]: unknown } | plugins.FlexibleSchema<unknown>;
760
+
761
+ export interface IFlexCatalogRunContext<TScope = unknown> {
762
+ /** The dotted path the model called. */
763
+ readonly method: string;
764
+ /** The `execute` call's ID; pass it with permission requests. */
765
+ readonly toolCallId: string;
766
+ readonly abortSignal: AbortSignal;
767
+ /** The run's tool-provider context: scope, session, run and `requestPermission`. */
768
+ readonly provider: Readonly<IFlexToolProviderContext<TScope>>;
769
+ }
770
+
771
+ export interface IFlexCatalogMethod<TScope = unknown, TParams = any, TResult = unknown> {
772
+ /**
773
+ * The dotted path the model calls, such as `banking.listMovements`: up to 8 segments, each a
774
+ * JavaScript identifier of letters, digits and `_`.
775
+ */
776
+ method: string;
777
+ /** One line of at most 120 characters, shown in search results. */
778
+ summary: string;
779
+ /** The full description, shown with the declaration. */
780
+ description?: string;
781
+ /** More words `search_tools` matches. */
782
+ tags?: readonly string[];
783
+ /** Code shown as `@example` in the declaration. */
784
+ examples?: readonly string[];
785
+ /** The parameter object's schema; `execute` validates every call against it. */
786
+ inputSchema: TFlexCatalogSchema;
787
+ /** The result's schema, declared as the method's return type. */
788
+ outputSchema?: TFlexCatalogSchema;
789
+ /**
790
+ * Declaration text that replaces the generated one, such as a declaration built from TypeScript
791
+ * sources with their JSDoc: `function listMovements(params: …): Promise<…>;`, optionally after
792
+ * its JSDoc, with the last path segment as the function name.
793
+ */
794
+ declaration?: string;
795
+ /** Runs the method with validated parameters. */
796
+ run(
797
+ params: TParams,
798
+ context: IFlexCatalogRunContext<TScope>,
799
+ ): TResult | PromiseLike<TResult> | AsyncIterable<TResult>;
800
+ }
801
+
802
+ /** A named type that method declarations refer to; it needs a `declaration`, a `schema`, or both. */
803
+ export interface IFlexCatalogType {
804
+ /** A JavaScript identifier such as `IMoney`. */
805
+ name: string;
806
+ description?: string;
807
+ /** Validates parameters that refer to this type, and generates its declaration. */
808
+ schema?: TFlexCatalogSchema;
809
+ /** Declaration text that replaces the generated one, such as `interface IMoney { … }`. */
810
+ declaration?: string;
811
+ }
812
+
813
+ export interface IFlexToolCatalog<TScope = unknown> {
814
+ methods: readonly IFlexCatalogMethod<TScope>[];
815
+ types?: readonly IFlexCatalogType[];
816
+ }
817
+
818
+ export interface IFlexToolCatalogOptions {
819
+ /**
820
+ * What `describe_tools` returns: `typescript` (default) declarations with named types shared
821
+ * across the session, or `json-schema` with each method's self-contained JSON Schema.
822
+ */
823
+ declarations?: 'typescript' | 'json-schema';
660
824
  }
661
825
 
662
826
  export interface IFlexResourceToolProviderResolverContext<TScope> extends IFlexSessionGeneration {
@@ -1419,6 +1583,7 @@ export interface IFlexHarnessOptions<TScope> {
1419
1583
  externalErrorProjector?: TFlexExternalErrorProjector;
1420
1584
  subagents?: IFlexSubagentDefinition[];
1421
1585
  builtInTools?: IFlexBuiltInToolsOptions;
1586
+ toolCatalog?: IFlexToolCatalogOptions;
1422
1587
  slashCommands?: readonly TFlexSlashCommandRegistration<TScope>[];
1423
1588
  /** Maximum child depth (root is 0). Defaults to 6; configurable from 1 through 8. */
1424
1589
  maxSubagentDepth?: number;
package/ts/plugins.ts CHANGED
@@ -6,6 +6,13 @@ import * as path from 'node:path';
6
6
 
7
7
  export { AsyncLocalStorage, crypto, fs, path };
8
8
 
9
+ // third party scope
10
+ import { asSchema, jsonSchema } from 'ai';
11
+ import { Validator as JsonSchemaValidator } from '@cfworker/json-schema';
12
+
13
+ export { asSchema, jsonSchema, JsonSchemaValidator };
14
+ export type { FlexibleSchema, JSONSchema7, ModelMessage, Schema } from 'ai';
15
+
9
16
  // @push.rocks scope
10
17
  import {
11
18
  AgentEventStoreConflictError,
@@ -13,6 +13,7 @@ import type {
13
13
  IFlexSlashCommandHandlerRegistration,
14
14
  IFlexSlashCommandTemplateRegistration,
15
15
  IFlexSubagentDefinition,
16
+ IFlexToolCatalogOptions,
16
17
  TFlexSlashCommandRegistration,
17
18
  } from './interfaces.js';
18
19
  import {
@@ -156,6 +157,27 @@ export function normalizeSubagents(
156
157
  return Object.freeze(normalized);
157
158
  }
158
159
 
160
+ export function normalizeToolCatalogOptions(
161
+ options: IFlexHarnessOptions<unknown>['toolCatalog'],
162
+ ): Readonly<Required<IFlexToolCatalogOptions>> {
163
+ if (options === undefined) return Object.freeze({ declarations: 'typescript' });
164
+ if (
165
+ !options
166
+ || typeof options !== 'object'
167
+ || Array.isArray(options)
168
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(options))
169
+ ) {
170
+ throw new FlexHarnessValidationError('toolCatalog must be a plain object.');
171
+ }
172
+ const unsupported = Object.keys(options).find((key) => key !== 'declarations');
173
+ if (unsupported) throw new FlexHarnessValidationError(`toolCatalog does not support "${unsupported}".`);
174
+ const declarations = options.declarations ?? 'typescript';
175
+ if (declarations !== 'typescript' && declarations !== 'json-schema') {
176
+ throw new FlexHarnessValidationError('toolCatalog.declarations must be "typescript" or "json-schema".');
177
+ }
178
+ return Object.freeze({ declarations });
179
+ }
180
+
159
181
  export function normalizeBuiltInTools(
160
182
  options: IFlexHarnessOptions<unknown>['builtInTools'],
161
183
  ): INormalizedBuiltInTools {
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Renders JSON Schema as TypeScript declarations for the tool catalog. The output depends only on
3
+ * the schema, so the same schema always renders to the same bytes.
4
+ */
5
+
6
+ export type TJsonSchemaNode = boolean | { [key: string]: unknown };
7
+
8
+ export interface ISchemaDeclarationContext {
9
+ /** The named type a `$ref` denotes, or undefined to inline the referenced schema. */
10
+ namedType(ref: string): string | undefined;
11
+ /** The schema a local `$ref` points to. */
12
+ resolve(ref: string): TJsonSchemaNode | undefined;
13
+ }
14
+
15
+ export interface IRenderedDeclaration {
16
+ text: string;
17
+ /** Named types the text refers to, sorted. */
18
+ types: string[];
19
+ }
20
+
21
+ const identifierPattern = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
22
+ const maxRenderDepth = 32;
23
+ const maxInlineObjectCharacters = 100;
24
+ const maxSafeInteger = Number.MAX_SAFE_INTEGER;
25
+
26
+ export const isSchemaRecord = (value: unknown): value is { [key: string]: unknown } =>
27
+ typeof value === 'object' && value !== null && !Array.isArray(value);
28
+
29
+ export const isIdentifier = (value: string): boolean => identifierPattern.test(value);
30
+
31
+ const literal = (value: unknown): string => {
32
+ if (typeof value === 'string') return JSON.stringify(value);
33
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
34
+ if (typeof value === 'boolean' || value === null) return String(value);
35
+ return 'unknown';
36
+ };
37
+
38
+ /**
39
+ * Reports whether `character` occurs outside brackets, braces, parentheses, string literals and
40
+ * comments.
41
+ */
42
+ const hasTopLevel = (text: string, character: string): boolean => {
43
+ let depth = 0;
44
+ let quote: string | undefined;
45
+ for (let index = 0; index < text.length; index++) {
46
+ const current = text[index];
47
+ if (quote) {
48
+ if (current === '\\') index++;
49
+ else if (current === quote) quote = undefined;
50
+ continue;
51
+ }
52
+ if (current === '/' && text[index + 1] === '*') {
53
+ const end = text.indexOf('*/', index + 2);
54
+ if (end === -1) return false;
55
+ index = end + 1;
56
+ continue;
57
+ }
58
+ if (current === '"' || current === "'") quote = current;
59
+ else if (current === '{' || current === '[' || current === '(' || current === '<') depth++;
60
+ else if (current === '}' || current === ']' || current === ')' || current === '>') depth--;
61
+ else if (depth === 0 && current === character) return true;
62
+ }
63
+ return false;
64
+ };
65
+
66
+ const union = (parts: string[]): string => {
67
+ const unique = [...new Set(parts)];
68
+ if (unique.includes('unknown')) return 'unknown';
69
+ const withoutNever = unique.length > 1 ? unique.filter((part) => part !== 'never') : unique;
70
+ return withoutNever.join(' | ');
71
+ };
72
+
73
+ const intersection = (parts: string[]): string => {
74
+ const unique = [...new Set(parts)].filter((part) => part !== 'unknown');
75
+ if (unique.length === 0) return 'unknown';
76
+ return unique.map((part) => hasTopLevel(part, '|') ? `(${part})` : part).join(' & ');
77
+ };
78
+
79
+ const arrayOf = (element: string): string =>
80
+ hasTopLevel(element, '|') || hasTopLevel(element, '&') ? `(${element})[]` : `${element}[]`;
81
+
82
+ const propertyKey = (name: string): string => isIdentifier(name) ? name : JSON.stringify(name);
83
+
84
+ /** A JSDoc block at `indent`; a short single line stays on one line. */
85
+ export const docBlock = (text: string, indent: string): string => {
86
+ const lines = text.replace(/\*\//g, '*\\/').split('\n').map((line) => line.trimEnd());
87
+ if (lines.length === 1 && indent.length + lines[0].length <= 100) return `${indent}/** ${lines[0]} */\n`;
88
+ return `${indent}/**\n${lines.map((line) => line ? `${indent} * ${line}` : `${indent} *`).join('\n')}\n${indent} */\n`;
89
+ };
90
+
91
+ export const countOf = (count: number, unit: string): string => `${count} ${unit}${count === 1 ? '' : 's'}`;
92
+
93
+ /** Constraints a TypeScript type cannot state, in a short readable form. */
94
+ const constraintsOf = (schema: { [key: string]: unknown }): string[] => {
95
+ const constraints: string[] = [];
96
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
97
+ if (types.includes('integer')) constraints.push('integer');
98
+ if (typeof schema.format === 'string') constraints.push(`format ${schema.format}`);
99
+ const bound = (value: unknown): value is number =>
100
+ typeof value === 'number' && Number.isFinite(value) && Math.abs(value) !== maxSafeInteger;
101
+ if (bound(schema.exclusiveMinimum)) constraints.push(`> ${schema.exclusiveMinimum}`);
102
+ else if (bound(schema.minimum)) constraints.push(`${schema.exclusiveMinimum === true ? '>' : '>='} ${schema.minimum}`);
103
+ if (bound(schema.exclusiveMaximum)) constraints.push(`< ${schema.exclusiveMaximum}`);
104
+ else if (bound(schema.maximum)) constraints.push(`${schema.exclusiveMaximum === true ? '<' : '<='} ${schema.maximum}`);
105
+ if (bound(schema.multipleOf)) constraints.push(`multiple of ${schema.multipleOf}`);
106
+ const range = (min: unknown, max: unknown, unit: string): void => {
107
+ const hasMin = bound(min) && min > 0;
108
+ const hasMax = bound(max);
109
+ if (hasMin && hasMax) constraints.push(min === max ? countOf(min, unit) : `${min}-${countOf(max, unit)}`);
110
+ else if (hasMin) constraints.push(`at least ${countOf(min, unit)}`);
111
+ else if (hasMax) constraints.push(`at most ${countOf(max, unit)}`);
112
+ };
113
+ range(schema.minLength, schema.maxLength, 'character');
114
+ range(schema.minItems, schema.maxItems, 'item');
115
+ if (schema.uniqueItems === true) constraints.push('unique items');
116
+ if (typeof schema.pattern === 'string') constraints.push(`pattern /${schema.pattern}/`);
117
+ if (schema.default !== undefined) {
118
+ const value = JSON.stringify(schema.default);
119
+ if (value !== undefined && value.length <= 60) constraints.push(`default ${value}`);
120
+ }
121
+ if (schema.deprecated === true) constraints.push('deprecated');
122
+ return constraints;
123
+ };
124
+
125
+ /** The documentation of a property: its description and the constraints its type cannot state. */
126
+ export const schemaDoc = (schema: TJsonSchemaNode, description?: string): string | undefined => {
127
+ if (!isSchemaRecord(schema)) return description?.trim() || undefined;
128
+ const text = (description ?? (typeof schema.description === 'string' ? schema.description : '')).trim();
129
+ const constraints = constraintsOf(schema);
130
+ if (constraints.length === 0) return text || undefined;
131
+ return text ? `${text} (${constraints.join(', ')})` : constraints.join(', ');
132
+ };
133
+
134
+ export class SchemaDeclarationWriter {
135
+ private readonly used = new Set<string>();
136
+ private readonly inlining = new Set<string>();
137
+
138
+ constructor(private readonly context: ISchemaDeclarationContext) {}
139
+
140
+ public get usedTypes(): string[] {
141
+ return [...this.used].sort();
142
+ }
143
+
144
+ /** The TypeScript type of `schema`, with continuation lines indented relative to `indent`. */
145
+ public type(schema: TJsonSchemaNode, indent = '', depth = 0, omitMemberDocs = false): string {
146
+ if (depth > maxRenderDepth) return 'unknown';
147
+ if (schema === true) return 'unknown';
148
+ if (schema === false) return 'never';
149
+ if (!isSchemaRecord(schema)) return 'unknown';
150
+ const base = this.baseType(schema, indent, depth, omitMemberDocs);
151
+ return schema.nullable === true ? union([base, 'null']) : base;
152
+ }
153
+
154
+ /** The members of an object schema; undefined when the schema is not a plain object type. */
155
+ public objectMembers(
156
+ schema: TJsonSchemaNode,
157
+ ): Array<{ name: string; doc?: string }> | undefined {
158
+ if (!isSchemaRecord(schema) || !this.isPlainObject(schema)) return undefined;
159
+ const properties = isSchemaRecord(schema.properties) ? schema.properties : {};
160
+ return Object.entries(properties).map(([name, property]) => {
161
+ const doc = schemaDoc(property as TJsonSchemaNode);
162
+ return { name, ...(doc === undefined ? {} : { doc }) };
163
+ });
164
+ }
165
+
166
+ /** Whether an object schema requires none of its properties. */
167
+ public hasNoRequiredProperties(schema: TJsonSchemaNode): boolean {
168
+ return isSchemaRecord(schema)
169
+ && this.isPlainObject(schema)
170
+ && !(Array.isArray(schema.required) && schema.required.length > 0);
171
+ }
172
+
173
+ /** `interface Name { … }` for a plain object schema, otherwise `type Name = …;`. */
174
+ public namedDeclaration(name: string, schema: TJsonSchemaNode, description?: string): string {
175
+ const doc = isSchemaRecord(schema)
176
+ ? (description ?? (typeof schema.description === 'string' ? schema.description : undefined))?.trim()
177
+ : description?.trim();
178
+ const prefix = doc ? docBlock(doc, '') : '';
179
+ if (isSchemaRecord(schema) && this.isPlainObject(schema) && schema.nullable !== true) {
180
+ return `${prefix}interface ${name} ${this.objectType(schema, '', 0, false)}`;
181
+ }
182
+ return `${prefix}type ${name} = ${this.type(schema, '', 0)};`;
183
+ }
184
+
185
+ private isPlainObject(schema: { [key: string]: unknown }): boolean {
186
+ if (schema.$ref !== undefined || 'const' in schema || schema.enum !== undefined) return false;
187
+ if (schema.anyOf !== undefined || schema.oneOf !== undefined || schema.allOf !== undefined) return false;
188
+ if (schema.type === 'object') return true;
189
+ return schema.type === undefined && isSchemaRecord(schema.properties);
190
+ }
191
+
192
+ private baseType(
193
+ schema: { [key: string]: unknown },
194
+ indent: string,
195
+ depth: number,
196
+ omitMemberDocs: boolean,
197
+ ): string {
198
+ if (typeof schema.$ref === 'string') return this.reference(schema.$ref, indent, depth);
199
+ if ('const' in schema) return literal(schema.const);
200
+ if (Array.isArray(schema.enum)) return schema.enum.length ? union(schema.enum.map(literal)) : 'never';
201
+ const alternatives = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
202
+ if (alternatives?.length) {
203
+ return union(alternatives.map((alternative) => this.type(alternative as TJsonSchemaNode, indent, depth + 1)));
204
+ }
205
+ if (Array.isArray(schema.allOf) && schema.allOf.length) {
206
+ const own = { ...schema };
207
+ delete own.allOf;
208
+ const hasOwnShape = own.type !== undefined || own.properties !== undefined;
209
+ return intersection([
210
+ ...schema.allOf.map((part) => this.type(part as TJsonSchemaNode, indent, depth + 1)),
211
+ ...(hasOwnShape ? [this.type(own, indent, depth + 1)] : []),
212
+ ]);
213
+ }
214
+ const types = Array.isArray(schema.type)
215
+ ? schema.type.filter((type): type is string => typeof type === 'string')
216
+ : typeof schema.type === 'string'
217
+ ? [schema.type]
218
+ : this.inferredTypes(schema);
219
+ if (types.length === 0) return 'unknown';
220
+ return union(types.map((type) => {
221
+ switch (type) {
222
+ case 'string': return 'string';
223
+ case 'number':
224
+ case 'integer': return 'number';
225
+ case 'boolean': return 'boolean';
226
+ case 'null': return 'null';
227
+ case 'array': return this.arrayType(schema, indent, depth);
228
+ case 'object': return this.objectType(schema, indent, depth, omitMemberDocs);
229
+ default: return 'unknown';
230
+ }
231
+ }));
232
+ }
233
+
234
+ private inferredTypes(schema: { [key: string]: unknown }): string[] {
235
+ if (
236
+ schema.properties !== undefined
237
+ || schema.additionalProperties !== undefined
238
+ || schema.patternProperties !== undefined
239
+ || schema.required !== undefined
240
+ ) return ['object'];
241
+ if (schema.items !== undefined || schema.prefixItems !== undefined) return ['array'];
242
+ return [];
243
+ }
244
+
245
+ private reference(ref: string, indent: string, depth: number): string {
246
+ const name = this.context.namedType(ref);
247
+ if (name) {
248
+ this.used.add(name);
249
+ return name;
250
+ }
251
+ const target = this.context.resolve(ref);
252
+ if (target === undefined || this.inlining.has(ref)) return 'unknown';
253
+ this.inlining.add(ref);
254
+ try {
255
+ return this.type(target, indent, depth + 1);
256
+ } finally {
257
+ this.inlining.delete(ref);
258
+ }
259
+ }
260
+
261
+ private arrayType(schema: { [key: string]: unknown }, indent: string, depth: number): string {
262
+ const tuple = Array.isArray(schema.prefixItems)
263
+ ? schema.prefixItems
264
+ : Array.isArray(schema.items) ? schema.items : undefined;
265
+ if (tuple) {
266
+ const rest = Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems;
267
+ const members = tuple.map((item) => this.type(item as TJsonSchemaNode, indent, depth + 1));
268
+ if (rest !== undefined && rest !== false) {
269
+ members.push(`...${arrayOf(this.type(rest as TJsonSchemaNode, indent, depth + 1))}`);
270
+ }
271
+ return `[${members.join(', ')}]`;
272
+ }
273
+ if (schema.items === undefined) return 'unknown[]';
274
+ return arrayOf(this.type(schema.items as TJsonSchemaNode, indent, depth + 1));
275
+ }
276
+
277
+ private objectType(
278
+ schema: { [key: string]: unknown },
279
+ indent: string,
280
+ depth: number,
281
+ omitMemberDocs: boolean,
282
+ ): string {
283
+ const memberIndent = `${indent} `;
284
+ const properties = isSchemaRecord(schema.properties) ? schema.properties : {};
285
+ const required = new Set(Array.isArray(schema.required)
286
+ ? schema.required.filter((name): name is string => typeof name === 'string')
287
+ : []);
288
+ const members: Array<{ doc?: string; text: string }> = [];
289
+ for (const [name, property] of Object.entries(properties)) {
290
+ const type = this.type(property as TJsonSchemaNode, memberIndent, depth + 1);
291
+ const doc = omitMemberDocs ? undefined : schemaDoc(property as TJsonSchemaNode);
292
+ members.push({
293
+ ...(doc === undefined ? {} : { doc }),
294
+ text: `${propertyKey(name)}${required.has(name) ? '' : '?'}: ${type}`,
295
+ });
296
+ }
297
+ const indexTypes: string[] = [];
298
+ if (isSchemaRecord(schema.patternProperties)) {
299
+ for (const property of Object.values(schema.patternProperties)) {
300
+ indexTypes.push(this.type(property as TJsonSchemaNode, memberIndent, depth + 1));
301
+ }
302
+ }
303
+ if (isSchemaRecord(schema.additionalProperties)) {
304
+ indexTypes.push(this.type(schema.additionalProperties, memberIndent, depth + 1));
305
+ } else if (schema.additionalProperties === true && members.length > 0) {
306
+ indexTypes.push('unknown');
307
+ }
308
+ if (indexTypes.length) members.push({ text: `[key: string]: ${union(indexTypes)}` });
309
+ if (members.length === 0) return schema.additionalProperties === false ? '{}' : 'Record<string, unknown>';
310
+ if (members.every((member) => member.doc === undefined && !member.text.includes('\n'))) {
311
+ const inline = `{ ${members.map((member) => member.text).join('; ')} }`;
312
+ if (inline.length <= maxInlineObjectCharacters) return inline;
313
+ }
314
+ const lines = members.map((member) =>
315
+ `${member.doc === undefined ? '' : docBlock(member.doc, memberIndent)}${memberIndent}${member.text};`);
316
+ return `{\n${lines.join('\n')}\n${indent}}`;
317
+ }
318
+ }
319
+
320
+ /** Indents every non-empty line of `text`. */
321
+ export const indentLines = (text: string, indent: string): string =>
322
+ text.split('\n').map((line) => line ? `${indent}${line}` : line).join('\n');