@portel/photon-core 2.9.3 → 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.
@@ -43,6 +43,8 @@ export class SchemaExtractor {
43
43
  hasGetConfigMethod: false,
44
44
  params: [],
45
45
  };
46
+ // Notification subscriptions tracking (from @notify-on tag)
47
+ let notificationSubscriptions;
46
48
  try {
47
49
  // If source doesn't contain a class declaration, wrap it in one
48
50
  let sourceToParse = source;
@@ -52,7 +54,7 @@ export class SchemaExtractor {
52
54
  // Parse source file into AST
53
55
  const sourceFile = ts.createSourceFile('temp.ts', sourceToParse, ts.ScriptTarget.Latest, true);
54
56
  // Helper to process a method declaration
55
- const processMethod = (member) => {
57
+ const processMethod = (member, isStatefulClass = false) => {
56
58
  const methodName = member.name.getText(sourceFile);
57
59
  // Skip private methods (prefixed with _)
58
60
  if (methodName.startsWith('_')) {
@@ -101,10 +103,14 @@ export class SchemaExtractor {
101
103
  // Extract descriptions from JSDoc
102
104
  const paramDocs = this.extractParamDocs(jsdoc);
103
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();
104
109
  // Merge descriptions and constraints into properties
105
110
  Object.keys(properties).forEach(key => {
106
111
  if (paramDocs.has(key)) {
107
112
  properties[key].description = paramDocs.get(key);
113
+ matchedDocs.add(key);
108
114
  }
109
115
  // Apply TypeScript-extracted metadata (before JSDoc, so JSDoc can override)
110
116
  if (properties[key]._tsReadOnly) {
@@ -115,8 +121,44 @@ export class SchemaExtractor {
115
121
  if (paramConstraints.has(key)) {
116
122
  const constraints = paramConstraints.get(key);
117
123
  this.applyConstraints(properties[key], constraints);
124
+ matchedConstraints.add(key);
118
125
  }
119
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
+ }
120
162
  const description = this.extractDescription(jsdoc);
121
163
  const inputSchema = {
122
164
  type: 'object',
@@ -167,12 +209,24 @@ export class SchemaExtractor {
167
209
  const throttled = this.extractThrottled(jsdoc);
168
210
  const debounced = this.extractDebounced(jsdoc);
169
211
  const queued = this.extractQueued(jsdoc);
170
- const validations = this.extractValidations(jsdoc);
212
+ const validations = this.extractValidations(jsdoc, Object.keys(properties));
171
213
  const deprecated = this.extractDeprecated(jsdoc);
172
214
  // Build unified middleware declarations (single source of truth for runtime)
173
215
  const middleware = this.buildMiddlewareDeclarations(jsdoc);
174
216
  // Check for static keyword on the method
175
217
  const isStaticMethod = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) || false;
218
+ // Event emission for @stateful classes: all public methods emit events automatically
219
+ const emitsEventData = isStatefulClass ? {
220
+ emitsEvent: true,
221
+ eventName: methodName,
222
+ eventPayload: {
223
+ method: 'string',
224
+ params: 'object',
225
+ result: 'any',
226
+ timestamp: 'string',
227
+ instance: 'string',
228
+ },
229
+ } : {};
176
230
  tools.push({
177
231
  name: methodName,
178
232
  description,
@@ -207,6 +261,8 @@ export class SchemaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecated } : {}),
208
262
  // Unified middleware declarations (new — runtime uses this)
209
263
  ...(middleware.length > 0 ? { middleware } : {}),
264
+ // Event emission (for @stateful classes)
265
+ ...emitsEventData,
210
266
  });
211
267
  }
212
268
  };
@@ -311,6 +367,11 @@ export class SchemaExtractor {
311
367
  const visit = (node) => {
312
368
  // Look for class declarations
313
369
  if (ts.isClassDeclaration(node)) {
370
+ // Check if this class has @stateful decorator
371
+ const classJsdoc = this.getJSDocComment(node, sourceFile);
372
+ const isStatefulClass = /@stateful\b/i.test(classJsdoc);
373
+ // Extract notification subscriptions from @notify-on tag
374
+ notificationSubscriptions = this.extractNotifyOn(classJsdoc);
314
375
  node.members.forEach((member) => {
315
376
  // Detect `protected settings = { ... }` property
316
377
  if (ts.isPropertyDeclaration(member)) {
@@ -321,7 +382,7 @@ export class SchemaExtractor {
321
382
  if (ts.isMethodDeclaration(member)) {
322
383
  const isPrivate = member.modifiers?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword);
323
384
  if (!isPrivate) {
324
- processMethod(member);
385
+ processMethod(member, isStatefulClass);
325
386
  }
326
387
  }
327
388
  });
@@ -342,6 +403,10 @@ export class SchemaExtractor {
342
403
  if (configSchema.hasConfigureMethod) {
343
404
  result.configSchema = configSchema;
344
405
  }
406
+ // Include notification subscriptions if detected
407
+ if (notificationSubscriptions) {
408
+ result.notificationSubscriptions = notificationSubscriptions;
409
+ }
345
410
  return result;
346
411
  }
347
412
  /**
@@ -425,6 +490,21 @@ export class SchemaExtractor {
425
490
  else {
426
491
  properties[paramName] = { type: 'string' };
427
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
+ }
428
508
  }
429
509
  return { properties, required, simpleParams: true };
430
510
  }
@@ -731,6 +811,28 @@ export class SchemaExtractor {
731
811
  /**
732
812
  * Extract default value from initializer
733
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
+ }
734
836
  extractDefaultValue(initializer, sourceFile) {
735
837
  // String literals
736
838
  if (ts.isStringLiteral(initializer)) {
@@ -747,8 +849,20 @@ export class SchemaExtractor {
747
849
  if (initializer.kind === ts.SyntaxKind.FalseKeyword) {
748
850
  return false;
749
851
  }
750
- // For complex expressions (function calls, etc.), return as string
751
- 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;
752
866
  }
753
867
  // ═══════════════════════════════════════════════════════════════════════════════
754
868
  // INLINE CONFIG + @use PARSING
@@ -1036,7 +1150,16 @@ export class SchemaExtractor {
1036
1150
  // Extract {@format formatName} - use lookahead to match until tag-closing }
1037
1151
  const formatMatch = description.match(/\{@format\s+(.+?)\}(?=\s|$|{@)/);
1038
1152
  if (formatMatch) {
1039
- 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
+ }
1040
1163
  }
1041
1164
  // Extract {@choice value1,value2,...} - converts to enum in schema
1042
1165
  const choiceMatch = description.match(/\{@choice\s+([^}]+)\}/);
@@ -1062,6 +1185,16 @@ export class SchemaExtractor {
1062
1185
  paramConstraints.default = defaultValue;
1063
1186
  }
1064
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
+ }
1065
1198
  // Extract {@unique} or {@uniqueItems} - for arrays
1066
1199
  if (description.match(/\{@unique(?:Items)?\s*\}/)) {
1067
1200
  paramConstraints.unique = true;
@@ -1093,10 +1226,15 @@ export class SchemaExtractor {
1093
1226
  if (deprecatedMatch) {
1094
1227
  paramConstraints.deprecated = deprecatedMatch[1]?.trim() || true;
1095
1228
  }
1096
- // Extract {@readOnly} and {@writeOnly} - track which comes last
1097
- // 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
1098
1231
  const readOnlyMatch = description.match(/\{@readOnly\s*\}/);
1099
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
+ }
1100
1238
  if (readOnlyMatch || writeOnlyMatch) {
1101
1239
  // Find positions to determine which comes last
1102
1240
  const readOnlyPos = readOnlyMatch ? description.indexOf(readOnlyMatch[0]) : -1;
@@ -1134,6 +1272,19 @@ export class SchemaExtractor {
1134
1272
  if (acceptMatch) {
1135
1273
  paramConstraints.accept = acceptMatch[1].trim();
1136
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
+ }
1137
1288
  if (Object.keys(paramConstraints).length > 0) {
1138
1289
  constraints.set(paramName, paramConstraints);
1139
1290
  }
@@ -1147,8 +1298,39 @@ export class SchemaExtractor {
1147
1298
  * Works with both simple types and anyOf schemas
1148
1299
  */
1149
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
+ }
1150
1309
  // Helper to apply constraints to a single schema based on type
1151
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
+ }
1152
1334
  if (s.enum) {
1153
1335
  // Skip enum types for most constraints (but still apply deprecated, examples, etc.)
1154
1336
  if (constraints.examples !== undefined) {
@@ -1159,7 +1341,7 @@ export class SchemaExtractor {
1159
1341
  }
1160
1342
  return;
1161
1343
  }
1162
- // Apply min/max based on type
1344
+ // Apply min/max based on type (skip if type is incompatible)
1163
1345
  if (s.type === 'number') {
1164
1346
  if (constraints.min !== undefined) {
1165
1347
  s.minimum = constraints.min;
@@ -1168,7 +1350,14 @@ export class SchemaExtractor {
1168
1350
  s.maximum = constraints.max;
1169
1351
  }
1170
1352
  if (constraints.multipleOf !== undefined) {
1171
- s.multipleOf = constraints.multipleOf;
1353
+ // Validate multipleOf is positive (JSON schema requires > 0)
1354
+ if (constraints.multipleOf <= 0) {
1355
+ console.warn(`Invalid @multipleOf value: ${constraints.multipleOf}. ` +
1356
+ `Must be positive and non-zero. Constraint not applied.`);
1357
+ }
1358
+ else {
1359
+ s.multipleOf = constraints.multipleOf;
1360
+ }
1172
1361
  }
1173
1362
  }
1174
1363
  else if (s.type === 'string') {
@@ -1179,7 +1368,22 @@ export class SchemaExtractor {
1179
1368
  s.maxLength = constraints.max;
1180
1369
  }
1181
1370
  if (constraints.pattern !== undefined) {
1182
- s.pattern = constraints.pattern;
1371
+ // Check for pattern+enum conflict
1372
+ if (s.enum || constraints.enum) {
1373
+ console.warn(`Conflicting constraints: @pattern cannot be used with enum/choices. ` +
1374
+ `Pattern is ignored when specific values are defined via enum or @choice.`);
1375
+ }
1376
+ else {
1377
+ // Validate pattern is valid regex before applying (fail-safe)
1378
+ try {
1379
+ new RegExp(constraints.pattern);
1380
+ s.pattern = constraints.pattern;
1381
+ }
1382
+ catch (e) {
1383
+ console.warn(`Invalid regex in @pattern constraint: "${constraints.pattern}". ` +
1384
+ `${e instanceof Error ? e.message : String(e)}. Pattern not applied.`);
1385
+ }
1386
+ }
1183
1387
  }
1184
1388
  }
1185
1389
  else if (s.type === 'array') {
@@ -1189,10 +1393,28 @@ export class SchemaExtractor {
1189
1393
  if (constraints.max !== undefined) {
1190
1394
  s.maxItems = constraints.max;
1191
1395
  }
1396
+ if (constraints.minItems !== undefined) {
1397
+ s.minItems = constraints.minItems;
1398
+ }
1399
+ if (constraints.maxItems !== undefined) {
1400
+ s.maxItems = constraints.maxItems;
1401
+ }
1192
1402
  if (constraints.unique === true) {
1193
1403
  s.uniqueItems = true;
1194
1404
  }
1195
1405
  }
1406
+ else if (s.type && s.type !== 'boolean' && s.type !== 'object') {
1407
+ // For other compatible types, apply min/max as needed
1408
+ if (['integer', 'number'].includes(s.type)) {
1409
+ if (constraints.min !== undefined) {
1410
+ s.minimum = constraints.min;
1411
+ }
1412
+ if (constraints.max !== undefined) {
1413
+ s.maximum = constraints.max;
1414
+ }
1415
+ }
1416
+ }
1417
+ // Note: For boolean and object types, we skip min/max constraints (already warned above)
1196
1418
  // Apply type-agnostic constraints
1197
1419
  if (constraints.format !== undefined) {
1198
1420
  s.format = constraints.format;
@@ -1294,6 +1516,26 @@ export class SchemaExtractor {
1294
1516
  hasAsyncTag(jsdocContent) {
1295
1517
  return /@async\b/i.test(jsdocContent);
1296
1518
  }
1519
+ /**
1520
+ * Extract notification subscriptions from @notify-on tag
1521
+ * Specifies which event types this photon is interested in
1522
+ * Format: @notify-on mentions, deadlines, errors
1523
+ */
1524
+ extractNotifyOn(jsdocContent) {
1525
+ const notifyMatch = jsdocContent.match(/@notify-on\s+([^\n]+)/i);
1526
+ if (!notifyMatch) {
1527
+ return undefined;
1528
+ }
1529
+ // Parse comma-separated event types
1530
+ const watchFor = notifyMatch[1]
1531
+ .split(',')
1532
+ .map(s => s.trim())
1533
+ .filter(s => s.length > 0);
1534
+ if (watchFor.length === 0) {
1535
+ return undefined;
1536
+ }
1537
+ return { watchFor };
1538
+ }
1297
1539
  // ═══════════════════════════════════════════════════════════════════════════════
1298
1540
  // DAEMON FEATURE EXTRACTION
1299
1541
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1426,11 +1668,22 @@ export class SchemaExtractor {
1426
1668
  * - @retryable 3 2s → 3 retries, 2s delay
1427
1669
  */
1428
1670
  extractRetryable(jsdocContent) {
1429
- const match = jsdocContent.match(/@retryable(?:\s+(\d+))?(?:\s+(\d+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1671
+ const match = jsdocContent.match(/@retryable(?:\s+([-\d]+))?(?:\s+([-\d]+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
1430
1672
  if (!match)
1431
1673
  return undefined;
1432
- const count = match[1] ? parseInt(match[1], 10) : 3;
1433
- const delay = match[2] ? parseDuration(match[2]) : 1_000;
1674
+ let count = match[1] ? parseInt(match[1], 10) : 3;
1675
+ let delay = match[2] ? parseDuration(match[2]) : 1_000;
1676
+ // Validate retryable configuration
1677
+ if (count <= 0) {
1678
+ console.warn(`Invalid @retryable count: ${count}. ` +
1679
+ `Count must be positive (> 0). Using default count 3.`);
1680
+ count = 3;
1681
+ }
1682
+ if (delay <= 0) {
1683
+ console.warn(`Invalid @retryable delay: ${delay}ms. ` +
1684
+ `Delay must be positive (> 0). Using default delay 1000ms.`);
1685
+ delay = 1_000;
1686
+ }
1434
1687
  return { count, delay };
1435
1688
  }
1436
1689
  /**
@@ -1439,10 +1692,34 @@ export class SchemaExtractor {
1439
1692
  * - @throttled 100/h → 100 calls per hour
1440
1693
  */
1441
1694
  extractThrottled(jsdocContent) {
1695
+ // First check if @throttled is present at all
1696
+ const hasThrottled = /@throttled\b/i.test(jsdocContent);
1442
1697
  const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
1443
- if (!match)
1698
+ if (!match) {
1699
+ if (hasThrottled) {
1700
+ // @throttled is present but format is invalid
1701
+ const invalidMatch = jsdocContent.match(/@throttled\s+([^\s]+)/i);
1702
+ if (invalidMatch) {
1703
+ console.warn(`Invalid @throttled rate format: "${invalidMatch[1]}". ` +
1704
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`);
1705
+ }
1706
+ }
1707
+ return undefined;
1708
+ }
1709
+ const rateStr = match[1];
1710
+ const rate = parseRate(rateStr);
1711
+ // Validate throttled configuration
1712
+ if (!rate) {
1713
+ console.warn(`Invalid @throttled rate format: "${rateStr}". ` +
1714
+ `Expected format: count/unit (e.g., 10/min, 100/h). Rate not applied.`);
1715
+ return undefined;
1716
+ }
1717
+ if (rate.count <= 0) {
1718
+ console.warn(`Invalid @throttled count: ${rate.count}. ` +
1719
+ `Count must be positive (> 0). Rate not applied.`);
1444
1720
  return undefined;
1445
- return parseRate(match[1]);
1721
+ }
1722
+ return rate;
1446
1723
  }
1447
1724
  /**
1448
1725
  * Extract debounce configuration from @debounced tag
@@ -1474,13 +1751,20 @@ export class SchemaExtractor {
1474
1751
  * - @validate params.email must be a valid email
1475
1752
  * - @validate params.amount must be positive
1476
1753
  */
1477
- extractValidations(jsdocContent) {
1754
+ extractValidations(jsdocContent, validParamNames) {
1478
1755
  const validations = [];
1479
1756
  const regex = /@validate\s+([\w.]+)\s+(.+)/g;
1480
1757
  let match;
1481
1758
  while ((match = regex.exec(jsdocContent)) !== null) {
1759
+ const fieldName = match[1].replace(/^params\./, ''); // strip params. prefix
1760
+ // Fail-safe: Validate that referenced field exists (if validParamNames provided)
1761
+ if (validParamNames && !validParamNames.includes(fieldName)) {
1762
+ console.warn(`@validate references non-existent parameter "${fieldName}". ` +
1763
+ `Available parameters: ${validParamNames.join(', ')}. Validation rule ignored.`);
1764
+ continue;
1765
+ }
1482
1766
  validations.push({
1483
- field: match[1].replace(/^params\./, ''), // strip params. prefix
1767
+ field: fieldName,
1484
1768
  rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
1485
1769
  });
1486
1770
  }