@typia/utils 12.0.0-dev.20260316 → 12.0.1

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.
@@ -1,360 +1,360 @@
1
- import {
2
- IHttpLlmApplication,
3
- IHttpLlmFunction,
4
- IHttpMigrateApplication,
5
- IHttpMigrateRoute,
6
- IJsonSchemaTransformError,
7
- ILlmSchema,
8
- IResult,
9
- OpenApi,
10
- } from "@typia/interface";
11
-
12
- import { LlmSchemaConverter } from "../../converters/LlmSchemaConverter";
13
- import { LlmJson } from "../../utils";
14
- import { OpenApiValidator } from "../../validators/OpenApiValidator";
15
-
16
- /**
17
- * Composes {@link IHttpLlmApplication} from an {@link IHttpMigrateApplication}.
18
- *
19
- * Converts OpenAPI-migrated HTTP routes into LLM function calling schemas,
20
- * filtering out unsupported methods (HEAD) and content types
21
- * (multipart/form-data), and shortening function names to fit the configured
22
- * maximum length.
23
- */
24
- export namespace HttpLlmApplicationComposer {
25
- /**
26
- * Builds an {@link IHttpLlmApplication} from migrated HTTP routes.
27
- *
28
- * Iterates all routes, converts each to an {@link IHttpLlmFunction}, and
29
- * collects conversion errors. Applies function name shortening at the end.
30
- */
31
- export const application = (props: {
32
- migrate: IHttpMigrateApplication;
33
- config?: Partial<IHttpLlmApplication.IConfig>;
34
- }): IHttpLlmApplication => {
35
- // fill in config defaults
36
- const config: IHttpLlmApplication.IConfig = {
37
- maxLength: props.config?.maxLength ?? 64,
38
- equals: props.config?.equals ?? false,
39
- strict: props.config?.strict ?? false,
40
- };
41
- // seed with pre-existing migration errors, excluding human-only endpoints
42
- const errors: IHttpLlmApplication.IError[] = props.migrate.errors
43
- .filter((e) => e.operation()["x-samchon-human"] !== true)
44
- .map((e) => ({
45
- method: e.method,
46
- path: e.path,
47
- messages: e.messages,
48
- operation: () => e.operation(),
49
- route: () => undefined,
50
- }));
51
- // convert each route to an LLM function, rejecting unsupported ones
52
- const functions: IHttpLlmFunction[] = props.migrate.routes
53
- .filter((e) => e.operation()["x-samchon-human"] !== true)
54
- .map((route, i) => {
55
- // reject HEAD — LLMs cannot interpret header-only responses
56
- if (route.method === "head") {
57
- errors.push({
58
- method: route.method,
59
- path: route.path,
60
- messages: ["HEAD method is not supported in the LLM application."],
61
- operation: () => route.operation(),
62
- route: () => route as any as IHttpMigrateRoute,
63
- });
64
- return null;
65
- // reject multipart/form-data — binary uploads not expressible in JSON Schema
66
- } else if (
67
- route.body?.type === "multipart/form-data" ||
68
- route.success?.type === "multipart/form-data"
69
- ) {
70
- errors.push({
71
- method: route.method,
72
- path: route.path,
73
- messages: [
74
- `The "multipart/form-data" content type is not supported in the LLM application.`,
75
- ],
76
- operation: () => route.operation(),
77
- route: () => route as any as IHttpMigrateRoute,
78
- });
79
- return null;
80
- }
81
- const localErrors: string[] = [];
82
- const func: IHttpLlmFunction | null = composeFunction({
83
- components: props.migrate.document().components,
84
- config,
85
- route,
86
- errors: localErrors,
87
- index: i,
88
- });
89
- if (func === null)
90
- errors.push({
91
- method: route.method,
92
- path: route.path,
93
- messages: localErrors,
94
- operation: () => route.operation(),
95
- route: () => route as any as IHttpMigrateRoute,
96
- });
97
- return func;
98
- })
99
- .filter((v): v is IHttpLlmFunction => v !== null);
100
-
101
- const app: IHttpLlmApplication = {
102
- config,
103
- functions,
104
- errors,
105
- };
106
- shorten(app, props.config?.maxLength ?? 64);
107
- return app;
108
- };
109
-
110
- /**
111
- * Converts a single {@link IHttpMigrateRoute} into an {@link IHttpLlmFunction}
112
- * by composing parameter/output schemas and validating function name
113
- * constraints.
114
- */
115
- const composeFunction = (props: {
116
- components: OpenApi.IComponents;
117
- route: IHttpMigrateRoute;
118
- config: IHttpLlmApplication.IConfig;
119
- errors: string[];
120
- index: number;
121
- }): IHttpLlmFunction | null => {
122
- // accessor prefix for error messages (mirrors OpenAPI document structure)
123
- const endpoint: string = `$input.paths[${JSON.stringify(props.route.path)}][${JSON.stringify(props.route.method)}]`;
124
- const operation: OpenApi.IOperation = props.route.operation();
125
- const description: string | undefined = concatDescription({
126
- summary: operation.summary,
127
- description: operation.description,
128
- });
129
- if ((description?.length ?? 0) > 1_024) {
130
- props.errors.push(
131
- `The description of the function is too long (must be equal or less than 1,024 characters, but ${description!.length.toLocaleString()} length).`,
132
- );
133
- }
134
-
135
- // build function name from route accessor, replacing forbidden chars
136
- const name: string = emend(props.route.accessor.join("_"));
137
- const isNameVariable: boolean = /^[a-zA-Z0-9_-]+$/.test(name);
138
- const isNameStartsWithNumber: boolean = /^[0-9]/.test(name[0] ?? "");
139
- if (isNameVariable === false)
140
- props.errors.push(
141
- `Elements of path (separated by '/') must be composed with alphabets, numbers, underscores, and hyphens`,
142
- );
143
- if (isNameStartsWithNumber === true)
144
- props.errors.push(`Function name cannot start with a number.`);
145
-
146
- //----
147
- // CONSTRUCT SCHEMAS
148
- //----
149
- // merge path parameters, query, and body into a single object schema
150
- const parameters: OpenApi.IJsonSchema.IObject = {
151
- type: "object",
152
- properties: Object.fromEntries([
153
- // path parameters (e.g., /users/:id)
154
- ...props.route.parameters.map(
155
- (s) =>
156
- [
157
- s.key,
158
- {
159
- ...s.schema,
160
- description: s.parameter().description ?? s.schema.description,
161
- },
162
- ] as const,
163
- ),
164
- // query parameters
165
- ...(props.route.query
166
- ? [
167
- [
168
- props.route.query.key,
169
- {
170
- ...props.route.query.schema,
171
- title:
172
- props.route.query.title() ?? props.route.query.schema.title,
173
- description:
174
- props.route.query.description() ??
175
- props.route.query.schema.description,
176
- },
177
- ] as const,
178
- ]
179
- : []),
180
- // request body
181
- ...(props.route.body
182
- ? [
183
- [
184
- props.route.body.key,
185
- {
186
- ...props.route.body.schema,
187
- description:
188
- props.route.body.description() ??
189
- props.route.body.schema.description,
190
- },
191
- ] as const,
192
- ]
193
- : []),
194
- ]),
195
- };
196
- parameters.required = Object.keys(parameters.properties ?? {});
197
-
198
- // convert merged object schema to LLM parameters
199
- const llmParameters: IResult<
200
- ILlmSchema.IParameters,
201
- IJsonSchemaTransformError
202
- > = LlmSchemaConverter.parameters({
203
- config: props.config,
204
- components: props.components,
205
- schema: parameters,
206
- accessor: `${endpoint}.parameters`,
207
- });
208
-
209
- // convert response schema to LLM output parameters
210
- const output:
211
- | IResult<ILlmSchema.IParameters, IJsonSchemaTransformError>
212
- | undefined = props.route.success
213
- ? LlmSchemaConverter.parameters({
214
- config: props.config,
215
- components: props.components,
216
- schema: props.route.success.schema as
217
- | OpenApi.IJsonSchema.IObject
218
- | OpenApi.IJsonSchema.IReference,
219
- accessor: `${endpoint}.responses[${JSON.stringify(props.route.success.status)}][${JSON.stringify(props.route.success.type)}].schema`,
220
- })
221
- : undefined;
222
-
223
- //----
224
- // CONVERSION
225
- //----
226
- // bail out if any validation or conversion failed
227
- if (
228
- output?.success === false ||
229
- llmParameters.success === false ||
230
- isNameVariable === false ||
231
- isNameStartsWithNumber === true ||
232
- (description?.length ?? 0) > 1_024
233
- ) {
234
- if (output?.success === false)
235
- props.errors.push(
236
- ...output.error.reasons.map((r) => `${r.accessor}: ${r.message}`),
237
- );
238
- if (llmParameters.success === false)
239
- props.errors.push(
240
- // rewrite internal accessor to match OpenAPI requestBody path
241
- ...llmParameters.error.reasons.map((r) => {
242
- const accessor: string = r.accessor.replace(
243
- `parameters.properties["body"]`,
244
- `requestBody.content[${JSON.stringify(props.route.body?.type ?? "application/json")}].schema`,
245
- );
246
- return `${accessor}: ${r.message}`;
247
- }),
248
- );
249
- return null;
250
- }
251
-
252
- // assemble the LLM function
253
- return {
254
- method: props.route.method as "get",
255
- path: props.route.path,
256
- name,
257
- parameters: llmParameters.value,
258
- output: output?.value,
259
- description,
260
- deprecated: operation.deprecated,
261
- tags: operation.tags,
262
- parse: (input: string) => LlmJson.parse(input, llmParameters.value),
263
- coerce: (input: unknown) => LlmJson.coerce(input, llmParameters.value),
264
- validate: OpenApiValidator.create({
265
- components: props.components,
266
- schema: parameters,
267
- required: true,
268
- equals: props.config.equals ?? false,
269
- }),
270
- route: () => props.route as any,
271
- operation: () => props.route.operation(),
272
- };
273
- };
274
-
275
- /**
276
- * Shortens function names exceeding the character limit.
277
- *
278
- * Tries progressively shorter accessor suffixes first, then falls back to
279
- * index-prefixed names, and finally UUID as a last resort.
280
- */
281
- export const shorten = (
282
- app: IHttpLlmApplication,
283
- limit: number = 64,
284
- ): void => {
285
- // collect all names for uniqueness checks
286
- const dictionary: Set<string> = new Set();
287
- const longFunctions: IHttpLlmFunction[] = [];
288
- for (const func of app.functions) {
289
- dictionary.add(func.name);
290
- if (func.name.length > limit) {
291
- longFunctions.push(func);
292
- }
293
- }
294
- if (longFunctions.length === 0) return;
295
-
296
- let index: number = 0;
297
- for (const func of longFunctions) {
298
- let success: boolean = false;
299
- const rename = (str: string) => {
300
- dictionary.delete(func.name);
301
- dictionary.add(str);
302
- func.name = str;
303
- success = true;
304
- };
305
- // try dropping leading accessor segments to shorten the name
306
- // (e.g., "api_users_getById" → "users_getById" → "getById")
307
- for (let i: number = 1; i < func.route().accessor.length; ++i) {
308
- const shortName: string = func.route().accessor.slice(i).join("_");
309
- if (shortName.length > limit - 8)
310
- continue; // reserve room for "_N_" prefix
311
- else if (dictionary.has(shortName) === false) rename(shortName);
312
- else {
313
- // name collision — prefix with a counter to disambiguate
314
- const newName: string = `_${index}_${shortName}`;
315
- if (dictionary.has(newName) === true) continue;
316
- rename(newName);
317
- ++index;
318
- }
319
- break;
320
- }
321
- // last resort — all suffix attempts failed or collided
322
- if (success === false) rename(randomFormatUuid());
323
- }
324
- };
325
- }
326
-
327
- const randomFormatUuid = (): string =>
328
- "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
329
- const r = (Math.random() * 16) | 0;
330
- const v = c === "x" ? r : (r & 0x3) | 0x8;
331
- return v.toString(16);
332
- });
333
-
334
- /** Replaces forbidden characters (`$`, `%`, `.`) with underscores. */
335
- const emend = (str: string): string => {
336
- for (const ch of FORBIDDEN) str = str.split(ch).join("_");
337
- return str;
338
- };
339
-
340
- const FORBIDDEN = ["$", "%", "."];
341
-
342
- /**
343
- * Concatenates summary and description into a single string.
344
- *
345
- * If both are present, joins them with a period and double newline, avoiding
346
- * duplication when the description already starts with the summary.
347
- */
348
- const concatDescription = (p: {
349
- summary?: string | undefined;
350
- description?: string | undefined;
351
- }): string | undefined => {
352
- if (!p.summary?.length || !p.description?.length)
353
- return p.summary || p.description;
354
- const summary: string = p.summary.endsWith(".")
355
- ? p.summary.slice(0, -1)
356
- : p.summary;
357
- return p.description.startsWith(summary)
358
- ? p.description
359
- : summary + ".\n\n" + p.description;
360
- };
1
+ import {
2
+ IHttpLlmApplication,
3
+ IHttpLlmFunction,
4
+ IHttpMigrateApplication,
5
+ IHttpMigrateRoute,
6
+ IJsonSchemaTransformError,
7
+ ILlmSchema,
8
+ IResult,
9
+ OpenApi,
10
+ } from "@typia/interface";
11
+
12
+ import { LlmSchemaConverter } from "../../converters/LlmSchemaConverter";
13
+ import { LlmJson } from "../../utils";
14
+ import { OpenApiValidator } from "../../validators/OpenApiValidator";
15
+
16
+ /**
17
+ * Composes {@link IHttpLlmApplication} from an {@link IHttpMigrateApplication}.
18
+ *
19
+ * Converts OpenAPI-migrated HTTP routes into LLM function calling schemas,
20
+ * filtering out unsupported methods (HEAD) and content types
21
+ * (multipart/form-data), and shortening function names to fit the configured
22
+ * maximum length.
23
+ */
24
+ export namespace HttpLlmApplicationComposer {
25
+ /**
26
+ * Builds an {@link IHttpLlmApplication} from migrated HTTP routes.
27
+ *
28
+ * Iterates all routes, converts each to an {@link IHttpLlmFunction}, and
29
+ * collects conversion errors. Applies function name shortening at the end.
30
+ */
31
+ export const application = (props: {
32
+ migrate: IHttpMigrateApplication;
33
+ config?: Partial<IHttpLlmApplication.IConfig>;
34
+ }): IHttpLlmApplication => {
35
+ // fill in config defaults
36
+ const config: IHttpLlmApplication.IConfig = {
37
+ maxLength: props.config?.maxLength ?? 64,
38
+ equals: props.config?.equals ?? false,
39
+ strict: props.config?.strict ?? false,
40
+ };
41
+ // seed with pre-existing migration errors, excluding human-only endpoints
42
+ const errors: IHttpLlmApplication.IError[] = props.migrate.errors
43
+ .filter((e) => e.operation()["x-samchon-human"] !== true)
44
+ .map((e) => ({
45
+ method: e.method,
46
+ path: e.path,
47
+ messages: e.messages,
48
+ operation: () => e.operation(),
49
+ route: () => undefined,
50
+ }));
51
+ // convert each route to an LLM function, rejecting unsupported ones
52
+ const functions: IHttpLlmFunction[] = props.migrate.routes
53
+ .filter((e) => e.operation()["x-samchon-human"] !== true)
54
+ .map((route, i) => {
55
+ // reject HEAD — LLMs cannot interpret header-only responses
56
+ if (route.method === "head") {
57
+ errors.push({
58
+ method: route.method,
59
+ path: route.path,
60
+ messages: ["HEAD method is not supported in the LLM application."],
61
+ operation: () => route.operation(),
62
+ route: () => route as any as IHttpMigrateRoute,
63
+ });
64
+ return null;
65
+ // reject multipart/form-data — binary uploads not expressible in JSON Schema
66
+ } else if (
67
+ route.body?.type === "multipart/form-data" ||
68
+ route.success?.type === "multipart/form-data"
69
+ ) {
70
+ errors.push({
71
+ method: route.method,
72
+ path: route.path,
73
+ messages: [
74
+ `The "multipart/form-data" content type is not supported in the LLM application.`,
75
+ ],
76
+ operation: () => route.operation(),
77
+ route: () => route as any as IHttpMigrateRoute,
78
+ });
79
+ return null;
80
+ }
81
+ const localErrors: string[] = [];
82
+ const func: IHttpLlmFunction | null = composeFunction({
83
+ components: props.migrate.document().components,
84
+ config,
85
+ route,
86
+ errors: localErrors,
87
+ index: i,
88
+ });
89
+ if (func === null)
90
+ errors.push({
91
+ method: route.method,
92
+ path: route.path,
93
+ messages: localErrors,
94
+ operation: () => route.operation(),
95
+ route: () => route as any as IHttpMigrateRoute,
96
+ });
97
+ return func;
98
+ })
99
+ .filter((v): v is IHttpLlmFunction => v !== null);
100
+
101
+ const app: IHttpLlmApplication = {
102
+ config,
103
+ functions,
104
+ errors,
105
+ };
106
+ shorten(app, props.config?.maxLength ?? 64);
107
+ return app;
108
+ };
109
+
110
+ /**
111
+ * Converts a single {@link IHttpMigrateRoute} into an {@link IHttpLlmFunction}
112
+ * by composing parameter/output schemas and validating function name
113
+ * constraints.
114
+ */
115
+ const composeFunction = (props: {
116
+ components: OpenApi.IComponents;
117
+ route: IHttpMigrateRoute;
118
+ config: IHttpLlmApplication.IConfig;
119
+ errors: string[];
120
+ index: number;
121
+ }): IHttpLlmFunction | null => {
122
+ // accessor prefix for error messages (mirrors OpenAPI document structure)
123
+ const endpoint: string = `$input.paths[${JSON.stringify(props.route.path)}][${JSON.stringify(props.route.method)}]`;
124
+ const operation: OpenApi.IOperation = props.route.operation();
125
+ const description: string | undefined = concatDescription({
126
+ summary: operation.summary,
127
+ description: operation.description,
128
+ });
129
+ if ((description?.length ?? 0) > 1_024) {
130
+ props.errors.push(
131
+ `The description of the function is too long (must be equal or less than 1,024 characters, but ${description!.length.toLocaleString()} length).`,
132
+ );
133
+ }
134
+
135
+ // build function name from route accessor, replacing forbidden chars
136
+ const name: string = emend(props.route.accessor.join("_"));
137
+ const isNameVariable: boolean = /^[a-zA-Z0-9_-]+$/.test(name);
138
+ const isNameStartsWithNumber: boolean = /^[0-9]/.test(name[0] ?? "");
139
+ if (isNameVariable === false)
140
+ props.errors.push(
141
+ `Elements of path (separated by '/') must be composed with alphabets, numbers, underscores, and hyphens`,
142
+ );
143
+ if (isNameStartsWithNumber === true)
144
+ props.errors.push(`Function name cannot start with a number.`);
145
+
146
+ //----
147
+ // CONSTRUCT SCHEMAS
148
+ //----
149
+ // merge path parameters, query, and body into a single object schema
150
+ const parameters: OpenApi.IJsonSchema.IObject = {
151
+ type: "object",
152
+ properties: Object.fromEntries([
153
+ // path parameters (e.g., /users/:id)
154
+ ...props.route.parameters.map(
155
+ (s) =>
156
+ [
157
+ s.key,
158
+ {
159
+ ...s.schema,
160
+ description: s.parameter().description ?? s.schema.description,
161
+ },
162
+ ] as const,
163
+ ),
164
+ // query parameters
165
+ ...(props.route.query
166
+ ? [
167
+ [
168
+ props.route.query.key,
169
+ {
170
+ ...props.route.query.schema,
171
+ title:
172
+ props.route.query.title() ?? props.route.query.schema.title,
173
+ description:
174
+ props.route.query.description() ??
175
+ props.route.query.schema.description,
176
+ },
177
+ ] as const,
178
+ ]
179
+ : []),
180
+ // request body
181
+ ...(props.route.body
182
+ ? [
183
+ [
184
+ props.route.body.key,
185
+ {
186
+ ...props.route.body.schema,
187
+ description:
188
+ props.route.body.description() ??
189
+ props.route.body.schema.description,
190
+ },
191
+ ] as const,
192
+ ]
193
+ : []),
194
+ ]),
195
+ };
196
+ parameters.required = Object.keys(parameters.properties ?? {});
197
+
198
+ // convert merged object schema to LLM parameters
199
+ const llmParameters: IResult<
200
+ ILlmSchema.IParameters,
201
+ IJsonSchemaTransformError
202
+ > = LlmSchemaConverter.parameters({
203
+ config: props.config,
204
+ components: props.components,
205
+ schema: parameters,
206
+ accessor: `${endpoint}.parameters`,
207
+ });
208
+
209
+ // convert response schema to LLM output parameters
210
+ const output:
211
+ | IResult<ILlmSchema.IParameters, IJsonSchemaTransformError>
212
+ | undefined = props.route.success
213
+ ? LlmSchemaConverter.parameters({
214
+ config: props.config,
215
+ components: props.components,
216
+ schema: props.route.success.schema as
217
+ | OpenApi.IJsonSchema.IObject
218
+ | OpenApi.IJsonSchema.IReference,
219
+ accessor: `${endpoint}.responses[${JSON.stringify(props.route.success.status)}][${JSON.stringify(props.route.success.type)}].schema`,
220
+ })
221
+ : undefined;
222
+
223
+ //----
224
+ // CONVERSION
225
+ //----
226
+ // bail out if any validation or conversion failed
227
+ if (
228
+ output?.success === false ||
229
+ llmParameters.success === false ||
230
+ isNameVariable === false ||
231
+ isNameStartsWithNumber === true ||
232
+ (description?.length ?? 0) > 1_024
233
+ ) {
234
+ if (output?.success === false)
235
+ props.errors.push(
236
+ ...output.error.reasons.map((r) => `${r.accessor}: ${r.message}`),
237
+ );
238
+ if (llmParameters.success === false)
239
+ props.errors.push(
240
+ // rewrite internal accessor to match OpenAPI requestBody path
241
+ ...llmParameters.error.reasons.map((r) => {
242
+ const accessor: string = r.accessor.replace(
243
+ `parameters.properties["body"]`,
244
+ `requestBody.content[${JSON.stringify(props.route.body?.type ?? "application/json")}].schema`,
245
+ );
246
+ return `${accessor}: ${r.message}`;
247
+ }),
248
+ );
249
+ return null;
250
+ }
251
+
252
+ // assemble the LLM function
253
+ return {
254
+ method: props.route.method as "get",
255
+ path: props.route.path,
256
+ name,
257
+ parameters: llmParameters.value,
258
+ output: output?.value,
259
+ description,
260
+ deprecated: operation.deprecated,
261
+ tags: operation.tags,
262
+ parse: (input: string) => LlmJson.parse(input, llmParameters.value),
263
+ coerce: (input: unknown) => LlmJson.coerce(input, llmParameters.value),
264
+ validate: OpenApiValidator.create({
265
+ components: props.components,
266
+ schema: parameters,
267
+ required: true,
268
+ equals: props.config.equals ?? false,
269
+ }),
270
+ route: () => props.route as any,
271
+ operation: () => props.route.operation(),
272
+ };
273
+ };
274
+
275
+ /**
276
+ * Shortens function names exceeding the character limit.
277
+ *
278
+ * Tries progressively shorter accessor suffixes first, then falls back to
279
+ * index-prefixed names, and finally UUID as a last resort.
280
+ */
281
+ export const shorten = (
282
+ app: IHttpLlmApplication,
283
+ limit: number = 64,
284
+ ): void => {
285
+ // collect all names for uniqueness checks
286
+ const dictionary: Set<string> = new Set();
287
+ const longFunctions: IHttpLlmFunction[] = [];
288
+ for (const func of app.functions) {
289
+ dictionary.add(func.name);
290
+ if (func.name.length > limit) {
291
+ longFunctions.push(func);
292
+ }
293
+ }
294
+ if (longFunctions.length === 0) return;
295
+
296
+ let index: number = 0;
297
+ for (const func of longFunctions) {
298
+ let success: boolean = false;
299
+ const rename = (str: string) => {
300
+ dictionary.delete(func.name);
301
+ dictionary.add(str);
302
+ func.name = str;
303
+ success = true;
304
+ };
305
+ // try dropping leading accessor segments to shorten the name
306
+ // (e.g., "api_users_getById" → "users_getById" → "getById")
307
+ for (let i: number = 1; i < func.route().accessor.length; ++i) {
308
+ const shortName: string = func.route().accessor.slice(i).join("_");
309
+ if (shortName.length > limit - 8)
310
+ continue; // reserve room for "_N_" prefix
311
+ else if (dictionary.has(shortName) === false) rename(shortName);
312
+ else {
313
+ // name collision — prefix with a counter to disambiguate
314
+ const newName: string = `_${index}_${shortName}`;
315
+ if (dictionary.has(newName) === true) continue;
316
+ rename(newName);
317
+ ++index;
318
+ }
319
+ break;
320
+ }
321
+ // last resort — all suffix attempts failed or collided
322
+ if (success === false) rename(randomFormatUuid());
323
+ }
324
+ };
325
+ }
326
+
327
+ const randomFormatUuid = (): string =>
328
+ "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
329
+ const r = (Math.random() * 16) | 0;
330
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
331
+ return v.toString(16);
332
+ });
333
+
334
+ /** Replaces forbidden characters (`$`, `%`, `.`) with underscores. */
335
+ const emend = (str: string): string => {
336
+ for (const ch of FORBIDDEN) str = str.split(ch).join("_");
337
+ return str;
338
+ };
339
+
340
+ const FORBIDDEN = ["$", "%", "."];
341
+
342
+ /**
343
+ * Concatenates summary and description into a single string.
344
+ *
345
+ * If both are present, joins them with a period and double newline, avoiding
346
+ * duplication when the description already starts with the summary.
347
+ */
348
+ const concatDescription = (p: {
349
+ summary?: string | undefined;
350
+ description?: string | undefined;
351
+ }): string | undefined => {
352
+ if (!p.summary?.length || !p.description?.length)
353
+ return p.summary || p.description;
354
+ const summary: string = p.summary.endsWith(".")
355
+ ? p.summary.slice(0, -1)
356
+ : p.summary;
357
+ return p.description.startsWith(summary)
358
+ ? p.description
359
+ : summary + ".\n\n" + p.description;
360
+ };