@portel/photon-core 1.0.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,833 @@
1
+ /**
2
+ * Schema Extractor
3
+ *
4
+ * Extracts JSON schemas from TypeScript method signatures and JSDoc comments
5
+ * Also extracts constructor parameters for config injection
6
+ * Supports Templates (@Template) and Static resources (@Static)
7
+ *
8
+ * Now uses TypeScript's compiler API for robust type parsing
9
+ */
10
+
11
+ import * as fs from 'fs/promises';
12
+ import * as ts from 'typescript';
13
+ import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo } from './types.js';
14
+
15
+ export interface ExtractedMetadata {
16
+ tools: ExtractedSchema[];
17
+ templates: TemplateInfo[];
18
+ statics: StaticInfo[];
19
+ }
20
+
21
+ /**
22
+ * Extract schemas from a Photon MCP class file
23
+ */
24
+ export class SchemaExtractor {
25
+ /**
26
+ * Extract method schemas from source code file
27
+ */
28
+ async extractFromFile(filePath: string): Promise<ExtractedSchema[]> {
29
+ try {
30
+ const source = await fs.readFile(filePath, 'utf-8');
31
+ return this.extractFromSource(source);
32
+ } catch (error: any) {
33
+ console.error(`Failed to extract schemas from ${filePath}: ${error.message}`);
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Extract all metadata (tools, templates, statics) from source code
40
+ */
41
+ extractAllFromSource(source: string): ExtractedMetadata {
42
+ const tools: ExtractedSchema[] = [];
43
+ const templates: TemplateInfo[] = [];
44
+ const statics: StaticInfo[] = [];
45
+
46
+ try {
47
+ // If source doesn't contain a class declaration, wrap it in one
48
+ let sourceToParse = source;
49
+ if (!source.includes('class ')) {
50
+ sourceToParse = `export default class Temp {\n${source}\n}`;
51
+ }
52
+
53
+ // Parse source file into AST
54
+ const sourceFile = ts.createSourceFile(
55
+ 'temp.ts',
56
+ sourceToParse,
57
+ ts.ScriptTarget.Latest,
58
+ true
59
+ );
60
+
61
+ // Helper to process a method declaration
62
+ const processMethod = (member: ts.MethodDeclaration) => {
63
+ const methodName = member.name.getText(sourceFile);
64
+ const jsdoc = this.getJSDocComment(member, sourceFile);
65
+
66
+ // Extract parameter type information
67
+ const paramsType = this.getFirstParameterType(member, sourceFile);
68
+ if (!paramsType) {
69
+ return; // Skip methods without proper params
70
+ }
71
+
72
+ // Build schema from TypeScript type
73
+ const { properties, required } = this.buildSchemaFromType(paramsType, sourceFile);
74
+
75
+ // Extract descriptions from JSDoc
76
+ const paramDocs = this.extractParamDocs(jsdoc);
77
+ const paramConstraints = this.extractParamConstraints(jsdoc);
78
+
79
+ // Merge descriptions and constraints into properties
80
+ Object.keys(properties).forEach(key => {
81
+ if (paramDocs.has(key)) {
82
+ properties[key].description = paramDocs.get(key);
83
+ }
84
+
85
+ // Apply TypeScript-extracted metadata (before JSDoc, so JSDoc can override)
86
+ if (properties[key]._tsReadOnly) {
87
+ properties[key].readOnly = true;
88
+ delete properties[key]._tsReadOnly; // Clean up internal marker
89
+ }
90
+
91
+ // Apply JSDoc constraints (takes precedence over TypeScript)
92
+ if (paramConstraints.has(key)) {
93
+ const constraints = paramConstraints.get(key)!;
94
+ this.applyConstraints(properties[key], constraints);
95
+ }
96
+ });
97
+
98
+ const description = this.extractDescription(jsdoc);
99
+ const inputSchema = {
100
+ type: 'object' as const,
101
+ properties,
102
+ ...(required.length > 0 ? { required } : {}),
103
+ };
104
+
105
+ // Check if this is a Template
106
+ if (this.hasTemplateTag(jsdoc)) {
107
+ templates.push({
108
+ name: methodName,
109
+ description,
110
+ inputSchema,
111
+ });
112
+ }
113
+ // Check if this is a Static resource
114
+ else if (this.hasStaticTag(jsdoc)) {
115
+ const uri = this.extractStaticURI(jsdoc) || `static://${methodName}`;
116
+ const mimeType = this.extractMimeType(jsdoc);
117
+
118
+ statics.push({
119
+ name: methodName,
120
+ uri,
121
+ description,
122
+ mimeType,
123
+ inputSchema,
124
+ });
125
+ }
126
+ // Otherwise, it's a regular tool
127
+ else {
128
+ const format = this.extractFormat(jsdoc);
129
+ tools.push({
130
+ name: methodName,
131
+ description,
132
+ inputSchema,
133
+ ...(format ? { format } : {}),
134
+ });
135
+ }
136
+ };
137
+
138
+ // Visit all nodes in the AST
139
+ const visit = (node: ts.Node) => {
140
+ // Look for class declarations
141
+ if (ts.isClassDeclaration(node)) {
142
+ node.members.forEach((member) => {
143
+ // Look for async methods
144
+ if (ts.isMethodDeclaration(member) &&
145
+ member.modifiers?.some(m => m.kind === ts.SyntaxKind.AsyncKeyword)) {
146
+ processMethod(member);
147
+ }
148
+ });
149
+ }
150
+
151
+ ts.forEachChild(node, visit);
152
+ };
153
+
154
+ visit(sourceFile);
155
+ } catch (error: any) {
156
+ console.error('Failed to parse TypeScript source:', error.message);
157
+ }
158
+
159
+ return { tools, templates, statics };
160
+ }
161
+
162
+ /**
163
+ * Extract schemas from source code string (backward compatibility)
164
+ */
165
+ extractFromSource(source: string): ExtractedSchema[] {
166
+ return this.extractAllFromSource(source).tools;
167
+ }
168
+
169
+ /**
170
+ * Get JSDoc comment for a node
171
+ */
172
+ private getJSDocComment(node: ts.Node, sourceFile: ts.SourceFile): string {
173
+ // Use TypeScript's JSDoc extraction
174
+ const jsDocs = (node as any).jsDoc;
175
+ if (jsDocs && jsDocs.length > 0) {
176
+ const jsDoc = jsDocs[0];
177
+ const comment = jsDoc.comment;
178
+
179
+ // Get full JSDoc text including tags
180
+ const fullText = sourceFile.getFullText();
181
+ const start = jsDoc.pos;
182
+ const end = jsDoc.end;
183
+ const jsDocText = fullText.substring(start, end);
184
+
185
+ // Extract content between /** and */
186
+ const match = jsDocText.match(/\/\*\*([\s\S]*?)\*\//);
187
+ return match ? match[1] : '';
188
+ }
189
+
190
+ return '';
191
+ }
192
+
193
+ /**
194
+ * Get the first parameter's type node
195
+ */
196
+ private getFirstParameterType(method: ts.MethodDeclaration, sourceFile: ts.SourceFile): ts.TypeNode | undefined {
197
+ if (method.parameters.length === 0) {
198
+ return undefined;
199
+ }
200
+
201
+ const firstParam = method.parameters[0];
202
+ return firstParam.type;
203
+ }
204
+
205
+ /**
206
+ * Build JSON schema from TypeScript type node
207
+ * Extracts: type, optional, readonly
208
+ */
209
+ private buildSchemaFromType(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): { properties: Record<string, any>, required: string[] } {
210
+ const properties: Record<string, any> = {};
211
+ const required: string[] = [];
212
+
213
+ // Handle type literal (object type)
214
+ if (ts.isTypeLiteralNode(typeNode)) {
215
+ typeNode.members.forEach((member) => {
216
+ if (ts.isPropertySignature(member) && member.name) {
217
+ const propName = member.name.getText(sourceFile);
218
+ const isOptional = member.questionToken !== undefined;
219
+ const isReadonly = member.modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) || false;
220
+
221
+ if (!isOptional) {
222
+ required.push(propName);
223
+ }
224
+
225
+ if (member.type) {
226
+ properties[propName] = this.typeNodeToSchema(member.type, sourceFile);
227
+ } else {
228
+ properties[propName] = { type: 'object' };
229
+ }
230
+
231
+ // Add readonly from TypeScript (JSDoc can override)
232
+ if (isReadonly) {
233
+ properties[propName]._tsReadOnly = true;
234
+ }
235
+ }
236
+ });
237
+ }
238
+
239
+ return { properties, required };
240
+ }
241
+
242
+ /**
243
+ * Convert TypeScript type node to JSON schema
244
+ */
245
+ private typeNodeToSchema(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): any {
246
+ const schema: any = {};
247
+
248
+ // Handle union types
249
+ if (ts.isUnionTypeNode(typeNode)) {
250
+ // Check if this is a union of literals that can be converted to enum
251
+ const enumValues = this.extractEnumFromUnion(typeNode, sourceFile);
252
+ if (enumValues) {
253
+ return enumValues;
254
+ }
255
+
256
+ // Otherwise use anyOf
257
+ schema.anyOf = typeNode.types.map(t => this.typeNodeToSchema(t, sourceFile));
258
+ return schema;
259
+ }
260
+
261
+ // Handle intersection types
262
+ if (ts.isIntersectionTypeNode(typeNode)) {
263
+ schema.allOf = typeNode.types.map(t => this.typeNodeToSchema(t, sourceFile));
264
+ return schema;
265
+ }
266
+
267
+ // Handle array types
268
+ if (ts.isArrayTypeNode(typeNode)) {
269
+ schema.type = 'array';
270
+ schema.items = this.typeNodeToSchema(typeNode.elementType, sourceFile);
271
+ return schema;
272
+ }
273
+
274
+ // Handle type reference (e.g., Array<string>)
275
+ if (ts.isTypeReferenceNode(typeNode)) {
276
+ const typeName = typeNode.typeName.getText(sourceFile);
277
+
278
+ if (typeName === 'Array' && typeNode.typeArguments && typeNode.typeArguments.length > 0) {
279
+ schema.type = 'array';
280
+ schema.items = this.typeNodeToSchema(typeNode.typeArguments[0], sourceFile);
281
+ return schema;
282
+ }
283
+
284
+ // For other type references, default to object
285
+ schema.type = 'object';
286
+ return schema;
287
+ }
288
+
289
+ // Handle literal types
290
+ if (ts.isLiteralTypeNode(typeNode)) {
291
+ const literal = typeNode.literal;
292
+ if (ts.isStringLiteral(literal)) {
293
+ schema.type = 'string';
294
+ schema.enum = [literal.text];
295
+ return schema;
296
+ }
297
+ if (ts.isNumericLiteral(literal)) {
298
+ schema.type = 'number';
299
+ schema.enum = [parseFloat(literal.text)];
300
+ return schema;
301
+ }
302
+ if (literal.kind === ts.SyntaxKind.TrueKeyword || literal.kind === ts.SyntaxKind.FalseKeyword) {
303
+ schema.type = 'boolean';
304
+ return schema;
305
+ }
306
+ }
307
+
308
+ // Handle tuple types
309
+ if (ts.isTupleTypeNode(typeNode)) {
310
+ schema.type = 'array';
311
+ schema.items = typeNode.elements.map(e => this.typeNodeToSchema(e, sourceFile));
312
+ return schema;
313
+ }
314
+
315
+ // Handle type literal (nested object)
316
+ if (ts.isTypeLiteralNode(typeNode)) {
317
+ schema.type = 'object';
318
+ const { properties, required } = this.buildSchemaFromType(typeNode, sourceFile);
319
+ schema.properties = properties;
320
+ if (required.length > 0) {
321
+ schema.required = required;
322
+ }
323
+ return schema;
324
+ }
325
+
326
+ // Handle keyword types (string, number, boolean, etc.)
327
+ const typeText = typeNode.getText(sourceFile);
328
+ switch (typeText) {
329
+ case 'string':
330
+ schema.type = 'string';
331
+ break;
332
+ case 'number':
333
+ schema.type = 'number';
334
+ break;
335
+ case 'boolean':
336
+ schema.type = 'boolean';
337
+ break;
338
+ case 'any':
339
+ case 'unknown':
340
+ // No type restriction
341
+ break;
342
+ default:
343
+ // Default to object for complex types
344
+ schema.type = 'object';
345
+ }
346
+
347
+ return schema;
348
+ }
349
+
350
+ /**
351
+ * Extract enum values from a union of literal types
352
+ * Returns a proper enum schema if all types are literals of the same kind
353
+ * Returns an optimized anyOf schema for mixed unions (e.g., number | '+1' | '-1')
354
+ * Returns null if the union should use standard anyOf processing
355
+ */
356
+ private extractEnumFromUnion(unionNode: ts.UnionTypeNode, sourceFile: ts.SourceFile): any | null {
357
+ const stringLiterals: string[] = [];
358
+ const numberLiterals: number[] = [];
359
+ const booleanLiterals: boolean[] = [];
360
+ const nonLiteralTypes: ts.TypeNode[] = [];
361
+
362
+ // Categorize union members
363
+ for (const typeNode of unionNode.types) {
364
+ if (ts.isLiteralTypeNode(typeNode)) {
365
+ const literal = typeNode.literal;
366
+
367
+ if (ts.isStringLiteral(literal)) {
368
+ stringLiterals.push(literal.text);
369
+ } else if (ts.isNumericLiteral(literal)) {
370
+ numberLiterals.push(parseFloat(literal.text));
371
+ } else if (literal.kind === ts.SyntaxKind.TrueKeyword) {
372
+ booleanLiterals.push(true);
373
+ } else if (literal.kind === ts.SyntaxKind.FalseKeyword) {
374
+ booleanLiterals.push(false);
375
+ }
376
+ } else {
377
+ nonLiteralTypes.push(typeNode);
378
+ }
379
+ }
380
+
381
+ // Case 1: All same-type literals - return simple enum
382
+ if (nonLiteralTypes.length === 0) {
383
+ if (stringLiterals.length > 0 && numberLiterals.length === 0 && booleanLiterals.length === 0) {
384
+ return {
385
+ type: 'string',
386
+ enum: stringLiterals,
387
+ };
388
+ }
389
+
390
+ if (numberLiterals.length > 0 && stringLiterals.length === 0 && booleanLiterals.length === 0) {
391
+ return {
392
+ type: 'number',
393
+ enum: numberLiterals,
394
+ };
395
+ }
396
+
397
+ if (booleanLiterals.length > 0 && stringLiterals.length === 0 && numberLiterals.length === 0) {
398
+ return {
399
+ type: 'boolean',
400
+ enum: booleanLiterals,
401
+ };
402
+ }
403
+ }
404
+
405
+ // Case 2: Mixed union with literals - create optimized anyOf
406
+ // Example: number | '+1' | '-1' → anyOf: [{ type: number }, { type: string, enum: ['+1', '-1'] }]
407
+ if (nonLiteralTypes.length > 0 && (stringLiterals.length > 0 || numberLiterals.length > 0 || booleanLiterals.length > 0)) {
408
+ const anyOf: any[] = [];
409
+
410
+ // Add non-literal types
411
+ for (const typeNode of nonLiteralTypes) {
412
+ anyOf.push(this.typeNodeToSchema(typeNode, sourceFile));
413
+ }
414
+
415
+ // Add grouped string literals
416
+ if (stringLiterals.length > 0) {
417
+ anyOf.push({
418
+ type: 'string',
419
+ enum: stringLiterals,
420
+ });
421
+ }
422
+
423
+ // Add grouped number literals
424
+ if (numberLiterals.length > 0) {
425
+ anyOf.push({
426
+ type: 'number',
427
+ enum: numberLiterals,
428
+ });
429
+ }
430
+
431
+ // Add grouped boolean literals
432
+ if (booleanLiterals.length > 0) {
433
+ anyOf.push({
434
+ type: 'boolean',
435
+ enum: booleanLiterals,
436
+ });
437
+ }
438
+
439
+ return { anyOf };
440
+ }
441
+
442
+ // Case 3: Complex union or mixed literal types - let standard anyOf handle it
443
+ return null;
444
+ }
445
+
446
+ /**
447
+ * Extract constructor parameters for config injection
448
+ */
449
+ extractConstructorParams(source: string): ConstructorParam[] {
450
+ const params: ConstructorParam[] = [];
451
+
452
+ try {
453
+ const sourceFile = ts.createSourceFile(
454
+ 'temp.ts',
455
+ source,
456
+ ts.ScriptTarget.Latest,
457
+ true
458
+ );
459
+
460
+ const visit = (node: ts.Node) => {
461
+ if (ts.isClassDeclaration(node)) {
462
+ node.members.forEach((member) => {
463
+ if (ts.isConstructorDeclaration(member)) {
464
+ member.parameters.forEach((param) => {
465
+ if (param.name && ts.isIdentifier(param.name)) {
466
+ const name = param.name.getText(sourceFile);
467
+ const type = param.type ? param.type.getText(sourceFile) : 'any';
468
+ const isOptional = param.questionToken !== undefined || param.initializer !== undefined;
469
+ const hasDefault = param.initializer !== undefined;
470
+
471
+ let defaultValue: any = undefined;
472
+ if (param.initializer) {
473
+ defaultValue = this.extractDefaultValue(param.initializer, sourceFile);
474
+ }
475
+
476
+ params.push({
477
+ name,
478
+ type,
479
+ isOptional,
480
+ hasDefault,
481
+ defaultValue,
482
+ });
483
+ }
484
+ });
485
+ }
486
+ });
487
+ }
488
+
489
+ ts.forEachChild(node, visit);
490
+ };
491
+
492
+ visit(sourceFile);
493
+ } catch (error: any) {
494
+ console.error('Failed to extract constructor params:', error.message);
495
+ }
496
+
497
+ return params;
498
+ }
499
+
500
+ /**
501
+ * Extract default value from initializer
502
+ */
503
+ private extractDefaultValue(initializer: ts.Expression, sourceFile: ts.SourceFile): any {
504
+ // String literals
505
+ if (ts.isStringLiteral(initializer)) {
506
+ return initializer.text;
507
+ }
508
+
509
+ // Numeric literals
510
+ if (ts.isNumericLiteral(initializer)) {
511
+ return parseFloat(initializer.text);
512
+ }
513
+
514
+ // Boolean literals
515
+ if (initializer.kind === ts.SyntaxKind.TrueKeyword) {
516
+ return true;
517
+ }
518
+ if (initializer.kind === ts.SyntaxKind.FalseKeyword) {
519
+ return false;
520
+ }
521
+
522
+ // For complex expressions (function calls, etc.), return as string
523
+ return initializer.getText(sourceFile);
524
+ }
525
+
526
+ /**
527
+ * Extract main description from JSDoc comment
528
+ */
529
+ private extractDescription(jsdocContent: string): string {
530
+ // Split by @param to get only the description part
531
+ const beforeParams = jsdocContent.split(/@param/)[0];
532
+
533
+ // Remove leading * from each line and trim
534
+ const lines = beforeParams
535
+ .split('\n')
536
+ .map((line) => line.trim().replace(/^\*\s?/, ''))
537
+ .filter((line) => line && !line.startsWith('@')); // Exclude @tags and empty lines
538
+
539
+ // Take only the last meaningful line (the actual method description)
540
+ // This filters out file headers
541
+ const meaningfulLines = lines.filter(line => line.length > 5); // Filter out short lines
542
+ const description = meaningfulLines.length > 0
543
+ ? meaningfulLines[meaningfulLines.length - 1]
544
+ : lines.join(' ');
545
+
546
+ // Clean up multiple spaces
547
+ return description.replace(/\s+/g, ' ').trim() || 'No description';
548
+ }
549
+
550
+ /**
551
+ * Extract parameter descriptions from JSDoc @param tags
552
+ * Also removes constraint tags from descriptions
553
+ */
554
+ private extractParamDocs(jsdocContent: string): Map<string, string> {
555
+ const paramDocs = new Map<string, string>();
556
+ const paramRegex = /@param\s+(\w+)\s+(.+)/g;
557
+
558
+ let match;
559
+ while ((match = paramRegex.exec(jsdocContent)) !== null) {
560
+ const [, paramName, description] = match;
561
+ // Remove all constraint tags from description
562
+ const cleanDesc = description
563
+ .replace(/\{@min\s+[^}]+\}/g, '')
564
+ .replace(/\{@max\s+[^}]+\}/g, '')
565
+ .replace(/\{@pattern\s+[^}]+\}/g, '')
566
+ .replace(/\{@format\s+[^}]+\}/g, '')
567
+ .replace(/\{@default\s+[^}]+\}/g, '')
568
+ .replace(/\{@unique(?:Items)?\s*\}/g, '')
569
+ .replace(/\{@example\s+[^}]+\}/g, '')
570
+ .replace(/\{@multipleOf\s+[^}]+\}/g, '')
571
+ .replace(/\{@deprecated(?:\s+[^}]+)?\}/g, '')
572
+ .replace(/\{@readOnly\s*\}/g, '')
573
+ .replace(/\{@writeOnly\s*\}/g, '')
574
+ .replace(/\s+/g, ' ') // Collapse multiple spaces
575
+ .trim();
576
+ paramDocs.set(paramName, cleanDesc);
577
+ }
578
+
579
+ return paramDocs;
580
+ }
581
+
582
+ /**
583
+ * Extract parameter constraints from JSDoc @param tags
584
+ * Supports inline tags: {@min}, {@max}, {@pattern}, {@format}, {@default}, {@unique},
585
+ * {@example}, {@multipleOf}, {@deprecated}, {@readOnly}, {@writeOnly}
586
+ */
587
+ private extractParamConstraints(jsdocContent: string): Map<string, any> {
588
+ const constraints = new Map<string, any>();
589
+ const paramRegex = /@param\s+(\w+)\s+(.+)/g;
590
+
591
+ let match;
592
+ while ((match = paramRegex.exec(jsdocContent)) !== null) {
593
+ const [, paramName, description] = match;
594
+ const paramConstraints: any = {};
595
+
596
+ // Extract {@min value} - works for numbers, strings, arrays
597
+ const minMatch = description.match(/\{@min\s+(-?\d+(?:\.\d+)?)\}/);
598
+ if (minMatch) {
599
+ paramConstraints.min = parseFloat(minMatch[1]);
600
+ }
601
+
602
+ // Extract {@max value} - works for numbers, strings, arrays
603
+ const maxMatch = description.match(/\{@max\s+(-?\d+(?:\.\d+)?)\}/);
604
+ if (maxMatch) {
605
+ paramConstraints.max = parseFloat(maxMatch[1]);
606
+ }
607
+
608
+ // Extract {@pattern regex} - use lookahead to match until tag-closing }
609
+ const patternMatch = description.match(/\{@pattern\s+(.+?)\}(?=\s|$|{@)/);
610
+ if (patternMatch) {
611
+ paramConstraints.pattern = patternMatch[1].trim();
612
+ }
613
+
614
+ // Extract {@format formatName} - use lookahead to match until tag-closing }
615
+ const formatMatch = description.match(/\{@format\s+(.+?)\}(?=\s|$|{@)/);
616
+ if (formatMatch) {
617
+ paramConstraints.format = formatMatch[1].trim();
618
+ }
619
+
620
+ // Extract {@default value} - use lookahead to match until tag-closing }
621
+ const defaultMatch = description.match(/\{@default\s+(.+?)\}(?=\s|$|{@)/);
622
+ if (defaultMatch) {
623
+ const defaultValue = defaultMatch[1].trim();
624
+ // Try to parse as JSON for numbers, booleans, objects, arrays
625
+ try {
626
+ paramConstraints.default = JSON.parse(defaultValue);
627
+ } catch {
628
+ // If not valid JSON, use as string
629
+ paramConstraints.default = defaultValue;
630
+ }
631
+ }
632
+
633
+ // Extract {@unique} or {@uniqueItems} - for arrays
634
+ if (description.match(/\{@unique(?:Items)?\s*\}/)) {
635
+ paramConstraints.unique = true;
636
+ }
637
+
638
+ // Extract {@example value} - supports multiple examples, use lookahead
639
+ const exampleMatches = description.matchAll(/\{@example\s+(.+?)\}(?=\s|$|{@)/g);
640
+ const examples: any[] = [];
641
+ for (const exampleMatch of exampleMatches) {
642
+ const exampleValue = exampleMatch[1].trim();
643
+ // Try to parse as JSON
644
+ try {
645
+ examples.push(JSON.parse(exampleValue));
646
+ } catch {
647
+ // If not valid JSON, use as string
648
+ examples.push(exampleValue);
649
+ }
650
+ }
651
+ if (examples.length > 0) {
652
+ paramConstraints.examples = examples;
653
+ }
654
+
655
+ // Extract {@multipleOf value} - for numbers
656
+ const multipleOfMatch = description.match(/\{@multipleOf\s+(-?\d+(?:\.\d+)?)\}/);
657
+ if (multipleOfMatch) {
658
+ paramConstraints.multipleOf = parseFloat(multipleOfMatch[1]);
659
+ }
660
+
661
+ // Extract {@deprecated message} - use lookahead to match until tag-closing }
662
+ const deprecatedMatch = description.match(/\{@deprecated(?:\s+(.+?))?\}(?=\s|$|{@)/);
663
+ if (deprecatedMatch) {
664
+ paramConstraints.deprecated = deprecatedMatch[1]?.trim() || true;
665
+ }
666
+
667
+ // Extract {@readOnly} and {@writeOnly} - track which comes last
668
+ // They are mutually exclusive, so last one wins
669
+ const readOnlyMatch = description.match(/\{@readOnly\s*\}/);
670
+ const writeOnlyMatch = description.match(/\{@writeOnly\s*\}/);
671
+
672
+ if (readOnlyMatch || writeOnlyMatch) {
673
+ // Find positions to determine which comes last
674
+ const readOnlyPos = readOnlyMatch ? description.indexOf(readOnlyMatch[0]) : -1;
675
+ const writeOnlyPos = writeOnlyMatch ? description.indexOf(writeOnlyMatch[0]) : -1;
676
+
677
+ if (readOnlyPos > writeOnlyPos) {
678
+ paramConstraints.readOnly = true;
679
+ paramConstraints.writeOnly = false; // Explicitly clear the other
680
+ } else if (writeOnlyPos > readOnlyPos) {
681
+ paramConstraints.writeOnly = true;
682
+ paramConstraints.readOnly = false; // Explicitly clear the other
683
+ }
684
+ }
685
+
686
+ if (Object.keys(paramConstraints).length > 0) {
687
+ constraints.set(paramName, paramConstraints);
688
+ }
689
+ }
690
+
691
+ return constraints;
692
+ }
693
+
694
+ /**
695
+ * Apply constraints to a schema property based on type
696
+ * Handles: min/max (contextual), pattern, format, default, unique,
697
+ * examples, multipleOf, deprecated, readOnly, writeOnly
698
+ * Works with both simple types and anyOf schemas
699
+ */
700
+ private applyConstraints(schema: any, constraints: any) {
701
+ // Helper to apply constraints to a single schema based on type
702
+ const applyToSchema = (s: any) => {
703
+ if (s.enum) {
704
+ // Skip enum types for most constraints (but still apply deprecated, examples, etc.)
705
+ if (constraints.examples !== undefined) {
706
+ s.examples = constraints.examples;
707
+ }
708
+ if (constraints.deprecated !== undefined) {
709
+ s.deprecated = constraints.deprecated === true ? true : constraints.deprecated;
710
+ }
711
+ return;
712
+ }
713
+
714
+ // Apply min/max based on type
715
+ if (s.type === 'number') {
716
+ if (constraints.min !== undefined) {
717
+ s.minimum = constraints.min;
718
+ }
719
+ if (constraints.max !== undefined) {
720
+ s.maximum = constraints.max;
721
+ }
722
+ if (constraints.multipleOf !== undefined) {
723
+ s.multipleOf = constraints.multipleOf;
724
+ }
725
+ } else if (s.type === 'string') {
726
+ if (constraints.min !== undefined) {
727
+ s.minLength = constraints.min;
728
+ }
729
+ if (constraints.max !== undefined) {
730
+ s.maxLength = constraints.max;
731
+ }
732
+ if (constraints.pattern !== undefined) {
733
+ s.pattern = constraints.pattern;
734
+ }
735
+ if (constraints.format !== undefined) {
736
+ s.format = constraints.format;
737
+ }
738
+ } else if (s.type === 'array') {
739
+ if (constraints.min !== undefined) {
740
+ s.minItems = constraints.min;
741
+ }
742
+ if (constraints.max !== undefined) {
743
+ s.maxItems = constraints.max;
744
+ }
745
+ if (constraints.unique === true) {
746
+ s.uniqueItems = true;
747
+ }
748
+ }
749
+
750
+ // Apply type-agnostic constraints
751
+ if (constraints.default !== undefined) {
752
+ s.default = constraints.default;
753
+ }
754
+ if (constraints.examples !== undefined) {
755
+ s.examples = constraints.examples;
756
+ }
757
+ if (constraints.deprecated !== undefined) {
758
+ s.deprecated = constraints.deprecated === true ? true : constraints.deprecated;
759
+ }
760
+
761
+ // readOnly and writeOnly are mutually exclusive
762
+ // JSDoc takes precedence over TypeScript
763
+ if (constraints.readOnly === true) {
764
+ s.readOnly = true;
765
+ delete s.writeOnly; // Clear writeOnly if readOnly is set
766
+ }
767
+ if (constraints.writeOnly === true) {
768
+ s.writeOnly = true;
769
+ delete s.readOnly; // Clear readOnly if writeOnly is set
770
+ }
771
+ };
772
+
773
+ // Apply to anyOf schemas or direct schema
774
+ if (schema.anyOf) {
775
+ schema.anyOf.forEach(applyToSchema);
776
+
777
+ // For deprecated/examples that apply to the whole property (not individual types)
778
+ // Apply them at the top level too
779
+ if (constraints.deprecated !== undefined) {
780
+ schema.deprecated = constraints.deprecated === true ? true : constraints.deprecated;
781
+ }
782
+ if (constraints.examples !== undefined) {
783
+ schema.examples = constraints.examples;
784
+ }
785
+ } else {
786
+ applyToSchema(schema);
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Check if JSDoc contains @Template tag
792
+ */
793
+ private hasTemplateTag(jsdocContent: string): boolean {
794
+ return /@Template/i.test(jsdocContent);
795
+ }
796
+
797
+ /**
798
+ * Check if JSDoc contains @Static tag
799
+ */
800
+ private hasStaticTag(jsdocContent: string): boolean {
801
+ return /@Static/i.test(jsdocContent);
802
+ }
803
+
804
+ /**
805
+ * Extract URI pattern from @Static tag
806
+ * Example: @Static github://repos/{owner}/{repo}/readme
807
+ */
808
+ private extractStaticURI(jsdocContent: string): string | null {
809
+ const match = jsdocContent.match(/@Static\s+([\w:\/\{\}\-_.]+)/i);
810
+ return match ? match[1].trim() : null;
811
+ }
812
+
813
+ /**
814
+ * Extract format hint from @format tag
815
+ * Example: @format table
816
+ */
817
+ private extractFormat(jsdocContent: string): 'primitive' | 'table' | 'tree' | 'list' | 'none' | undefined {
818
+ const match = jsdocContent.match(/@format\s+(primitive|table|tree|list|none)/i);
819
+ if (match) {
820
+ return match[1].toLowerCase() as 'primitive' | 'table' | 'tree' | 'list' | 'none';
821
+ }
822
+ return undefined;
823
+ }
824
+
825
+ /**
826
+ * Extract MIME type from @mimeType tag
827
+ * Example: @mimeType text/markdown
828
+ */
829
+ private extractMimeType(jsdocContent: string): string | undefined {
830
+ const match = jsdocContent.match(/@mimeType\s+([\w\/\-+.]+)/i);
831
+ return match ? match[1].trim() : undefined;
832
+ }
833
+ }