@portel/photon-core 2.9.4 → 2.10.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.
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +23 -1
- package/dist/compiler.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mixins.d.ts +42 -0
- package/dist/mixins.d.ts.map +1 -0
- package/dist/mixins.js +156 -0
- package/dist/mixins.js.map +1 -0
- package/dist/schema-extractor.d.ts +4 -0
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +270 -18
- package/dist/schema-extractor.js.map +1 -1
- package/package.json +6 -4
- package/src/compiler.ts +26 -1
- package/src/index.ts +3 -0
- package/src/mixins.ts +187 -0
- package/src/schema-extractor.ts +343 -18
package/src/schema-extractor.ts
CHANGED
|
@@ -140,10 +140,15 @@ export class SchemaExtractor {
|
|
|
140
140
|
const paramDocs = this.extractParamDocs(jsdoc);
|
|
141
141
|
const paramConstraints = this.extractParamConstraints(jsdoc);
|
|
142
142
|
|
|
143
|
+
// Track which paramDocs/constraints were matched for fail-safe handling
|
|
144
|
+
const matchedDocs = new Set<string>();
|
|
145
|
+
const matchedConstraints = new Set<string>();
|
|
146
|
+
|
|
143
147
|
// Merge descriptions and constraints into properties
|
|
144
148
|
Object.keys(properties).forEach(key => {
|
|
145
149
|
if (paramDocs.has(key)) {
|
|
146
150
|
properties[key].description = paramDocs.get(key);
|
|
151
|
+
matchedDocs.add(key);
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
// Apply TypeScript-extracted metadata (before JSDoc, so JSDoc can override)
|
|
@@ -156,9 +161,54 @@ export class SchemaExtractor {
|
|
|
156
161
|
if (paramConstraints.has(key)) {
|
|
157
162
|
const constraints = paramConstraints.get(key)!;
|
|
158
163
|
this.applyConstraints(properties[key], constraints);
|
|
164
|
+
matchedConstraints.add(key);
|
|
159
165
|
}
|
|
160
166
|
});
|
|
161
167
|
|
|
168
|
+
// Fail-safe: Handle mismatched parameter names from JSDoc
|
|
169
|
+
// If a @param name doesn't match any actual parameter, try fuzzy matching
|
|
170
|
+
const unmatchedDocs = Array.from(paramDocs.entries()).filter(([name]) => !matchedDocs.has(name));
|
|
171
|
+
if (unmatchedDocs.length > 0) {
|
|
172
|
+
const propKeys = Object.keys(properties);
|
|
173
|
+
|
|
174
|
+
// If there's only one parameter and one unmatched doc, assume it belongs to that parameter
|
|
175
|
+
if (propKeys.length === 1 && unmatchedDocs.length === 1) {
|
|
176
|
+
const paramKey = propKeys[0];
|
|
177
|
+
const [docName, docValue] = unmatchedDocs[0];
|
|
178
|
+
if (!properties[paramKey].description) {
|
|
179
|
+
properties[paramKey].description = `${docName}: ${docValue}`;
|
|
180
|
+
console.warn(
|
|
181
|
+
`Parameter name mismatch in JSDoc: @param ${docName} doesn't match ` +
|
|
182
|
+
`function parameter "${paramKey}". Using description from @param ${docName}.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} else if (unmatchedDocs.length > 0) {
|
|
186
|
+
// Log warning for other mismatches (multiple parameters or multiple unmatched docs)
|
|
187
|
+
unmatchedDocs.forEach(([docName]) => {
|
|
188
|
+
console.warn(
|
|
189
|
+
`Parameter name mismatch in JSDoc: @param ${docName} doesn't match any function parameter. ` +
|
|
190
|
+
`Available parameters: ${propKeys.join(', ')}. Consider updating @param tag.`
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Similar fail-safe for constraints
|
|
197
|
+
const unmatchedConstraints = Array.from(paramConstraints.entries()).filter(([name]) => !matchedConstraints.has(name));
|
|
198
|
+
if (unmatchedConstraints.length > 0) {
|
|
199
|
+
const propKeys = Object.keys(properties);
|
|
200
|
+
|
|
201
|
+
if (propKeys.length === 1 && unmatchedConstraints.length === 1) {
|
|
202
|
+
const paramKey = propKeys[0];
|
|
203
|
+
const [constraintName, constraintValue] = unmatchedConstraints[0];
|
|
204
|
+
this.applyConstraints(properties[paramKey], constraintValue);
|
|
205
|
+
console.warn(
|
|
206
|
+
`Parameter name mismatch in JSDoc: constraint tag for ${constraintName} doesn't match ` +
|
|
207
|
+
`function parameter "${paramKey}". Applying constraint to "${paramKey}".`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
162
212
|
const description = this.extractDescription(jsdoc);
|
|
163
213
|
const inputSchema = {
|
|
164
214
|
type: 'object' as const,
|
|
@@ -213,7 +263,7 @@ export class SchemaExtractor {
|
|
|
213
263
|
const throttled = this.extractThrottled(jsdoc);
|
|
214
264
|
const debounced = this.extractDebounced(jsdoc);
|
|
215
265
|
const queued = this.extractQueued(jsdoc);
|
|
216
|
-
const validations = this.extractValidations(jsdoc);
|
|
266
|
+
const validations = this.extractValidations(jsdoc, Object.keys(properties));
|
|
217
267
|
const deprecated = this.extractDeprecated(jsdoc);
|
|
218
268
|
|
|
219
269
|
// Build unified middleware declarations (single source of truth for runtime)
|
|
@@ -520,6 +570,24 @@ export class SchemaExtractor {
|
|
|
520
570
|
} else {
|
|
521
571
|
properties[paramName] = { type: 'string' };
|
|
522
572
|
}
|
|
573
|
+
|
|
574
|
+
// Extract default value if initializer exists
|
|
575
|
+
if (param.initializer) {
|
|
576
|
+
const defaultValue = this.extractDefaultValue(param.initializer, sourceFile);
|
|
577
|
+
if (defaultValue !== undefined) {
|
|
578
|
+
// Validate default value type matches parameter type
|
|
579
|
+
const paramType = properties[paramName].type;
|
|
580
|
+
if (!this.isDefaultValueTypeCompatible(defaultValue, paramType)) {
|
|
581
|
+
const defaultType = typeof defaultValue;
|
|
582
|
+
console.warn(
|
|
583
|
+
`Default value type mismatch: parameter "${paramName}" is type "${paramType}" ` +
|
|
584
|
+
`but default value is type "${defaultType}" (${JSON.stringify(defaultValue)}). ` +
|
|
585
|
+
`This may cause runtime errors.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
properties[paramName].default = defaultValue;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
523
591
|
}
|
|
524
592
|
|
|
525
593
|
return { properties, required, simpleParams: true };
|
|
@@ -874,6 +942,30 @@ export class SchemaExtractor {
|
|
|
874
942
|
/**
|
|
875
943
|
* Extract default value from initializer
|
|
876
944
|
*/
|
|
945
|
+
/**
|
|
946
|
+
* Check if a default value's type is compatible with the parameter type
|
|
947
|
+
*/
|
|
948
|
+
private isDefaultValueTypeCompatible(defaultValue: any, paramType: string): boolean {
|
|
949
|
+
const valueType = typeof defaultValue;
|
|
950
|
+
|
|
951
|
+
// Type must match (number → number, boolean → boolean, etc.)
|
|
952
|
+
switch (paramType) {
|
|
953
|
+
case 'string':
|
|
954
|
+
return valueType === 'string';
|
|
955
|
+
case 'number':
|
|
956
|
+
// Number params need number defaults, not strings
|
|
957
|
+
return valueType === 'number';
|
|
958
|
+
case 'boolean':
|
|
959
|
+
return valueType === 'boolean';
|
|
960
|
+
case 'array':
|
|
961
|
+
return Array.isArray(defaultValue);
|
|
962
|
+
case 'object':
|
|
963
|
+
return valueType === 'object';
|
|
964
|
+
default:
|
|
965
|
+
return true; // Unknown types are assumed compatible
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
877
969
|
private extractDefaultValue(initializer: ts.Expression, sourceFile: ts.SourceFile): any {
|
|
878
970
|
// String literals
|
|
879
971
|
if (ts.isStringLiteral(initializer)) {
|
|
@@ -893,8 +985,25 @@ export class SchemaExtractor {
|
|
|
893
985
|
return false;
|
|
894
986
|
}
|
|
895
987
|
|
|
896
|
-
//
|
|
897
|
-
|
|
988
|
+
// Detect complex expressions
|
|
989
|
+
const expressionText = initializer.getText(sourceFile);
|
|
990
|
+
const isComplexExpression =
|
|
991
|
+
ts.isCallExpression(initializer) || // Function calls: Math.max(10, 100)
|
|
992
|
+
ts.isObjectLiteralExpression(initializer) || // Objects: { key: 'value' }
|
|
993
|
+
ts.isArrayLiteralExpression(initializer) || // Arrays: [1, 2, 3]
|
|
994
|
+
ts.isBinaryExpression(initializer) || // Binary ops: 10 + 20
|
|
995
|
+
ts.isConditionalExpression(initializer); // Ternary: x ? a : b
|
|
996
|
+
|
|
997
|
+
if (isComplexExpression) {
|
|
998
|
+
console.warn(
|
|
999
|
+
`complex default value cannot be reliably serialized: "${expressionText}". ` +
|
|
1000
|
+
`Default will not be applied to schema. Consider using a simple literal value instead.`
|
|
1001
|
+
);
|
|
1002
|
+
return undefined;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// For other expressions, return as string
|
|
1006
|
+
return expressionText;
|
|
898
1007
|
}
|
|
899
1008
|
|
|
900
1009
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1213,7 +1322,17 @@ export class SchemaExtractor {
|
|
|
1213
1322
|
// Extract {@format formatName} - use lookahead to match until tag-closing }
|
|
1214
1323
|
const formatMatch = description.match(/\{@format\s+(.+?)\}(?=\s|$|{@)/);
|
|
1215
1324
|
if (formatMatch) {
|
|
1216
|
-
|
|
1325
|
+
const format = formatMatch[1].trim();
|
|
1326
|
+
// Validate format is in whitelist (JSON Schema + custom formats)
|
|
1327
|
+
const validFormats = ['email', 'date', 'date-time', 'time', 'duration', 'uri', 'uri-reference', 'uuid', 'ipv4', 'ipv6', 'hostname', 'json', 'table'];
|
|
1328
|
+
if (!validFormats.includes(format)) {
|
|
1329
|
+
console.warn(
|
|
1330
|
+
`Invalid @format value: "${format}". ` +
|
|
1331
|
+
`Valid formats: ${validFormats.join(', ')}. Format not applied.`
|
|
1332
|
+
);
|
|
1333
|
+
} else {
|
|
1334
|
+
paramConstraints.format = format;
|
|
1335
|
+
}
|
|
1217
1336
|
}
|
|
1218
1337
|
|
|
1219
1338
|
// Extract {@choice value1,value2,...} - converts to enum in schema
|
|
@@ -1242,6 +1361,18 @@ export class SchemaExtractor {
|
|
|
1242
1361
|
}
|
|
1243
1362
|
}
|
|
1244
1363
|
|
|
1364
|
+
// Extract {@minItems value} - for arrays
|
|
1365
|
+
const minItemsMatch = description.match(/\{@minItems\s+(-?\d+(?:\.\d+)?)\}/);
|
|
1366
|
+
if (minItemsMatch) {
|
|
1367
|
+
paramConstraints.minItems = parseInt(minItemsMatch[1], 10);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Extract {@maxItems value} - for arrays
|
|
1371
|
+
const maxItemsMatch = description.match(/\{@maxItems\s+(-?\d+(?:\.\d+)?)\}/);
|
|
1372
|
+
if (maxItemsMatch) {
|
|
1373
|
+
paramConstraints.maxItems = parseInt(maxItemsMatch[1], 10);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1245
1376
|
// Extract {@unique} or {@uniqueItems} - for arrays
|
|
1246
1377
|
if (description.match(/\{@unique(?:Items)?\s*\}/)) {
|
|
1247
1378
|
paramConstraints.unique = true;
|
|
@@ -1276,11 +1407,19 @@ export class SchemaExtractor {
|
|
|
1276
1407
|
paramConstraints.deprecated = deprecatedMatch[1]?.trim() || true;
|
|
1277
1408
|
}
|
|
1278
1409
|
|
|
1279
|
-
// Extract {@readOnly} and {@writeOnly} -
|
|
1280
|
-
// They are mutually exclusive, so
|
|
1410
|
+
// Extract {@readOnly} and {@writeOnly} - detect conflicts
|
|
1411
|
+
// They are mutually exclusive, so warn if both present
|
|
1281
1412
|
const readOnlyMatch = description.match(/\{@readOnly\s*\}/);
|
|
1282
1413
|
const writeOnlyMatch = description.match(/\{@writeOnly\s*\}/);
|
|
1283
1414
|
|
|
1415
|
+
if (readOnlyMatch && writeOnlyMatch) {
|
|
1416
|
+
// Warn about conflict
|
|
1417
|
+
console.warn(
|
|
1418
|
+
`Conflicting constraints: @readOnly and @writeOnly cannot both be applied. ` +
|
|
1419
|
+
`Keeping @${readOnlyMatch && writeOnlyMatch ? (description.lastIndexOf('@readOnly') > description.lastIndexOf('@writeOnly') ? 'readOnly' : 'writeOnly') : 'readOnly'}.`
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1284
1423
|
if (readOnlyMatch || writeOnlyMatch) {
|
|
1285
1424
|
// Find positions to determine which comes last
|
|
1286
1425
|
const readOnlyPos = readOnlyMatch ? description.indexOf(readOnlyMatch[0]) : -1;
|
|
@@ -1324,6 +1463,22 @@ export class SchemaExtractor {
|
|
|
1324
1463
|
paramConstraints.accept = acceptMatch[1].trim();
|
|
1325
1464
|
}
|
|
1326
1465
|
|
|
1466
|
+
// Validate no unknown {@...} tags (typos in constraint names)
|
|
1467
|
+
const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'field', 'default', 'unique', 'uniqueItems',
|
|
1468
|
+
'example', 'multipleOf', 'deprecated', 'readOnly', 'writeOnly', 'label', 'placeholder',
|
|
1469
|
+
'hint', 'hidden', 'accept', 'minItems', 'maxItems'];
|
|
1470
|
+
const unknownTagRegex = /\{@([\w-]+)\s*(?:\s+[^}]*)?\}/g;
|
|
1471
|
+
let unknownMatch;
|
|
1472
|
+
while ((unknownMatch = unknownTagRegex.exec(description)) !== null) {
|
|
1473
|
+
const tagName = unknownMatch[1];
|
|
1474
|
+
if (!allKnownTags.includes(tagName)) {
|
|
1475
|
+
console.warn(
|
|
1476
|
+
`unknown constraint/hint: @${tagName}. ` +
|
|
1477
|
+
`Valid hints: ${allKnownTags.slice(0, 8).join(', ')}, etc. This tag will be ignored.`
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1327
1482
|
if (Object.keys(paramConstraints).length > 0) {
|
|
1328
1483
|
constraints.set(paramName, paramConstraints);
|
|
1329
1484
|
}
|
|
@@ -1339,9 +1494,62 @@ export class SchemaExtractor {
|
|
|
1339
1494
|
* Works with both simple types and anyOf schemas
|
|
1340
1495
|
*/
|
|
1341
1496
|
private applyConstraints(schema: any, constraints: any) {
|
|
1497
|
+
// Validate constraint values before applying (fail-safe)
|
|
1498
|
+
if (constraints.min !== undefined && constraints.max !== undefined) {
|
|
1499
|
+
if (constraints.min > constraints.max) {
|
|
1500
|
+
console.warn(
|
|
1501
|
+
`Invalid constraint: @min (${constraints.min}) > @max (${constraints.max}). ` +
|
|
1502
|
+
`Using only @min. Remove @max or use a larger value.`
|
|
1503
|
+
);
|
|
1504
|
+
delete constraints.max;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1342
1508
|
// Helper to apply constraints to a single schema based on type
|
|
1343
1509
|
const applyToSchema = (s: any) => {
|
|
1510
|
+
// Validate constraint-type compatibility
|
|
1511
|
+
if (!s.enum) {
|
|
1512
|
+
// Warn for incompatible constraints
|
|
1513
|
+
if ((constraints.min !== undefined || constraints.max !== undefined) &&
|
|
1514
|
+
s.type !== 'number' && s.type !== 'string' && s.type !== 'array') {
|
|
1515
|
+
const constraintType = constraints.min !== undefined ? '@min' : '@max';
|
|
1516
|
+
console.warn(
|
|
1517
|
+
`Constraint ${constraintType} not applicable to type "${s.type || 'unknown'}". ` +
|
|
1518
|
+
`This constraint applies to number, string, or array types only.`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (constraints.pattern !== undefined && s.type !== 'string') {
|
|
1523
|
+
console.warn(
|
|
1524
|
+
`Constraint @pattern not applicable to type "${s.type || 'unknown'}". ` +
|
|
1525
|
+
`Pattern only applies to string types.`
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if ((constraints.minItems !== undefined || constraints.maxItems !== undefined) && s.type !== 'array') {
|
|
1530
|
+
const constraintType = constraints.minItems !== undefined ? '@minItems' : '@maxItems';
|
|
1531
|
+
console.warn(
|
|
1532
|
+
`Constraint ${constraintType} not applicable to type "${s.type || 'unknown'}". ` +
|
|
1533
|
+
`This constraint applies to array types only.`
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (constraints.multipleOf !== undefined && s.type !== 'number') {
|
|
1538
|
+
console.warn(
|
|
1539
|
+
`Constraint @multipleOf not applicable to type "${s.type || 'unknown'}". ` +
|
|
1540
|
+
`This constraint applies to number types only.`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1344
1545
|
if (s.enum) {
|
|
1546
|
+
// Check for pattern+enum conflict before returning
|
|
1547
|
+
if (constraints.pattern !== undefined) {
|
|
1548
|
+
console.warn(
|
|
1549
|
+
`Conflicting constraints: @pattern cannot be used with enum/choices. ` +
|
|
1550
|
+
`Pattern is ignored when specific values are defined via enum or @choice.`
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1345
1553
|
// Skip enum types for most constraints (but still apply deprecated, examples, etc.)
|
|
1346
1554
|
if (constraints.examples !== undefined) {
|
|
1347
1555
|
s.examples = constraints.examples;
|
|
@@ -1352,7 +1560,7 @@ export class SchemaExtractor {
|
|
|
1352
1560
|
return;
|
|
1353
1561
|
}
|
|
1354
1562
|
|
|
1355
|
-
// Apply min/max based on type
|
|
1563
|
+
// Apply min/max based on type (skip if type is incompatible)
|
|
1356
1564
|
if (s.type === 'number') {
|
|
1357
1565
|
if (constraints.min !== undefined) {
|
|
1358
1566
|
s.minimum = constraints.min;
|
|
@@ -1361,7 +1569,15 @@ export class SchemaExtractor {
|
|
|
1361
1569
|
s.maximum = constraints.max;
|
|
1362
1570
|
}
|
|
1363
1571
|
if (constraints.multipleOf !== undefined) {
|
|
1364
|
-
|
|
1572
|
+
// Validate multipleOf is positive (JSON schema requires > 0)
|
|
1573
|
+
if (constraints.multipleOf <= 0) {
|
|
1574
|
+
console.warn(
|
|
1575
|
+
`Invalid @multipleOf value: ${constraints.multipleOf}. ` +
|
|
1576
|
+
`Must be positive and non-zero. Constraint not applied.`
|
|
1577
|
+
);
|
|
1578
|
+
} else {
|
|
1579
|
+
s.multipleOf = constraints.multipleOf;
|
|
1580
|
+
}
|
|
1365
1581
|
}
|
|
1366
1582
|
} else if (s.type === 'string') {
|
|
1367
1583
|
if (constraints.min !== undefined) {
|
|
@@ -1371,7 +1587,16 @@ export class SchemaExtractor {
|
|
|
1371
1587
|
s.maxLength = constraints.max;
|
|
1372
1588
|
}
|
|
1373
1589
|
if (constraints.pattern !== undefined) {
|
|
1374
|
-
|
|
1590
|
+
// Validate pattern is valid regex before applying (fail-safe)
|
|
1591
|
+
try {
|
|
1592
|
+
new RegExp(constraints.pattern);
|
|
1593
|
+
s.pattern = constraints.pattern;
|
|
1594
|
+
} catch (e) {
|
|
1595
|
+
console.warn(
|
|
1596
|
+
`Invalid regex in @pattern constraint: "${constraints.pattern}". ` +
|
|
1597
|
+
`${e instanceof Error ? e.message : String(e)}. Pattern not applied.`
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1375
1600
|
}
|
|
1376
1601
|
} else if (s.type === 'array') {
|
|
1377
1602
|
if (constraints.min !== undefined) {
|
|
@@ -1380,10 +1605,27 @@ export class SchemaExtractor {
|
|
|
1380
1605
|
if (constraints.max !== undefined) {
|
|
1381
1606
|
s.maxItems = constraints.max;
|
|
1382
1607
|
}
|
|
1608
|
+
if (constraints.minItems !== undefined) {
|
|
1609
|
+
s.minItems = constraints.minItems;
|
|
1610
|
+
}
|
|
1611
|
+
if (constraints.maxItems !== undefined) {
|
|
1612
|
+
s.maxItems = constraints.maxItems;
|
|
1613
|
+
}
|
|
1383
1614
|
if (constraints.unique === true) {
|
|
1384
1615
|
s.uniqueItems = true;
|
|
1385
1616
|
}
|
|
1617
|
+
} else if (s.type && s.type !== 'boolean' && s.type !== 'object') {
|
|
1618
|
+
// For other compatible types, apply min/max as needed
|
|
1619
|
+
if (['integer', 'number'].includes(s.type)) {
|
|
1620
|
+
if (constraints.min !== undefined) {
|
|
1621
|
+
s.minimum = constraints.min;
|
|
1622
|
+
}
|
|
1623
|
+
if (constraints.max !== undefined) {
|
|
1624
|
+
s.maximum = constraints.max;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1386
1627
|
}
|
|
1628
|
+
// Note: For boolean and object types, we skip min/max constraints (already warned above)
|
|
1387
1629
|
|
|
1388
1630
|
// Apply type-agnostic constraints
|
|
1389
1631
|
if (constraints.format !== undefined) {
|
|
@@ -1399,12 +1641,31 @@ export class SchemaExtractor {
|
|
|
1399
1641
|
s.deprecated = constraints.deprecated === true ? true : constraints.deprecated;
|
|
1400
1642
|
}
|
|
1401
1643
|
// Apply enum from @choice tag (overrides TypeScript-derived enum if present)
|
|
1402
|
-
if (constraints.enum !== undefined
|
|
1403
|
-
|
|
1644
|
+
if (constraints.enum !== undefined) {
|
|
1645
|
+
// Check for pattern+enum conflict when @choice is being applied
|
|
1646
|
+
if (constraints.pattern !== undefined && s.type === 'string') {
|
|
1647
|
+
console.warn(
|
|
1648
|
+
`Conflicting constraints: @pattern cannot be used with enum/choices. ` +
|
|
1649
|
+
`Pattern is ignored when specific values are defined via enum or @choice.`
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
if (!s.enum) {
|
|
1653
|
+
s.enum = constraints.enum;
|
|
1654
|
+
}
|
|
1404
1655
|
}
|
|
1405
1656
|
// Apply field hint for UI rendering
|
|
1406
1657
|
if (constraints.field !== undefined) {
|
|
1407
1658
|
s.field = constraints.field;
|
|
1659
|
+
// Validate @field integer constraint with default values
|
|
1660
|
+
if (constraints.field === 'integer' && s.default !== undefined && typeof s.default === 'number') {
|
|
1661
|
+
if (!Number.isInteger(s.default)) {
|
|
1662
|
+
console.warn(
|
|
1663
|
+
`Default value violates @field integer constraint: ` +
|
|
1664
|
+
`expected integer but got ${s.default}. ` +
|
|
1665
|
+
`Consider using an integer default value.`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1408
1669
|
}
|
|
1409
1670
|
// Apply custom label for form fields
|
|
1410
1671
|
if (constraints.label !== undefined) {
|
|
@@ -1662,10 +1923,27 @@ export class SchemaExtractor {
|
|
|
1662
1923
|
* - @retryable 3 2s → 3 retries, 2s delay
|
|
1663
1924
|
*/
|
|
1664
1925
|
private extractRetryable(jsdocContent: string): { count: number; delay: number } | undefined {
|
|
1665
|
-
const match = jsdocContent.match(/@retryable(?:\s+(
|
|
1926
|
+
const match = jsdocContent.match(/@retryable(?:\s+([-\d]+))?(?:\s+([-\d]+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
|
|
1666
1927
|
if (!match) return undefined;
|
|
1667
|
-
|
|
1668
|
-
|
|
1928
|
+
let count = match[1] ? parseInt(match[1], 10) : 3;
|
|
1929
|
+
let delay = match[2] ? parseDuration(match[2]) : 1_000;
|
|
1930
|
+
|
|
1931
|
+
// Validate retryable configuration
|
|
1932
|
+
if (count <= 0) {
|
|
1933
|
+
console.warn(
|
|
1934
|
+
`Invalid @retryable count: ${count}. ` +
|
|
1935
|
+
`Count must be positive (> 0). Using default count 3.`
|
|
1936
|
+
);
|
|
1937
|
+
count = 3;
|
|
1938
|
+
}
|
|
1939
|
+
if (delay <= 0) {
|
|
1940
|
+
console.warn(
|
|
1941
|
+
`Invalid @retryable delay: ${delay}ms. ` +
|
|
1942
|
+
`Delay must be positive (> 0). Using default delay 1000ms.`
|
|
1943
|
+
);
|
|
1944
|
+
delay = 1_000;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1669
1947
|
return { count, delay };
|
|
1670
1948
|
}
|
|
1671
1949
|
|
|
@@ -1675,9 +1953,45 @@ export class SchemaExtractor {
|
|
|
1675
1953
|
* - @throttled 100/h → 100 calls per hour
|
|
1676
1954
|
*/
|
|
1677
1955
|
private extractThrottled(jsdocContent: string): { count: number; windowMs: number } | undefined {
|
|
1956
|
+
// First check if @throttled is present at all
|
|
1957
|
+
const hasThrottled = /@throttled\b/i.test(jsdocContent);
|
|
1678
1958
|
const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
|
|
1679
|
-
|
|
1680
|
-
|
|
1959
|
+
|
|
1960
|
+
if (!match) {
|
|
1961
|
+
if (hasThrottled) {
|
|
1962
|
+
// @throttled is present but format is invalid
|
|
1963
|
+
const invalidMatch = jsdocContent.match(/@throttled\s+([^\s]+)/i);
|
|
1964
|
+
if (invalidMatch) {
|
|
1965
|
+
console.warn(
|
|
1966
|
+
`Invalid @throttled rate format: "${invalidMatch[1]}". ` +
|
|
1967
|
+
`Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return undefined;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const rateStr = match[1];
|
|
1975
|
+
const rate = parseRate(rateStr);
|
|
1976
|
+
|
|
1977
|
+
// Validate throttled configuration
|
|
1978
|
+
if (!rate) {
|
|
1979
|
+
console.warn(
|
|
1980
|
+
`Invalid @throttled rate format: "${rateStr}". ` +
|
|
1981
|
+
`Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`
|
|
1982
|
+
);
|
|
1983
|
+
return undefined;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (rate.count <= 0) {
|
|
1987
|
+
console.warn(
|
|
1988
|
+
`Invalid @throttled count: ${rate.count}. ` +
|
|
1989
|
+
`Count must be positive (> 0). Rate not applied.`
|
|
1990
|
+
);
|
|
1991
|
+
return undefined;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
return rate;
|
|
1681
1995
|
}
|
|
1682
1996
|
|
|
1683
1997
|
/**
|
|
@@ -1710,13 +2024,24 @@ export class SchemaExtractor {
|
|
|
1710
2024
|
* - @validate params.email must be a valid email
|
|
1711
2025
|
* - @validate params.amount must be positive
|
|
1712
2026
|
*/
|
|
1713
|
-
private extractValidations(jsdocContent: string): Array<{ field: string; rule: string }> | undefined {
|
|
2027
|
+
private extractValidations(jsdocContent: string, validParamNames?: string[]): Array<{ field: string; rule: string }> | undefined {
|
|
1714
2028
|
const validations: Array<{ field: string; rule: string }> = [];
|
|
1715
2029
|
const regex = /@validate\s+([\w.]+)\s+(.+)/g;
|
|
1716
2030
|
let match;
|
|
1717
2031
|
while ((match = regex.exec(jsdocContent)) !== null) {
|
|
2032
|
+
const fieldName = match[1].replace(/^params\./, ''); // strip params. prefix
|
|
2033
|
+
|
|
2034
|
+
// Fail-safe: Validate that referenced field exists (if validParamNames provided)
|
|
2035
|
+
if (validParamNames && !validParamNames.includes(fieldName)) {
|
|
2036
|
+
console.warn(
|
|
2037
|
+
`@validate references non-existent parameter "${fieldName}". ` +
|
|
2038
|
+
`Available parameters: ${validParamNames.join(', ')}. Validation rule ignored.`
|
|
2039
|
+
);
|
|
2040
|
+
continue;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
1718
2043
|
validations.push({
|
|
1719
|
-
field:
|
|
2044
|
+
field: fieldName,
|
|
1720
2045
|
rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
|
|
1721
2046
|
});
|
|
1722
2047
|
}
|