@azumag/opencode-rate-limit-fallback 1.31.0 → 1.36.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.
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Configuration validation and diagnostics
3
+ */
4
+ import { existsSync, readFileSync } from 'fs';
5
+ /**
6
+ * Configuration Validator class
7
+ */
8
+ export class ConfigValidator {
9
+ logger;
10
+ constructor(logger) {
11
+ this.logger = logger;
12
+ }
13
+ /**
14
+ * Validate a configuration object
15
+ */
16
+ validate(config, options) {
17
+ const errors = [];
18
+ const warnings = [];
19
+ const { strict = false, logWarnings = true } = options || {};
20
+ // Validate fallbackModels
21
+ if (config.fallbackModels) {
22
+ if (!Array.isArray(config.fallbackModels)) {
23
+ errors.push({
24
+ path: 'fallbackModels',
25
+ message: 'fallbackModels must be an array',
26
+ severity: 'error',
27
+ value: config.fallbackModels,
28
+ });
29
+ }
30
+ else {
31
+ for (let i = 0; i < config.fallbackModels.length; i++) {
32
+ const model = config.fallbackModels[i];
33
+ const modelPath = `fallbackModels[${i}]`;
34
+ if (!model || typeof model !== 'object') {
35
+ errors.push({
36
+ path: modelPath,
37
+ message: 'Fallback model must be an object',
38
+ severity: 'error',
39
+ value: model,
40
+ });
41
+ }
42
+ else {
43
+ if (!model.providerID || typeof model.providerID !== 'string') {
44
+ errors.push({
45
+ path: `${modelPath}.providerID`,
46
+ message: 'providerID is required and must be a string',
47
+ severity: 'error',
48
+ value: model.providerID,
49
+ });
50
+ }
51
+ if (!model.modelID || typeof model.modelID !== 'string') {
52
+ errors.push({
53
+ path: `${modelPath}.modelID`,
54
+ message: 'modelID is required and must be a string',
55
+ severity: 'error',
56
+ value: model.modelID,
57
+ });
58
+ }
59
+ }
60
+ }
61
+ // Warning if fallbackModels is empty
62
+ if (config.fallbackModels.length === 0) {
63
+ warnings.push({
64
+ path: 'fallbackModels',
65
+ message: 'fallbackModels is empty - no fallback models available',
66
+ severity: 'warning',
67
+ });
68
+ }
69
+ }
70
+ }
71
+ // Validate cooldownMs
72
+ if (config.cooldownMs !== undefined) {
73
+ if (typeof config.cooldownMs !== 'number' || config.cooldownMs < 0) {
74
+ errors.push({
75
+ path: 'cooldownMs',
76
+ message: 'cooldownMs must be a non-negative number',
77
+ severity: 'error',
78
+ value: config.cooldownMs,
79
+ });
80
+ }
81
+ else if (config.cooldownMs < 1000) {
82
+ warnings.push({
83
+ path: 'cooldownMs',
84
+ message: 'cooldownMs is very low (< 1000ms), may cause frequent retries',
85
+ severity: 'warning',
86
+ value: config.cooldownMs,
87
+ });
88
+ }
89
+ else if (config.cooldownMs > 300000) {
90
+ warnings.push({
91
+ path: 'cooldownMs',
92
+ message: 'cooldownMs is very high (> 5min), fallback will be slow',
93
+ severity: 'warning',
94
+ value: config.cooldownMs,
95
+ });
96
+ }
97
+ }
98
+ // Validate enabled
99
+ if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {
100
+ errors.push({
101
+ path: 'enabled',
102
+ message: 'enabled must be a boolean',
103
+ severity: 'error',
104
+ value: config.enabled,
105
+ });
106
+ }
107
+ // Validate fallbackMode
108
+ if (config.fallbackMode && config.fallbackMode !== 'cycle' && config.fallbackMode !== 'stop' && config.fallbackMode !== 'retry-last') {
109
+ errors.push({
110
+ path: 'fallbackMode',
111
+ message: 'fallbackMode must be one of: cycle, stop, retry-last',
112
+ severity: 'error',
113
+ value: config.fallbackMode,
114
+ });
115
+ }
116
+ // Validate retryPolicy
117
+ if (config.retryPolicy) {
118
+ if (typeof config.retryPolicy !== 'object') {
119
+ errors.push({
120
+ path: 'retryPolicy',
121
+ message: 'retryPolicy must be an object',
122
+ severity: 'error',
123
+ value: config.retryPolicy,
124
+ });
125
+ }
126
+ else {
127
+ if (config.retryPolicy.maxRetries !== undefined) {
128
+ if (typeof config.retryPolicy.maxRetries !== 'number' || config.retryPolicy.maxRetries < 0) {
129
+ errors.push({
130
+ path: 'retryPolicy.maxRetries',
131
+ message: 'maxRetries must be a non-negative number',
132
+ severity: 'error',
133
+ value: config.retryPolicy.maxRetries,
134
+ });
135
+ }
136
+ else if (config.retryPolicy.maxRetries > 10) {
137
+ warnings.push({
138
+ path: 'retryPolicy.maxRetries',
139
+ message: 'maxRetries is very high (> 10), may cause excessive retries',
140
+ severity: 'warning',
141
+ value: config.retryPolicy.maxRetries,
142
+ });
143
+ }
144
+ }
145
+ if (config.retryPolicy.strategy !== undefined) {
146
+ const validStrategies = ['immediate', 'exponential', 'linear', 'custom'];
147
+ if (!validStrategies.includes(config.retryPolicy.strategy)) {
148
+ errors.push({
149
+ path: 'retryPolicy.strategy',
150
+ message: `strategy must be one of: ${validStrategies.join(', ')}`,
151
+ severity: 'error',
152
+ value: config.retryPolicy.strategy,
153
+ });
154
+ }
155
+ }
156
+ if (config.retryPolicy.baseDelayMs !== undefined) {
157
+ if (typeof config.retryPolicy.baseDelayMs !== 'number' || config.retryPolicy.baseDelayMs < 0) {
158
+ errors.push({
159
+ path: 'retryPolicy.baseDelayMs',
160
+ message: 'baseDelayMs must be a non-negative number',
161
+ severity: 'error',
162
+ value: config.retryPolicy.baseDelayMs,
163
+ });
164
+ }
165
+ }
166
+ if (config.retryPolicy.maxDelayMs !== undefined) {
167
+ if (typeof config.retryPolicy.maxDelayMs !== 'number' || config.retryPolicy.maxDelayMs < 0) {
168
+ errors.push({
169
+ path: 'retryPolicy.maxDelayMs',
170
+ message: 'maxDelayMs must be a non-negative number',
171
+ severity: 'error',
172
+ value: config.retryPolicy.maxDelayMs,
173
+ });
174
+ }
175
+ }
176
+ if (config.retryPolicy.jitterEnabled !== undefined && typeof config.retryPolicy.jitterEnabled !== 'boolean') {
177
+ errors.push({
178
+ path: 'retryPolicy.jitterEnabled',
179
+ message: 'jitterEnabled must be a boolean',
180
+ severity: 'error',
181
+ value: config.retryPolicy.jitterEnabled,
182
+ });
183
+ }
184
+ if (config.retryPolicy.jitterFactor !== undefined) {
185
+ if (typeof config.retryPolicy.jitterFactor !== 'number' || config.retryPolicy.jitterFactor < 0 || config.retryPolicy.jitterFactor > 1) {
186
+ errors.push({
187
+ path: 'retryPolicy.jitterFactor',
188
+ message: 'jitterFactor must be a number between 0 and 1',
189
+ severity: 'error',
190
+ value: config.retryPolicy.jitterFactor,
191
+ });
192
+ }
193
+ }
194
+ if (config.retryPolicy.timeoutMs !== undefined) {
195
+ if (typeof config.retryPolicy.timeoutMs !== 'number' || config.retryPolicy.timeoutMs < 0) {
196
+ errors.push({
197
+ path: 'retryPolicy.timeoutMs',
198
+ message: 'timeoutMs must be a non-negative number',
199
+ severity: 'error',
200
+ value: config.retryPolicy.timeoutMs,
201
+ });
202
+ }
203
+ }
204
+ }
205
+ }
206
+ // Validate circuitBreaker
207
+ if (config.circuitBreaker) {
208
+ if (typeof config.circuitBreaker !== 'object') {
209
+ errors.push({
210
+ path: 'circuitBreaker',
211
+ message: 'circuitBreaker must be an object',
212
+ severity: 'error',
213
+ value: config.circuitBreaker,
214
+ });
215
+ }
216
+ else {
217
+ if (config.circuitBreaker.enabled !== undefined && typeof config.circuitBreaker.enabled !== 'boolean') {
218
+ errors.push({
219
+ path: 'circuitBreaker.enabled',
220
+ message: 'enabled must be a boolean',
221
+ severity: 'error',
222
+ value: config.circuitBreaker.enabled,
223
+ });
224
+ }
225
+ if (config.circuitBreaker.failureThreshold !== undefined) {
226
+ if (typeof config.circuitBreaker.failureThreshold !== 'number' || config.circuitBreaker.failureThreshold < 1) {
227
+ errors.push({
228
+ path: 'circuitBreaker.failureThreshold',
229
+ message: 'failureThreshold must be a positive number',
230
+ severity: 'error',
231
+ value: config.circuitBreaker.failureThreshold,
232
+ });
233
+ }
234
+ }
235
+ if (config.circuitBreaker.recoveryTimeoutMs !== undefined) {
236
+ if (typeof config.circuitBreaker.recoveryTimeoutMs !== 'number' || config.circuitBreaker.recoveryTimeoutMs < 0) {
237
+ errors.push({
238
+ path: 'circuitBreaker.recoveryTimeoutMs',
239
+ message: 'recoveryTimeoutMs must be a non-negative number',
240
+ severity: 'error',
241
+ value: config.circuitBreaker.recoveryTimeoutMs,
242
+ });
243
+ }
244
+ }
245
+ if (config.circuitBreaker.halfOpenMaxCalls !== undefined) {
246
+ if (typeof config.circuitBreaker.halfOpenMaxCalls !== 'number' || config.circuitBreaker.halfOpenMaxCalls < 1) {
247
+ errors.push({
248
+ path: 'circuitBreaker.halfOpenMaxCalls',
249
+ message: 'halfOpenMaxCalls must be a positive number',
250
+ severity: 'error',
251
+ value: config.circuitBreaker.halfOpenMaxCalls,
252
+ });
253
+ }
254
+ }
255
+ if (config.circuitBreaker.successThreshold !== undefined) {
256
+ if (typeof config.circuitBreaker.successThreshold !== 'number' || config.circuitBreaker.successThreshold < 1) {
257
+ errors.push({
258
+ path: 'circuitBreaker.successThreshold',
259
+ message: 'successThreshold must be a positive number',
260
+ severity: 'error',
261
+ value: config.circuitBreaker.successThreshold,
262
+ });
263
+ }
264
+ }
265
+ }
266
+ }
267
+ // Validate log
268
+ if (config.log) {
269
+ if (typeof config.log !== 'object') {
270
+ errors.push({
271
+ path: 'log',
272
+ message: 'log must be an object',
273
+ severity: 'error',
274
+ value: config.log,
275
+ });
276
+ }
277
+ else {
278
+ if (config.log.level !== undefined) {
279
+ const validLevels = ['error', 'warn', 'info', 'debug'];
280
+ if (!validLevels.includes(config.log.level)) {
281
+ errors.push({
282
+ path: 'log.level',
283
+ message: `level must be one of: ${validLevels.join(', ')}`,
284
+ severity: 'error',
285
+ value: config.log.level,
286
+ });
287
+ }
288
+ }
289
+ if (config.log.format !== undefined) {
290
+ const validFormats = ['simple', 'json'];
291
+ if (!validFormats.includes(config.log.format)) {
292
+ errors.push({
293
+ path: 'log.format',
294
+ message: `format must be one of: ${validFormats.join(', ')}`,
295
+ severity: 'error',
296
+ value: config.log.format,
297
+ });
298
+ }
299
+ }
300
+ if (config.log.enableTimestamp !== undefined && typeof config.log.enableTimestamp !== 'boolean') {
301
+ errors.push({
302
+ path: 'log.enableTimestamp',
303
+ message: 'enableTimestamp must be a boolean',
304
+ severity: 'error',
305
+ value: config.log.enableTimestamp,
306
+ });
307
+ }
308
+ }
309
+ }
310
+ // Validate metrics
311
+ if (config.metrics) {
312
+ if (typeof config.metrics !== 'object') {
313
+ errors.push({
314
+ path: 'metrics',
315
+ message: 'metrics must be an object',
316
+ severity: 'error',
317
+ value: config.metrics,
318
+ });
319
+ }
320
+ else {
321
+ if (config.metrics.enabled !== undefined && typeof config.metrics.enabled !== 'boolean') {
322
+ errors.push({
323
+ path: 'metrics.enabled',
324
+ message: 'enabled must be a boolean',
325
+ severity: 'error',
326
+ value: config.metrics.enabled,
327
+ });
328
+ }
329
+ if (config.metrics.output !== undefined) {
330
+ if (typeof config.metrics.output !== 'object') {
331
+ errors.push({
332
+ path: 'metrics.output',
333
+ message: 'output must be an object',
334
+ severity: 'error',
335
+ value: config.metrics.output,
336
+ });
337
+ }
338
+ else {
339
+ if (config.metrics.output.console !== undefined && typeof config.metrics.output.console !== 'boolean') {
340
+ errors.push({
341
+ path: 'metrics.output.console',
342
+ message: 'console must be a boolean',
343
+ severity: 'error',
344
+ value: config.metrics.output.console,
345
+ });
346
+ }
347
+ if (config.metrics.output.format !== undefined) {
348
+ const validFormats = ['pretty', 'json', 'csv'];
349
+ if (!validFormats.includes(config.metrics.output.format)) {
350
+ errors.push({
351
+ path: 'metrics.output.format',
352
+ message: `format must be one of: ${validFormats.join(', ')}`,
353
+ severity: 'error',
354
+ value: config.metrics.output.format,
355
+ });
356
+ }
357
+ }
358
+ if (config.metrics.output.file !== undefined && typeof config.metrics.output.file !== 'string') {
359
+ errors.push({
360
+ path: 'metrics.output.file',
361
+ message: 'file must be a string',
362
+ severity: 'error',
363
+ value: config.metrics.output.file,
364
+ });
365
+ }
366
+ }
367
+ }
368
+ if (config.metrics.resetInterval !== undefined) {
369
+ const validIntervals = ['hourly', 'daily', 'weekly'];
370
+ if (!validIntervals.includes(config.metrics.resetInterval)) {
371
+ errors.push({
372
+ path: 'metrics.resetInterval',
373
+ message: `resetInterval must be one of: ${validIntervals.join(', ')}`,
374
+ severity: 'error',
375
+ value: config.metrics.resetInterval,
376
+ });
377
+ }
378
+ }
379
+ }
380
+ }
381
+ // Validate new configuration options (for v1.36.0)
382
+ // Validate enableHealthBasedSelection
383
+ if (config.enableHealthBasedSelection !== undefined && typeof config.enableHealthBasedSelection !== 'boolean') {
384
+ errors.push({
385
+ path: 'enableHealthBasedSelection',
386
+ message: 'enableHealthBasedSelection must be a boolean',
387
+ severity: 'error',
388
+ value: config.enableHealthBasedSelection,
389
+ });
390
+ }
391
+ // Validate healthPersistence
392
+ if (config.healthPersistence) {
393
+ if (typeof config.healthPersistence !== 'object') {
394
+ errors.push({
395
+ path: 'healthPersistence',
396
+ message: 'healthPersistence must be an object',
397
+ severity: 'error',
398
+ value: config.healthPersistence,
399
+ });
400
+ }
401
+ else {
402
+ if (config.healthPersistence.enabled !== undefined && typeof config.healthPersistence.enabled !== 'boolean') {
403
+ errors.push({
404
+ path: 'healthPersistence.enabled',
405
+ message: 'enabled must be a boolean',
406
+ severity: 'error',
407
+ value: config.healthPersistence.enabled,
408
+ });
409
+ }
410
+ if (config.healthPersistence.path !== undefined && typeof config.healthPersistence.path !== 'string') {
411
+ errors.push({
412
+ path: 'healthPersistence.path',
413
+ message: 'path must be a string',
414
+ severity: 'error',
415
+ value: config.healthPersistence.path,
416
+ });
417
+ }
418
+ else if (config.healthPersistence.path) {
419
+ // Check for potential path traversal
420
+ if (config.healthPersistence.path.includes('..')) {
421
+ errors.push({
422
+ path: 'healthPersistence.path',
423
+ message: 'path must not contain ".." for security reasons',
424
+ severity: 'error',
425
+ value: config.healthPersistence.path,
426
+ });
427
+ }
428
+ }
429
+ }
430
+ }
431
+ // Validate verbose
432
+ if (config.verbose !== undefined && typeof config.verbose !== 'boolean') {
433
+ errors.push({
434
+ path: 'verbose',
435
+ message: 'verbose must be a boolean',
436
+ severity: 'error',
437
+ value: config.verbose,
438
+ });
439
+ }
440
+ // Validate errorPatterns
441
+ if (config.errorPatterns) {
442
+ if (typeof config.errorPatterns !== 'object') {
443
+ errors.push({
444
+ path: 'errorPatterns',
445
+ message: 'errorPatterns must be an object',
446
+ severity: 'error',
447
+ value: config.errorPatterns,
448
+ });
449
+ }
450
+ else {
451
+ if (config.errorPatterns.custom && !Array.isArray(config.errorPatterns.custom)) {
452
+ errors.push({
453
+ path: 'errorPatterns.custom',
454
+ message: 'custom must be an array',
455
+ severity: 'error',
456
+ value: config.errorPatterns.custom,
457
+ });
458
+ }
459
+ if (config.errorPatterns.enableLearning !== undefined && typeof config.errorPatterns.enableLearning !== 'boolean') {
460
+ errors.push({
461
+ path: 'errorPatterns.enableLearning',
462
+ message: 'enableLearning must be a boolean',
463
+ severity: 'error',
464
+ value: config.errorPatterns.enableLearning,
465
+ });
466
+ }
467
+ }
468
+ }
469
+ // Log warnings if enabled
470
+ if (logWarnings && warnings.length > 0 && this.logger) {
471
+ for (const warning of warnings) {
472
+ this.logger.warn(`Config warning at ${warning.path}: ${warning.message}`);
473
+ }
474
+ }
475
+ // Log errors if present
476
+ if (errors.length > 0 && this.logger) {
477
+ for (const error of errors) {
478
+ this.logger.error(`Config error at ${error.path}: ${error.message}`);
479
+ }
480
+ }
481
+ return {
482
+ isValid: strict ? errors.length === 0 : true,
483
+ errors,
484
+ warnings,
485
+ config: config,
486
+ };
487
+ }
488
+ /**
489
+ * Validate a configuration file
490
+ */
491
+ validateFile(filePath, options) {
492
+ if (!existsSync(filePath)) {
493
+ return {
494
+ isValid: false,
495
+ errors: [{
496
+ path: 'file',
497
+ message: `Config file not found: ${filePath}`,
498
+ severity: 'error',
499
+ }],
500
+ warnings: [],
501
+ config: {},
502
+ };
503
+ }
504
+ try {
505
+ const content = readFileSync(filePath, 'utf-8');
506
+ const config = JSON.parse(content);
507
+ return this.validate(config, options);
508
+ }
509
+ catch (error) {
510
+ const errorMessage = error instanceof Error ? error.message : String(error);
511
+ return {
512
+ isValid: false,
513
+ errors: [{
514
+ path: 'file',
515
+ message: `Failed to parse config file: ${errorMessage}`,
516
+ severity: 'error',
517
+ value: error,
518
+ }],
519
+ warnings: [],
520
+ config: {},
521
+ };
522
+ }
523
+ }
524
+ /**
525
+ * Get diagnostics information for the current configuration
526
+ */
527
+ getDiagnostics(config, configSource, defaultsApplied = []) {
528
+ const validation = this.validate(config, { logWarnings: false });
529
+ return {
530
+ configSource,
531
+ config,
532
+ validation,
533
+ defaultsApplied,
534
+ };
535
+ }
536
+ /**
537
+ * Format diagnostics as human-readable text
538
+ */
539
+ formatDiagnostics(diagnostics) {
540
+ const lines = [];
541
+ lines.push('='.repeat(60));
542
+ lines.push('Rate Limit Fallback - Configuration Diagnostics');
543
+ lines.push('='.repeat(60));
544
+ lines.push('');
545
+ // Config source
546
+ lines.push(`Config Source: ${diagnostics.configSource || 'Default (no file found)'}`);
547
+ lines.push('');
548
+ // Validation summary
549
+ const { isValid, errors, warnings } = diagnostics.validation;
550
+ lines.push(`Validation Status: ${isValid ? 'VALID' : 'INVALID'}`);
551
+ lines.push(`Errors: ${errors.length}, Warnings: ${warnings.length}`);
552
+ lines.push('');
553
+ // Errors
554
+ if (errors.length > 0) {
555
+ lines.push('ERRORS:');
556
+ for (const error of errors) {
557
+ lines.push(` - ${error.path}: ${error.message}`);
558
+ }
559
+ lines.push('');
560
+ }
561
+ // Warnings
562
+ if (warnings.length > 0) {
563
+ lines.push('WARNINGS:');
564
+ for (const warning of warnings) {
565
+ lines.push(` - ${warning.path}: ${warning.message}`);
566
+ }
567
+ lines.push('');
568
+ }
569
+ // Defaults applied
570
+ if (diagnostics.defaultsApplied.length > 0) {
571
+ lines.push('DEFAULTS APPLIED:');
572
+ for (const defaultApplied of diagnostics.defaultsApplied) {
573
+ lines.push(` - ${defaultApplied}`);
574
+ }
575
+ lines.push('');
576
+ }
577
+ // Current configuration
578
+ lines.push('CURRENT CONFIGURATION:');
579
+ lines.push(` Fallback Models: ${JSON.stringify(diagnostics.config.fallbackModels.map(m => `${m.providerID}/${m.modelID}`))}`);
580
+ lines.push(` Cooldown: ${diagnostics.config.cooldownMs}ms`);
581
+ lines.push(` Enabled: ${diagnostics.config.enabled}`);
582
+ lines.push(` Fallback Mode: ${diagnostics.config.fallbackMode}`);
583
+ lines.push(` Health-Based Selection: ${diagnostics.config.enableHealthBasedSelection ?? false}`);
584
+ lines.push(` Verbose: ${diagnostics.config.verbose ?? false}`);
585
+ lines.push('');
586
+ // Retry policy
587
+ if (diagnostics.config.retryPolicy) {
588
+ lines.push('RETRY POLICY:');
589
+ lines.push(` Max Retries: ${diagnostics.config.retryPolicy.maxRetries}`);
590
+ lines.push(` Strategy: ${diagnostics.config.retryPolicy.strategy}`);
591
+ lines.push(` Base Delay: ${diagnostics.config.retryPolicy.baseDelayMs}ms`);
592
+ lines.push(` Max Delay: ${diagnostics.config.retryPolicy.maxDelayMs}ms`);
593
+ lines.push(` Jitter Enabled: ${diagnostics.config.retryPolicy.jitterEnabled}`);
594
+ lines.push('');
595
+ }
596
+ // Circuit breaker
597
+ if (diagnostics.config.circuitBreaker) {
598
+ lines.push('CIRCUIT BREAKER:');
599
+ lines.push(` Enabled: ${diagnostics.config.circuitBreaker.enabled}`);
600
+ lines.push(` Failure Threshold: ${diagnostics.config.circuitBreaker.failureThreshold}`);
601
+ lines.push(` Recovery Timeout: ${diagnostics.config.circuitBreaker.recoveryTimeoutMs}ms`);
602
+ lines.push(` Half-Open Max Calls: ${diagnostics.config.circuitBreaker.halfOpenMaxCalls}`);
603
+ lines.push(` Success Threshold: ${diagnostics.config.circuitBreaker.successThreshold}`);
604
+ lines.push('');
605
+ }
606
+ // Metrics
607
+ if (diagnostics.config.metrics) {
608
+ lines.push('METRICS:');
609
+ lines.push(` Enabled: ${diagnostics.config.metrics.enabled}`);
610
+ lines.push(` Output: ${diagnostics.config.metrics.output.console ? 'console' : diagnostics.config.metrics.output.file || 'none'}`);
611
+ lines.push(` Format: ${diagnostics.config.metrics.output.format}`);
612
+ lines.push(` Reset Interval: ${diagnostics.config.metrics.resetInterval}`);
613
+ lines.push('');
614
+ }
615
+ lines.push('='.repeat(60));
616
+ return lines.join('\n');
617
+ }
618
+ }