@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/src/schema-extractor.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import * as fs from 'fs/promises';
|
|
12
12
|
import * as ts from 'typescript';
|
|
13
|
-
import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam, SettingsSchema, SettingsProperty } from './types.js';
|
|
13
|
+
import { ExtractedSchema, ConstructorParam, TemplateInfo, StaticInfo, OutputFormat, YieldInfo, MCPDependency, PhotonDependency, CLIDependency, ResolvedInjection, PhotonAssets, UIAsset, PromptAsset, ResourceAsset, ConfigSchema, ConfigParam, SettingsSchema, SettingsProperty, NotificationSubscription } from './types.js';
|
|
14
14
|
import { parseDuration, parseRate } from './utils/duration.js';
|
|
15
15
|
import { builtinRegistry, type MiddlewareDeclaration } from './middleware.js';
|
|
16
16
|
|
|
@@ -22,6 +22,8 @@ export interface ExtractedMetadata {
|
|
|
22
22
|
settingsSchema?: SettingsSchema;
|
|
23
23
|
/** @deprecated Configuration schema from configure() method */
|
|
24
24
|
configSchema?: ConfigSchema;
|
|
25
|
+
/** Notification subscription from @notify-on tag */
|
|
26
|
+
notificationSubscriptions?: NotificationSubscription;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -59,6 +61,9 @@ export class SchemaExtractor {
|
|
|
59
61
|
params: [],
|
|
60
62
|
};
|
|
61
63
|
|
|
64
|
+
// Notification subscriptions tracking (from @notify-on tag)
|
|
65
|
+
let notificationSubscriptions: NotificationSubscription | undefined;
|
|
66
|
+
|
|
62
67
|
try {
|
|
63
68
|
// If source doesn't contain a class declaration, wrap it in one
|
|
64
69
|
let sourceToParse = source;
|
|
@@ -75,7 +80,7 @@ export class SchemaExtractor {
|
|
|
75
80
|
);
|
|
76
81
|
|
|
77
82
|
// Helper to process a method declaration
|
|
78
|
-
const processMethod = (member: ts.MethodDeclaration) => {
|
|
83
|
+
const processMethod = (member: ts.MethodDeclaration, isStatefulClass: boolean = false) => {
|
|
79
84
|
const methodName = member.name.getText(sourceFile);
|
|
80
85
|
|
|
81
86
|
// Skip private methods (prefixed with _)
|
|
@@ -135,10 +140,15 @@ export class SchemaExtractor {
|
|
|
135
140
|
const paramDocs = this.extractParamDocs(jsdoc);
|
|
136
141
|
const paramConstraints = this.extractParamConstraints(jsdoc);
|
|
137
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
|
+
|
|
138
147
|
// Merge descriptions and constraints into properties
|
|
139
148
|
Object.keys(properties).forEach(key => {
|
|
140
149
|
if (paramDocs.has(key)) {
|
|
141
150
|
properties[key].description = paramDocs.get(key);
|
|
151
|
+
matchedDocs.add(key);
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
// Apply TypeScript-extracted metadata (before JSDoc, so JSDoc can override)
|
|
@@ -151,9 +161,54 @@ export class SchemaExtractor {
|
|
|
151
161
|
if (paramConstraints.has(key)) {
|
|
152
162
|
const constraints = paramConstraints.get(key)!;
|
|
153
163
|
this.applyConstraints(properties[key], constraints);
|
|
164
|
+
matchedConstraints.add(key);
|
|
154
165
|
}
|
|
155
166
|
});
|
|
156
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
|
+
|
|
157
212
|
const description = this.extractDescription(jsdoc);
|
|
158
213
|
const inputSchema = {
|
|
159
214
|
type: 'object' as const,
|
|
@@ -208,7 +263,7 @@ export class SchemaExtractor {
|
|
|
208
263
|
const throttled = this.extractThrottled(jsdoc);
|
|
209
264
|
const debounced = this.extractDebounced(jsdoc);
|
|
210
265
|
const queued = this.extractQueued(jsdoc);
|
|
211
|
-
const validations = this.extractValidations(jsdoc);
|
|
266
|
+
const validations = this.extractValidations(jsdoc, Object.keys(properties));
|
|
212
267
|
const deprecated = this.extractDeprecated(jsdoc);
|
|
213
268
|
|
|
214
269
|
// Build unified middleware declarations (single source of truth for runtime)
|
|
@@ -217,6 +272,19 @@ export class SchemaExtractor {
|
|
|
217
272
|
// Check for static keyword on the method
|
|
218
273
|
const isStaticMethod = member.modifiers?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) || false;
|
|
219
274
|
|
|
275
|
+
// Event emission for @stateful classes: all public methods emit events automatically
|
|
276
|
+
const emitsEventData = isStatefulClass ? {
|
|
277
|
+
emitsEvent: true,
|
|
278
|
+
eventName: methodName,
|
|
279
|
+
eventPayload: {
|
|
280
|
+
method: 'string',
|
|
281
|
+
params: 'object',
|
|
282
|
+
result: 'any',
|
|
283
|
+
timestamp: 'string',
|
|
284
|
+
instance: 'string',
|
|
285
|
+
},
|
|
286
|
+
} : {};
|
|
287
|
+
|
|
220
288
|
tools.push({
|
|
221
289
|
name: methodName,
|
|
222
290
|
description,
|
|
@@ -251,6 +319,8 @@ export class SchemaExtractor {
|
|
|
251
319
|
...(deprecated !== undefined ? { deprecated } : {}),
|
|
252
320
|
// Unified middleware declarations (new — runtime uses this)
|
|
253
321
|
...(middleware.length > 0 ? { middleware } : {}),
|
|
322
|
+
// Event emission (for @stateful classes)
|
|
323
|
+
...emitsEventData,
|
|
254
324
|
});
|
|
255
325
|
}
|
|
256
326
|
};
|
|
@@ -353,6 +423,13 @@ export class SchemaExtractor {
|
|
|
353
423
|
const visit = (node: ts.Node) => {
|
|
354
424
|
// Look for class declarations
|
|
355
425
|
if (ts.isClassDeclaration(node)) {
|
|
426
|
+
// Check if this class has @stateful decorator
|
|
427
|
+
const classJsdoc = this.getJSDocComment(node as any, sourceFile);
|
|
428
|
+
const isStatefulClass = /@stateful\b/i.test(classJsdoc);
|
|
429
|
+
|
|
430
|
+
// Extract notification subscriptions from @notify-on tag
|
|
431
|
+
notificationSubscriptions = this.extractNotifyOn(classJsdoc);
|
|
432
|
+
|
|
356
433
|
node.members.forEach((member) => {
|
|
357
434
|
// Detect `protected settings = { ... }` property
|
|
358
435
|
if (ts.isPropertyDeclaration(member)) {
|
|
@@ -366,7 +443,7 @@ export class SchemaExtractor {
|
|
|
366
443
|
m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword
|
|
367
444
|
);
|
|
368
445
|
if (!isPrivate) {
|
|
369
|
-
processMethod(member);
|
|
446
|
+
processMethod(member, isStatefulClass);
|
|
370
447
|
}
|
|
371
448
|
}
|
|
372
449
|
});
|
|
@@ -392,6 +469,11 @@ export class SchemaExtractor {
|
|
|
392
469
|
result.configSchema = configSchema;
|
|
393
470
|
}
|
|
394
471
|
|
|
472
|
+
// Include notification subscriptions if detected
|
|
473
|
+
if (notificationSubscriptions) {
|
|
474
|
+
result.notificationSubscriptions = notificationSubscriptions;
|
|
475
|
+
}
|
|
476
|
+
|
|
395
477
|
return result;
|
|
396
478
|
}
|
|
397
479
|
|
|
@@ -488,6 +570,24 @@ export class SchemaExtractor {
|
|
|
488
570
|
} else {
|
|
489
571
|
properties[paramName] = { type: 'string' };
|
|
490
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
|
+
}
|
|
491
591
|
}
|
|
492
592
|
|
|
493
593
|
return { properties, required, simpleParams: true };
|
|
@@ -842,6 +942,30 @@ export class SchemaExtractor {
|
|
|
842
942
|
/**
|
|
843
943
|
* Extract default value from initializer
|
|
844
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
|
+
|
|
845
969
|
private extractDefaultValue(initializer: ts.Expression, sourceFile: ts.SourceFile): any {
|
|
846
970
|
// String literals
|
|
847
971
|
if (ts.isStringLiteral(initializer)) {
|
|
@@ -861,8 +985,25 @@ export class SchemaExtractor {
|
|
|
861
985
|
return false;
|
|
862
986
|
}
|
|
863
987
|
|
|
864
|
-
//
|
|
865
|
-
|
|
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;
|
|
866
1007
|
}
|
|
867
1008
|
|
|
868
1009
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1181,7 +1322,17 @@ export class SchemaExtractor {
|
|
|
1181
1322
|
// Extract {@format formatName} - use lookahead to match until tag-closing }
|
|
1182
1323
|
const formatMatch = description.match(/\{@format\s+(.+?)\}(?=\s|$|{@)/);
|
|
1183
1324
|
if (formatMatch) {
|
|
1184
|
-
|
|
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
|
+
}
|
|
1185
1336
|
}
|
|
1186
1337
|
|
|
1187
1338
|
// Extract {@choice value1,value2,...} - converts to enum in schema
|
|
@@ -1210,6 +1361,18 @@ export class SchemaExtractor {
|
|
|
1210
1361
|
}
|
|
1211
1362
|
}
|
|
1212
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
|
+
|
|
1213
1376
|
// Extract {@unique} or {@uniqueItems} - for arrays
|
|
1214
1377
|
if (description.match(/\{@unique(?:Items)?\s*\}/)) {
|
|
1215
1378
|
paramConstraints.unique = true;
|
|
@@ -1244,11 +1407,19 @@ export class SchemaExtractor {
|
|
|
1244
1407
|
paramConstraints.deprecated = deprecatedMatch[1]?.trim() || true;
|
|
1245
1408
|
}
|
|
1246
1409
|
|
|
1247
|
-
// Extract {@readOnly} and {@writeOnly} -
|
|
1248
|
-
// They are mutually exclusive, so
|
|
1410
|
+
// Extract {@readOnly} and {@writeOnly} - detect conflicts
|
|
1411
|
+
// They are mutually exclusive, so warn if both present
|
|
1249
1412
|
const readOnlyMatch = description.match(/\{@readOnly\s*\}/);
|
|
1250
1413
|
const writeOnlyMatch = description.match(/\{@writeOnly\s*\}/);
|
|
1251
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
|
+
|
|
1252
1423
|
if (readOnlyMatch || writeOnlyMatch) {
|
|
1253
1424
|
// Find positions to determine which comes last
|
|
1254
1425
|
const readOnlyPos = readOnlyMatch ? description.indexOf(readOnlyMatch[0]) : -1;
|
|
@@ -1292,6 +1463,22 @@ export class SchemaExtractor {
|
|
|
1292
1463
|
paramConstraints.accept = acceptMatch[1].trim();
|
|
1293
1464
|
}
|
|
1294
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
|
+
|
|
1295
1482
|
if (Object.keys(paramConstraints).length > 0) {
|
|
1296
1483
|
constraints.set(paramName, paramConstraints);
|
|
1297
1484
|
}
|
|
@@ -1307,8 +1494,54 @@ export class SchemaExtractor {
|
|
|
1307
1494
|
* Works with both simple types and anyOf schemas
|
|
1308
1495
|
*/
|
|
1309
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
|
+
|
|
1310
1508
|
// Helper to apply constraints to a single schema based on type
|
|
1311
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
|
+
|
|
1312
1545
|
if (s.enum) {
|
|
1313
1546
|
// Skip enum types for most constraints (but still apply deprecated, examples, etc.)
|
|
1314
1547
|
if (constraints.examples !== undefined) {
|
|
@@ -1320,7 +1553,7 @@ export class SchemaExtractor {
|
|
|
1320
1553
|
return;
|
|
1321
1554
|
}
|
|
1322
1555
|
|
|
1323
|
-
// Apply min/max based on type
|
|
1556
|
+
// Apply min/max based on type (skip if type is incompatible)
|
|
1324
1557
|
if (s.type === 'number') {
|
|
1325
1558
|
if (constraints.min !== undefined) {
|
|
1326
1559
|
s.minimum = constraints.min;
|
|
@@ -1329,7 +1562,15 @@ export class SchemaExtractor {
|
|
|
1329
1562
|
s.maximum = constraints.max;
|
|
1330
1563
|
}
|
|
1331
1564
|
if (constraints.multipleOf !== undefined) {
|
|
1332
|
-
|
|
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
|
+
}
|
|
1333
1574
|
}
|
|
1334
1575
|
} else if (s.type === 'string') {
|
|
1335
1576
|
if (constraints.min !== undefined) {
|
|
@@ -1339,7 +1580,24 @@ export class SchemaExtractor {
|
|
|
1339
1580
|
s.maxLength = constraints.max;
|
|
1340
1581
|
}
|
|
1341
1582
|
if (constraints.pattern !== undefined) {
|
|
1342
|
-
|
|
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
|
+
}
|
|
1343
1601
|
}
|
|
1344
1602
|
} else if (s.type === 'array') {
|
|
1345
1603
|
if (constraints.min !== undefined) {
|
|
@@ -1348,10 +1606,27 @@ export class SchemaExtractor {
|
|
|
1348
1606
|
if (constraints.max !== undefined) {
|
|
1349
1607
|
s.maxItems = constraints.max;
|
|
1350
1608
|
}
|
|
1609
|
+
if (constraints.minItems !== undefined) {
|
|
1610
|
+
s.minItems = constraints.minItems;
|
|
1611
|
+
}
|
|
1612
|
+
if (constraints.maxItems !== undefined) {
|
|
1613
|
+
s.maxItems = constraints.maxItems;
|
|
1614
|
+
}
|
|
1351
1615
|
if (constraints.unique === true) {
|
|
1352
1616
|
s.uniqueItems = true;
|
|
1353
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
|
+
}
|
|
1354
1628
|
}
|
|
1629
|
+
// Note: For boolean and object types, we skip min/max constraints (already warned above)
|
|
1355
1630
|
|
|
1356
1631
|
// Apply type-agnostic constraints
|
|
1357
1632
|
if (constraints.format !== undefined) {
|
|
@@ -1463,6 +1738,30 @@ export class SchemaExtractor {
|
|
|
1463
1738
|
return /@async\b/i.test(jsdocContent);
|
|
1464
1739
|
}
|
|
1465
1740
|
|
|
1741
|
+
/**
|
|
1742
|
+
* Extract notification subscriptions from @notify-on tag
|
|
1743
|
+
* Specifies which event types this photon is interested in
|
|
1744
|
+
* Format: @notify-on mentions, deadlines, errors
|
|
1745
|
+
*/
|
|
1746
|
+
private extractNotifyOn(jsdocContent: string): NotificationSubscription | undefined {
|
|
1747
|
+
const notifyMatch = jsdocContent.match(/@notify-on\s+([^\n]+)/i);
|
|
1748
|
+
if (!notifyMatch) {
|
|
1749
|
+
return undefined;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Parse comma-separated event types
|
|
1753
|
+
const watchFor = notifyMatch[1]
|
|
1754
|
+
.split(',')
|
|
1755
|
+
.map(s => s.trim())
|
|
1756
|
+
.filter(s => s.length > 0);
|
|
1757
|
+
|
|
1758
|
+
if (watchFor.length === 0) {
|
|
1759
|
+
return undefined;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
return { watchFor };
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1466
1765
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1467
1766
|
// DAEMON FEATURE EXTRACTION
|
|
1468
1767
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1606,10 +1905,27 @@ export class SchemaExtractor {
|
|
|
1606
1905
|
* - @retryable 3 2s → 3 retries, 2s delay
|
|
1607
1906
|
*/
|
|
1608
1907
|
private extractRetryable(jsdocContent: string): { count: number; delay: number } | undefined {
|
|
1609
|
-
const match = jsdocContent.match(/@retryable(?:\s+(
|
|
1908
|
+
const match = jsdocContent.match(/@retryable(?:\s+([-\d]+))?(?:\s+([-\d]+(?:ms|s|sec|m|min|h|hr|d|day)))?/i);
|
|
1610
1909
|
if (!match) return undefined;
|
|
1611
|
-
|
|
1612
|
-
|
|
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
|
+
|
|
1613
1929
|
return { count, delay };
|
|
1614
1930
|
}
|
|
1615
1931
|
|
|
@@ -1619,9 +1935,45 @@ export class SchemaExtractor {
|
|
|
1619
1935
|
* - @throttled 100/h → 100 calls per hour
|
|
1620
1936
|
*/
|
|
1621
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);
|
|
1622
1940
|
const match = jsdocContent.match(/@throttled\s+(\d+\/(?:s|sec|m|min|h|hr|d|day))/i);
|
|
1623
|
-
|
|
1624
|
-
|
|
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;
|
|
1625
1977
|
}
|
|
1626
1978
|
|
|
1627
1979
|
/**
|
|
@@ -1654,13 +2006,24 @@ export class SchemaExtractor {
|
|
|
1654
2006
|
* - @validate params.email must be a valid email
|
|
1655
2007
|
* - @validate params.amount must be positive
|
|
1656
2008
|
*/
|
|
1657
|
-
private extractValidations(jsdocContent: string): Array<{ field: string; rule: string }> | undefined {
|
|
2009
|
+
private extractValidations(jsdocContent: string, validParamNames?: string[]): Array<{ field: string; rule: string }> | undefined {
|
|
1658
2010
|
const validations: Array<{ field: string; rule: string }> = [];
|
|
1659
2011
|
const regex = /@validate\s+([\w.]+)\s+(.+)/g;
|
|
1660
2012
|
let match;
|
|
1661
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
|
+
|
|
1662
2025
|
validations.push({
|
|
1663
|
-
field:
|
|
2026
|
+
field: fieldName,
|
|
1664
2027
|
rule: match[2].trim().replace(/\*\/$/, '').trim(), // strip JSDoc closing
|
|
1665
2028
|
});
|
|
1666
2029
|
}
|
package/src/types.ts
CHANGED
|
@@ -191,6 +191,26 @@ export interface ExtractedSchema {
|
|
|
191
191
|
|
|
192
192
|
/** When true, method uses individual params instead of a single params object */
|
|
193
193
|
simpleParams?: boolean;
|
|
194
|
+
|
|
195
|
+
// ═══ EVENT EMISSION ═══
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* True if this method automatically emits an event on execution
|
|
199
|
+
* (for @stateful classes, all public methods emit automatically)
|
|
200
|
+
*/
|
|
201
|
+
emitsEvent?: boolean;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Event name that will be emitted when this method is called
|
|
205
|
+
* For @stateful classes, defaults to the method name (e.g., 'add', 'done')
|
|
206
|
+
*/
|
|
207
|
+
eventName?: string;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Event payload structure — what data will be sent with the event
|
|
211
|
+
* Typically mirrors the return type of the method
|
|
212
|
+
*/
|
|
213
|
+
eventPayload?: Record<string, string>;
|
|
194
214
|
}
|
|
195
215
|
|
|
196
216
|
export interface PhotonClass {
|
|
@@ -725,3 +745,31 @@ export interface ConfigSchema {
|
|
|
725
745
|
/** Description from configure() JSDoc */
|
|
726
746
|
description?: string;
|
|
727
747
|
}
|
|
748
|
+
|
|
749
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
750
|
+
// NOTIFICATION SUBSCRIPTIONS (@notify-on tag)
|
|
751
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Notification subscription declaration extracted from @notify-on tag
|
|
755
|
+
* Specifies which event types this photon cares about for notifications
|
|
756
|
+
*/
|
|
757
|
+
export interface NotificationSubscription {
|
|
758
|
+
/** Event types this photon wants to be notified about */
|
|
759
|
+
watchFor: string[];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Notification metadata attached to method return values via __notification property
|
|
764
|
+
* Specifies priority and type of notification when an event occurs
|
|
765
|
+
*/
|
|
766
|
+
export interface NotificationMetadata {
|
|
767
|
+
/** Type of notification (mentions, deadline, error, etc.) */
|
|
768
|
+
type: string;
|
|
769
|
+
/** Priority level for display and handling */
|
|
770
|
+
priority: 'critical' | 'warning' | 'info';
|
|
771
|
+
/** Optional message to display */
|
|
772
|
+
message?: string;
|
|
773
|
+
/** Optional tags for additional filtering */
|
|
774
|
+
tags?: string[];
|
|
775
|
+
}
|