@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.
@@ -103,10 +103,14 @@ export class SchemaExtractor {
103
103
  // Extract descriptions from JSDoc
104
104
  const paramDocs = this.extractParamDocs(jsdoc);
105
105
  const paramConstraints = this.extractParamConstraints(jsdoc);
106
+ // Track which paramDocs/constraints were matched for fail-safe handling
107
+ const matchedDocs = new Set();
108
+ const matchedConstraints = new Set();
106
109
  // Merge descriptions and constraints into properties
107
110
  Object.keys(properties).forEach(key => {
108
111
  if (paramDocs.has(key)) {
109
112
  properties[key].description = paramDocs.get(key);
113
+ matchedDocs.add(key);
110
114
  }
111
115
  // Apply TypeScript-extracted metadata (before JSDoc, so JSDoc can override)
112
116
  if (properties[key]._tsReadOnly) {
@@ -117,8 +121,44 @@ export class SchemaExtractor {
117
121
  if (paramConstraints.has(key)) {
118
122
  const constraints = paramConstraints.get(key);
119
123
  this.applyConstraints(properties[key], constraints);
124
+ matchedConstraints.add(key);
120
125
  }
121
126
  });
127
+ // Fail-safe: Handle mismatched parameter names from JSDoc
128
+ // If a @param name doesn't match any actual parameter, try fuzzy matching
129
+ const unmatchedDocs = Array.from(paramDocs.entries()).filter(([name]) => !matchedDocs.has(name));
130
+ if (unmatchedDocs.length > 0) {
131
+ const propKeys = Object.keys(properties);
132
+ // If there's only one parameter and one unmatched doc, assume it belongs to that parameter
133
+ if (propKeys.length === 1 && unmatchedDocs.length === 1) {
134
+ const paramKey = propKeys[0];
135
+ const [docName, docValue] = unmatchedDocs[0];
136
+ if (!properties[paramKey].description) {
137
+ properties[paramKey].description = `${docName}: ${docValue}`;
138
+ console.warn(`Parameter name mismatch in JSDoc: @param ${docName} doesn't match ` +
139
+ `function parameter "${paramKey}". Using description from @param ${docName}.`);
140
+ }
141
+ }
142
+ else if (unmatchedDocs.length > 0) {
143
+ // Log warning for other mismatches (multiple parameters or multiple unmatched docs)
144
+ unmatchedDocs.forEach(([docName]) => {
145
+ console.warn(`Parameter name mismatch in JSDoc: @param ${docName} doesn't match any function parameter. ` +
146
+ `Available parameters: ${propKeys.join(', ')}. Consider updating @param tag.`);
147
+ });
148
+ }
149
+ }
150
+ // Similar fail-safe for constraints
151
+ const unmatchedConstraints = Array.from(paramConstraints.entries()).filter(([name]) => !matchedConstraints.has(name));
152
+ if (unmatchedConstraints.length > 0) {
153
+ const propKeys = Object.keys(properties);
154
+ if (propKeys.length === 1 && unmatchedConstraints.length === 1) {
155
+ const paramKey = propKeys[0];
156
+ const [constraintName, constraintValue] = unmatchedConstraints[0];
157
+ this.applyConstraints(properties[paramKey], constraintValue);
158
+ console.warn(`Parameter name mismatch in JSDoc: constraint tag for ${constraintName} doesn't match ` +
159
+ `function parameter "${paramKey}". Applying constraint to "${paramKey}".`);
160
+ }
161
+ }
122
162
  const description = this.extractDescription(jsdoc);
123
163
  const inputSchema = {
124
164
  type: 'object',
@@ -169,7 +209,7 @@ export class SchemaExtractor {
169
209
  const throttled = this.extractThrottled(jsdoc);
170
210
  const debounced = this.extractDebounced(jsdoc);
171
211
  const queued = this.extractQueued(jsdoc);
172
- const validations = this.extractValidations(jsdoc);
212
+ const validations = this.extractValidations(jsdoc, Object.keys(properties));
173
213
  const deprecated = this.extractDeprecated(jsdoc);
174
214
  // Build unified middleware declarations (single source of truth for runtime)
175
215
  const middleware = this.buildMiddlewareDeclarations(jsdoc);
@@ -450,6 +490,21 @@ export class SchemaExtractor {
450
490
  else {
451
491
  properties[paramName] = { type: 'string' };
452
492
  }
493
+ // Extract default value if initializer exists
494
+ if (param.initializer) {
495
+ const defaultValue = this.extractDefaultValue(param.initializer, sourceFile);
496
+ if (defaultValue !== undefined) {
497
+ // Validate default value type matches parameter type
498
+ const paramType = properties[paramName].type;
499
+ if (!this.isDefaultValueTypeCompatible(defaultValue, paramType)) {
500
+ const defaultType = typeof defaultValue;
501
+ console.warn(`Default value type mismatch: parameter "${paramName}" is type "${paramType}" ` +
502
+ `but default value is type "${defaultType}" (${JSON.stringify(defaultValue)}). ` +
503
+ `This may cause runtime errors.`);
504
+ }
505
+ properties[paramName].default = defaultValue;
506
+ }
507
+ }
453
508
  }
454
509
  return { properties, required, simpleParams: true };
455
510
  }
@@ -756,6 +811,28 @@ export class SchemaExtractor {
756
811
  /**
757
812
  * Extract default value from initializer
758
813
  */
814
+ /**
815
+ * Check if a default value's type is compatible with the parameter type
816
+ */
817
+ isDefaultValueTypeCompatible(defaultValue, paramType) {
818
+ const valueType = typeof defaultValue;
819
+ // Type must match (number → number, boolean → boolean, etc.)
820
+ switch (paramType) {
821
+ case 'string':
822
+ return valueType === 'string';
823
+ case 'number':
824
+ // Number params need number defaults, not strings
825
+ return valueType === 'number';
826
+ case 'boolean':
827
+ return valueType === 'boolean';
828
+ case 'array':
829
+ return Array.isArray(defaultValue);
830
+ case 'object':
831
+ return valueType === 'object';
832
+ default:
833
+ return true; // Unknown types are assumed compatible
834
+ }
835
+ }
759
836
  extractDefaultValue(initializer, sourceFile) {
760
837
  // String literals
761
838
  if (ts.isStringLiteral(initializer)) {
@@ -772,8 +849,20 @@ export class SchemaExtractor {
772
849
  if (initializer.kind === ts.SyntaxKind.FalseKeyword) {
773
850
  return false;
774
851
  }
775
- // For complex expressions (function calls, etc.), return as string
776
- return initializer.getText(sourceFile);
852
+ // Detect complex expressions
853
+ const expressionText = initializer.getText(sourceFile);
854
+ const isComplexExpression = ts.isCallExpression(initializer) || // Function calls: Math.max(10, 100)
855
+ ts.isObjectLiteralExpression(initializer) || // Objects: { key: 'value' }
856
+ ts.isArrayLiteralExpression(initializer) || // Arrays: [1, 2, 3]
857
+ ts.isBinaryExpression(initializer) || // Binary ops: 10 + 20
858
+ ts.isConditionalExpression(initializer); // Ternary: x ? a : b
859
+ if (isComplexExpression) {
860
+ console.warn(`complex default value cannot be reliably serialized: "${expressionText}". ` +
861
+ `Default will not be applied to schema. Consider using a simple literal value instead.`);
862
+ return undefined;
863
+ }
864
+ // For other expressions, return as string
865
+ return expressionText;
777
866
  }
778
867
  // ═══════════════════════════════════════════════════════════════════════════════
779
868
  // INLINE CONFIG + @use PARSING
@@ -1061,7 +1150,16 @@ export class SchemaExtractor {
1061
1150
  // Extract {@format formatName} - use lookahead to match until tag-closing }
1062
1151
  const formatMatch = description.match(/\{@format\s+(.+?)\}(?=\s|$|{@)/);
1063
1152
  if (formatMatch) {
1064
- paramConstraints.format = formatMatch[1].trim();
1153
+ const format = formatMatch[1].trim();
1154
+ // Validate format is in whitelist (JSON Schema + custom formats)
1155
+ const validFormats = ['email', 'date', 'date-time', 'time', 'duration', 'uri', 'uri-reference', 'uuid', 'ipv4', 'ipv6', 'hostname', 'json', 'table'];
1156
+ if (!validFormats.includes(format)) {
1157
+ console.warn(`Invalid @format value: "${format}". ` +
1158
+ `Valid formats: ${validFormats.join(', ')}. Format not applied.`);
1159
+ }
1160
+ else {
1161
+ paramConstraints.format = format;
1162
+ }
1065
1163
  }
1066
1164
  // Extract {@choice value1,value2,...} - converts to enum in schema
1067
1165
  const choiceMatch = description.match(/\{@choice\s+([^}]+)\}/);
@@ -1087,6 +1185,16 @@ export class SchemaExtractor {
1087
1185
  paramConstraints.default = defaultValue;
1088
1186
  }
1089
1187
  }
1188
+ // Extract {@minItems value} - for arrays
1189
+ const minItemsMatch = description.match(/\{@minItems\s+(-?\d+(?:\.\d+)?)\}/);
1190
+ if (minItemsMatch) {
1191
+ paramConstraints.minItems = parseInt(minItemsMatch[1], 10);
1192
+ }
1193
+ // Extract {@maxItems value} - for arrays
1194
+ const maxItemsMatch = description.match(/\{@maxItems\s+(-?\d+(?:\.\d+)?)\}/);
1195
+ if (maxItemsMatch) {
1196
+ paramConstraints.maxItems = parseInt(maxItemsMatch[1], 10);
1197
+ }
1090
1198
  // Extract {@unique} or {@uniqueItems} - for arrays
1091
1199
  if (description.match(/\{@unique(?:Items)?\s*\}/)) {
1092
1200
  paramConstraints.unique = true;
@@ -1118,10 +1226,15 @@ export class SchemaExtractor {
1118
1226
  if (deprecatedMatch) {
1119
1227
  paramConstraints.deprecated = deprecatedMatch[1]?.trim() || true;
1120
1228
  }
1121
- // Extract {@readOnly} and {@writeOnly} - track which comes last
1122
- // They are mutually exclusive, so last one wins
1229
+ // Extract {@readOnly} and {@writeOnly} - detect conflicts
1230
+ // They are mutually exclusive, so warn if both present
1123
1231
  const readOnlyMatch = description.match(/\{@readOnly\s*\}/);
1124
1232
  const writeOnlyMatch = description.match(/\{@writeOnly\s*\}/);
1233
+ if (readOnlyMatch && writeOnlyMatch) {
1234
+ // Warn about conflict
1235
+ console.warn(`Conflicting constraints: @readOnly and @writeOnly cannot both be applied. ` +
1236
+ `Keeping @${readOnlyMatch && writeOnlyMatch ? (description.lastIndexOf('@readOnly') > description.lastIndexOf('@writeOnly') ? 'readOnly' : 'writeOnly') : 'readOnly'}.`);
1237
+ }
1125
1238
  if (readOnlyMatch || writeOnlyMatch) {
1126
1239
  // Find positions to determine which comes last
1127
1240
  const readOnlyPos = readOnlyMatch ? description.indexOf(readOnlyMatch[0]) : -1;
@@ -1159,6 +1272,19 @@ export class SchemaExtractor {
1159
1272
  if (acceptMatch) {
1160
1273
  paramConstraints.accept = acceptMatch[1].trim();
1161
1274
  }
1275
+ // Validate no unknown {@...} tags (typos in constraint names)
1276
+ const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'field', 'default', 'unique', 'uniqueItems',
1277
+ 'example', 'multipleOf', 'deprecated', 'readOnly', 'writeOnly', 'label', 'placeholder',
1278
+ 'hint', 'hidden', 'accept', 'minItems', 'maxItems'];
1279
+ const unknownTagRegex = /\{@([\w-]+)\s*(?:\s+[^}]*)?\}/g;
1280
+ let unknownMatch;
1281
+ while ((unknownMatch = unknownTagRegex.exec(description)) !== null) {
1282
+ const tagName = unknownMatch[1];
1283
+ if (!allKnownTags.includes(tagName)) {
1284
+ console.warn(`unknown constraint/hint: @${tagName}. ` +
1285
+ `Valid hints: ${allKnownTags.slice(0, 8).join(', ')}, etc. This tag will be ignored.`);
1286
+ }
1287
+ }
1162
1288
  if (Object.keys(paramConstraints).length > 0) {
1163
1289
  constraints.set(paramName, paramConstraints);
1164
1290
  }
@@ -1172,9 +1298,45 @@ export class SchemaExtractor {
1172
1298
  * Works with both simple types and anyOf schemas
1173
1299
  */
1174
1300
  applyConstraints(schema, constraints) {
1301
+ // Validate constraint values before applying (fail-safe)
1302
+ if (constraints.min !== undefined && constraints.max !== undefined) {
1303
+ if (constraints.min > constraints.max) {
1304
+ console.warn(`Invalid constraint: @min (${constraints.min}) > @max (${constraints.max}). ` +
1305
+ `Using only @min. Remove @max or use a larger value.`);
1306
+ delete constraints.max;
1307
+ }
1308
+ }
1175
1309
  // Helper to apply constraints to a single schema based on type
1176
1310
  const applyToSchema = (s) => {
1311
+ // Validate constraint-type compatibility
1312
+ if (!s.enum) {
1313
+ // Warn for incompatible constraints
1314
+ if ((constraints.min !== undefined || constraints.max !== undefined) &&
1315
+ s.type !== 'number' && s.type !== 'string' && s.type !== 'array') {
1316
+ const constraintType = constraints.min !== undefined ? '@min' : '@max';
1317
+ console.warn(`Constraint ${constraintType} not applicable to type "${s.type || 'unknown'}". ` +
1318
+ `This constraint applies to number, string, or array types only.`);
1319
+ }
1320
+ if (constraints.pattern !== undefined && s.type !== 'string') {
1321
+ console.warn(`Constraint @pattern not applicable to type "${s.type || 'unknown'}". ` +
1322
+ `Pattern only applies to string types.`);
1323
+ }
1324
+ if ((constraints.minItems !== undefined || constraints.maxItems !== undefined) && s.type !== 'array') {
1325
+ const constraintType = constraints.minItems !== undefined ? '@minItems' : '@maxItems';
1326
+ console.warn(`Constraint ${constraintType} not applicable to type "${s.type || 'unknown'}". ` +
1327
+ `This constraint applies to array types only.`);
1328
+ }
1329
+ if (constraints.multipleOf !== undefined && s.type !== 'number') {
1330
+ console.warn(`Constraint @multipleOf not applicable to type "${s.type || 'unknown'}". ` +
1331
+ `This constraint applies to number types only.`);
1332
+ }
1333
+ }
1177
1334
  if (s.enum) {
1335
+ // Check for pattern+enum conflict before returning
1336
+ if (constraints.pattern !== undefined) {
1337
+ console.warn(`Conflicting constraints: @pattern cannot be used with enum/choices. ` +
1338
+ `Pattern is ignored when specific values are defined via enum or @choice.`);
1339
+ }
1178
1340
  // Skip enum types for most constraints (but still apply deprecated, examples, etc.)
1179
1341
  if (constraints.examples !== undefined) {
1180
1342
  s.examples = constraints.examples;
@@ -1184,7 +1346,7 @@ export class SchemaExtractor {
1184
1346
  }
1185
1347
  return;
1186
1348
  }
1187
- // Apply min/max based on type
1349
+ // Apply min/max based on type (skip if type is incompatible)
1188
1350
  if (s.type === 'number') {
1189
1351
  if (constraints.min !== undefined) {
1190
1352
  s.minimum = constraints.min;
@@ -1193,7 +1355,14 @@ export class SchemaExtractor {
1193
1355
  s.maximum = constraints.max;
1194
1356
  }
1195
1357
  if (constraints.multipleOf !== undefined) {
1196
- s.multipleOf = constraints.multipleOf;
1358
+ // Validate multipleOf is positive (JSON schema requires > 0)
1359
+ if (constraints.multipleOf <= 0) {
1360
+ console.warn(`Invalid @multipleOf value: ${constraints.multipleOf}. ` +
1361
+ `Must be positive and non-zero. Constraint not applied.`);
1362
+ }
1363
+ else {
1364
+ s.multipleOf = constraints.multipleOf;
1365
+ }
1197
1366
  }
1198
1367
  }
1199
1368
  else if (s.type === 'string') {
@@ -1204,7 +1373,15 @@ export class SchemaExtractor {
1204
1373
  s.maxLength = constraints.max;
1205
1374
  }
1206
1375
  if (constraints.pattern !== undefined) {
1207
- s.pattern = constraints.pattern;
1376
+ // Validate pattern is valid regex before applying (fail-safe)
1377
+ try {
1378
+ new RegExp(constraints.pattern);
1379
+ s.pattern = constraints.pattern;
1380
+ }
1381
+ catch (e) {
1382
+ console.warn(`Invalid regex in @pattern constraint: "${constraints.pattern}". ` +
1383
+ `${e instanceof Error ? e.message : String(e)}. Pattern not applied.`);
1384
+ }
1208
1385
  }
1209
1386
  }
1210
1387
  else if (s.type === 'array') {
@@ -1214,10 +1391,28 @@ export class SchemaExtractor {
1214
1391
  if (constraints.max !== undefined) {
1215
1392
  s.maxItems = constraints.max;
1216
1393
  }
1394
+ if (constraints.minItems !== undefined) {
1395
+ s.minItems = constraints.minItems;
1396
+ }
1397
+ if (constraints.maxItems !== undefined) {
1398
+ s.maxItems = constraints.maxItems;
1399
+ }
1217
1400
  if (constraints.unique === true) {
1218
1401
  s.uniqueItems = true;
1219
1402
  }
1220
1403
  }
1404
+ else if (s.type && s.type !== 'boolean' && s.type !== 'object') {
1405
+ // For other compatible types, apply min/max as needed
1406
+ if (['integer', 'number'].includes(s.type)) {
1407
+ if (constraints.min !== undefined) {
1408
+ s.minimum = constraints.min;
1409
+ }
1410
+ if (constraints.max !== undefined) {
1411
+ s.maximum = constraints.max;
1412
+ }
1413
+ }
1414
+ }
1415
+ // Note: For boolean and object types, we skip min/max constraints (already warned above)
1221
1416
  // Apply type-agnostic constraints
1222
1417
  if (constraints.format !== undefined) {
1223
1418
  s.format = constraints.format;
@@ -1232,12 +1427,27 @@ export class SchemaExtractor {
1232
1427
  s.deprecated = constraints.deprecated === true ? true : constraints.deprecated;
1233
1428
  }
1234
1429
  // Apply enum from @choice tag (overrides TypeScript-derived enum if present)
1235
- if (constraints.enum !== undefined && !s.enum) {
1236
- s.enum = constraints.enum;
1430
+ if (constraints.enum !== undefined) {
1431
+ // Check for pattern+enum conflict when @choice is being applied
1432
+ if (constraints.pattern !== undefined && s.type === 'string') {
1433
+ console.warn(`Conflicting constraints: @pattern cannot be used with enum/choices. ` +
1434
+ `Pattern is ignored when specific values are defined via enum or @choice.`);
1435
+ }
1436
+ if (!s.enum) {
1437
+ s.enum = constraints.enum;
1438
+ }
1237
1439
  }
1238
1440
  // Apply field hint for UI rendering
1239
1441
  if (constraints.field !== undefined) {
1240
1442
  s.field = constraints.field;
1443
+ // Validate @field integer constraint with default values
1444
+ if (constraints.field === 'integer' && s.default !== undefined && typeof s.default === 'number') {
1445
+ if (!Number.isInteger(s.default)) {
1446
+ console.warn(`Default value violates @field integer constraint: ` +
1447
+ `expected integer but got ${s.default}. ` +
1448
+ `Consider using an integer default value.`);
1449
+ }
1450
+ }
1241
1451
  }
1242
1452
  // Apply custom label for form fields
1243
1453
  if (constraints.label !== undefined) {
@@ -1471,11 +1681,22 @@ export class SchemaExtractor {
1471
1681
  * - @retryable 3 2s → 3 retries, 2s delay
1472
1682
  */
1473
1683
  extractRetryable(jsdocContent) {
1474
- const match = jsdocContent.match(/@retryable(?:\s+(\d+))?(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1684
+ const match = jsdocContent.match(/@retryable(?:\s+([-\d]+))?(?:\s+([-\d]+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1475
1685
  if (!match)
1476
1686
  return undefined;
1477
- const count = match[1] ? parseInt(match[1], 10) : 3;
1478
- const delay = match[2] ? parseDuration(match[2]) : 1_000;
1687
+ let count = match[1] ? parseInt(match[1], 10) : 3;
1688
+ let delay = match[2] ? parseDuration(match[2]) : 1_000;
1689
+ // Validate retryable configuration
1690
+ if (count <= 0) {
1691
+ console.warn(`Invalid @retryable count: ${count}. ` +
1692
+ `Count must be positive (> 0). Using default count 3.`);
1693
+ count = 3;
1694
+ }
1695
+ if (delay <= 0) {
1696
+ console.warn(`Invalid @retryable delay: ${delay}ms. ` +
1697
+ `Delay must be positive (> 0). Using default delay 1000ms.`);
1698
+ delay = 1_000;
1699
+ }
1479
1700
  return { count, delay };
1480
1701
  }
1481
1702
  /**
@@ -1484,10 +1705,34 @@ export class SchemaExtractor {
1484
1705
  * - @throttled 100/h → 100 calls per hour
1485
1706
  */
1486
1707
  extractThrottled(jsdocContent) {
1708
+ // First check if @throttled is present at all
1709
+ const hasThrottled = /@throttled\b/i.test(jsdocContent);
1487
1710
  const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
1488
- if (!match)
1711
+ if (!match) {
1712
+ if (hasThrottled) {
1713
+ // @throttled is present but format is invalid
1714
+ const invalidMatch = jsdocContent.match(/@throttled\s+([^\s]+)/i);
1715
+ if (invalidMatch) {
1716
+ console.warn(`Invalid @throttled rate format: "${invalidMatch[1]}". ` +
1717
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`);
1718
+ }
1719
+ }
1489
1720
  return undefined;
1490
- return parseRate(match[1]);
1721
+ }
1722
+ const rateStr = match[1];
1723
+ const rate = parseRate(rateStr);
1724
+ // Validate throttled configuration
1725
+ if (!rate) {
1726
+ console.warn(`Invalid @throttled rate format: "${rateStr}". ` +
1727
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`);
1728
+ return undefined;
1729
+ }
1730
+ if (rate.count <= 0) {
1731
+ console.warn(`Invalid @throttled count: ${rate.count}. ` +
1732
+ `Count must be positive (> 0). Rate not applied.`);
1733
+ return undefined;
1734
+ }
1735
+ return rate;
1491
1736
  }
1492
1737
  /**
1493
1738
  * Extract debounce configuration from @debounced tag
@@ -1519,13 +1764,20 @@ export class SchemaExtractor {
1519
1764
  * - @validate params.email must be a valid email
1520
1765
  * - @validate params.amount must be positive
1521
1766
  */
1522
- extractValidations(jsdocContent) {
1767
+ extractValidations(jsdocContent, validParamNames) {
1523
1768
  const validations = [];
1524
1769
  const regex = /@validate\s+([\w.]+)\s+(.+)/g;
1525
1770
  let match;
1526
1771
  while ((match = regex.exec(jsdocContent)) !== null) {
1772
+ const fieldName = match[1].replace(/^params\./, ''); // strip params. prefix
1773
+ // Fail-safe: Validate that referenced field exists (if validParamNames provided)
1774
+ if (validParamNames && !validParamNames.includes(fieldName)) {
1775
+ console.warn(`@validate references non-existent parameter "${fieldName}". ` +
1776
+ `Available parameters: ${validParamNames.join(', ')}. Validation rule ignored.`);
1777
+ continue;
1778
+ }
1527
1779
  validations.push({
1528
- field: match[1].replace(/^params\./, ''), // strip params. prefix
1780
+ field: fieldName,
1529
1781
  rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
1530
1782
  });
1531
1783
  }