@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.
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +26 -2
- 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 +13 -1
- package/dist/schema-extractor.d.ts.map +1 -1
- package/dist/schema-extractor.js +302 -18
- package/dist/schema-extractor.js.map +1 -1
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +6 -4
- package/src/compiler.ts +29 -2
- package/src/index.ts +3 -0
- package/src/mixins.ts +187 -0
- package/src/schema-extractor.ts +382 -19
- package/src/types.ts +48 -0
package/dist/schema-extractor.js
CHANGED
|
@@ -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
|
-
//
|
|
751
|
-
|
|
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
|
-
|
|
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} -
|
|
1097
|
-
// They are mutually exclusive, so
|
|
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
|
-
|
|
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
|
-
|
|
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+(
|
|
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
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
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:
|
|
1767
|
+
field: fieldName,
|
|
1484
1768
|
rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
|
|
1485
1769
|
});
|
|
1486
1770
|
}
|