@portel/photon-core 2.9.4 → 2.10.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.
@@ -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
- // For complex expressions (function calls, etc.), return as string
897
- return initializer.getText(sourceFile);
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
- paramConstraints.format = formatMatch[1].trim();
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} - track which comes last
1280
- // They are mutually exclusive, so last one wins
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,8 +1494,54 @@ 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) {
1345
1546
  // Skip enum types for most constraints (but still apply deprecated, examples, etc.)
1346
1547
  if (constraints.examples !== undefined) {
@@ -1352,7 +1553,7 @@ export class SchemaExtractor {
1352
1553
  return;
1353
1554
  }
1354
1555
 
1355
- // Apply min/max based on type
1556
+ // Apply min/max based on type (skip if type is incompatible)
1356
1557
  if (s.type === 'number') {
1357
1558
  if (constraints.min !== undefined) {
1358
1559
  s.minimum = constraints.min;
@@ -1361,7 +1562,15 @@ export class SchemaExtractor {
1361
1562
  s.maximum = constraints.max;
1362
1563
  }
1363
1564
  if (constraints.multipleOf !== undefined) {
1364
- s.multipleOf = constraints.multipleOf;
1565
+ // Validate multipleOf is positive (JSON schema requires > 0)
1566
+ if (constraints.multipleOf <= 0) {
1567
+ console.warn(
1568
+ `Invalid @multipleOf value: ${constraints.multipleOf}. ` +
1569
+ `Must be positive and non-zero. Constraint not applied.`
1570
+ );
1571
+ } else {
1572
+ s.multipleOf = constraints.multipleOf;
1573
+ }
1365
1574
  }
1366
1575
  } else if (s.type === 'string') {
1367
1576
  if (constraints.min !== undefined) {
@@ -1371,7 +1580,24 @@ export class SchemaExtractor {
1371
1580
  s.maxLength = constraints.max;
1372
1581
  }
1373
1582
  if (constraints.pattern !== undefined) {
1374
- s.pattern = constraints.pattern;
1583
+ // Check for pattern+enum conflict
1584
+ if (s.enum || constraints.enum) {
1585
+ console.warn(
1586
+ `Conflicting constraints: @pattern cannot be used with enum/choices. ` +
1587
+ `Pattern is ignored when specific values are defined via enum or @choice.`
1588
+ );
1589
+ } else {
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
+ }
1600
+ }
1375
1601
  }
1376
1602
  } else if (s.type === 'array') {
1377
1603
  if (constraints.min !== undefined) {
@@ -1380,10 +1606,27 @@ export class SchemaExtractor {
1380
1606
  if (constraints.max !== undefined) {
1381
1607
  s.maxItems = constraints.max;
1382
1608
  }
1609
+ if (constraints.minItems !== undefined) {
1610
+ s.minItems = constraints.minItems;
1611
+ }
1612
+ if (constraints.maxItems !== undefined) {
1613
+ s.maxItems = constraints.maxItems;
1614
+ }
1383
1615
  if (constraints.unique === true) {
1384
1616
  s.uniqueItems = true;
1385
1617
  }
1618
+ } else if (s.type && s.type !== 'boolean' && s.type !== 'object') {
1619
+ // For other compatible types, apply min/max as needed
1620
+ if (['integer', 'number'].includes(s.type)) {
1621
+ if (constraints.min !== undefined) {
1622
+ s.minimum = constraints.min;
1623
+ }
1624
+ if (constraints.max !== undefined) {
1625
+ s.maximum = constraints.max;
1626
+ }
1627
+ }
1386
1628
  }
1629
+ // Note: For boolean and object types, we skip min/max constraints (already warned above)
1387
1630
 
1388
1631
  // Apply type-agnostic constraints
1389
1632
  if (constraints.format !== undefined) {
@@ -1662,10 +1905,27 @@ export class SchemaExtractor {
1662
1905
  * - @retryable 3 2s → 3 retries, 2s delay
1663
1906
  */
1664
1907
  private extractRetryable(jsdocContent: string): { count: number; delay: number } | undefined {
1665
- const match = jsdocContent.match(/@retryable(?:\s+(\d+))?(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1908
+ const match = jsdocContent.match(/@retryable(?:\s+([-\d]+))?(?:\s+([-\d]+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1666
1909
  if (!match) return undefined;
1667
- const count = match[1] ? parseInt(match[1], 10) : 3;
1668
- const delay = match[2] ? parseDuration(match[2]) : 1_000;
1910
+ let count = match[1] ? parseInt(match[1], 10) : 3;
1911
+ let delay = match[2] ? parseDuration(match[2]) : 1_000;
1912
+
1913
+ // Validate retryable configuration
1914
+ if (count <= 0) {
1915
+ console.warn(
1916
+ `Invalid @retryable count: ${count}. ` +
1917
+ `Count must be positive (> 0). Using default count 3.`
1918
+ );
1919
+ count = 3;
1920
+ }
1921
+ if (delay <= 0) {
1922
+ console.warn(
1923
+ `Invalid @retryable delay: ${delay}ms. ` +
1924
+ `Delay must be positive (> 0). Using default delay 1000ms.`
1925
+ );
1926
+ delay = 1_000;
1927
+ }
1928
+
1669
1929
  return { count, delay };
1670
1930
  }
1671
1931
 
@@ -1675,9 +1935,45 @@ export class SchemaExtractor {
1675
1935
  * - @throttled 100/h → 100 calls per hour
1676
1936
  */
1677
1937
  private extractThrottled(jsdocContent: string): { count: number; windowMs: number } | undefined {
1938
+ // First check if @throttled is present at all
1939
+ const hasThrottled = /@throttled\b/i.test(jsdocContent);
1678
1940
  const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
1679
- if (!match) return undefined;
1680
- return parseRate(match[1]);
1941
+
1942
+ if (!match) {
1943
+ if (hasThrottled) {
1944
+ // @throttled is present but format is invalid
1945
+ const invalidMatch = jsdocContent.match(/@throttled\s+([^\s]+)/i);
1946
+ if (invalidMatch) {
1947
+ console.warn(
1948
+ `Invalid @throttled rate format: "${invalidMatch[1]}". ` +
1949
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`
1950
+ );
1951
+ }
1952
+ }
1953
+ return undefined;
1954
+ }
1955
+
1956
+ const rateStr = match[1];
1957
+ const rate = parseRate(rateStr);
1958
+
1959
+ // Validate throttled configuration
1960
+ if (!rate) {
1961
+ console.warn(
1962
+ `Invalid @throttled rate format: "${rateStr}". ` +
1963
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`
1964
+ );
1965
+ return undefined;
1966
+ }
1967
+
1968
+ if (rate.count <= 0) {
1969
+ console.warn(
1970
+ `Invalid @throttled count: ${rate.count}. ` +
1971
+ `Count must be positive (> 0). Rate not applied.`
1972
+ );
1973
+ return undefined;
1974
+ }
1975
+
1976
+ return rate;
1681
1977
  }
1682
1978
 
1683
1979
  /**
@@ -1710,13 +2006,24 @@ export class SchemaExtractor {
1710
2006
  * - @validate params.email must be a valid email
1711
2007
  * - @validate params.amount must be positive
1712
2008
  */
1713
- private extractValidations(jsdocContent: string): Array<{ field: string; rule: string }> | undefined {
2009
+ private extractValidations(jsdocContent: string, validParamNames?: string[]): Array<{ field: string; rule: string }> | undefined {
1714
2010
  const validations: Array<{ field: string; rule: string }> = [];
1715
2011
  const regex = /@validate\s+([\w.]+)\s+(.+)/g;
1716
2012
  let match;
1717
2013
  while ((match = regex.exec(jsdocContent)) !== null) {
2014
+ const fieldName = match[1].replace(/^params\./, ''); // strip params. prefix
2015
+
2016
+ // Fail-safe: Validate that referenced field exists (if validParamNames provided)
2017
+ if (validParamNames && !validParamNames.includes(fieldName)) {
2018
+ console.warn(
2019
+ `@validate references non-existent parameter "${fieldName}". ` +
2020
+ `Available parameters: ${validParamNames.join(', ')}. Validation rule ignored.`
2021
+ );
2022
+ continue;
2023
+ }
2024
+
1718
2025
  validations.push({
1719
- field: match[1].replace(/^params\./, ''), // strip params. prefix
2026
+ field: fieldName,
1720
2027
  rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
1721
2028
  });
1722
2029
  }