@jay-framework/plugin-validator 0.20.0 → 0.22.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/index.d.ts +76 -2
- package/dist/index.js +933 -12
- package/package.json +8 -8
package/dist/index.d.ts
CHANGED
|
@@ -18,16 +18,22 @@ interface ValidationResult {
|
|
|
18
18
|
typesGenerated?: number;
|
|
19
19
|
}
|
|
20
20
|
interface ValidationError {
|
|
21
|
-
type: 'schema' | 'file-missing' | 'export-mismatch' | 'contract-invalid' | 'component-contract-mismatch' | 'type-generation-failed';
|
|
21
|
+
type: 'schema' | 'file-missing' | 'export-mismatch' | 'contract-invalid' | 'component-contract-mismatch' | 'type-generation-failed' | 'add-menu-catalog';
|
|
22
22
|
message: string;
|
|
23
23
|
location?: string;
|
|
24
24
|
suggestion?: string;
|
|
25
|
+
/** Add Menu lint / schema code (Design Log #30) */
|
|
26
|
+
code?: string;
|
|
27
|
+
itemId?: string;
|
|
25
28
|
}
|
|
26
29
|
interface ValidationWarning {
|
|
27
30
|
type: string;
|
|
28
31
|
message: string;
|
|
29
32
|
location?: string;
|
|
30
33
|
suggestion?: string;
|
|
34
|
+
/** Add Menu lint code (Design Log #30) */
|
|
35
|
+
code?: string;
|
|
36
|
+
itemId?: string;
|
|
31
37
|
}
|
|
32
38
|
interface PluginContext {
|
|
33
39
|
manifest: PluginManifest;
|
|
@@ -79,4 +85,72 @@ interface CheckResult {
|
|
|
79
85
|
*/
|
|
80
86
|
declare function checkComponentPropsAndParams(sourceCode: string, contract: ContractPropsAndParams, contractName: string, contractPath: string, sourcePath: string): CheckResult;
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Add Menu catalog schema validation and lint.
|
|
90
|
+
*
|
|
91
|
+
* Source of truth for plugin authors (`jay-stack validate-plugin`) and for
|
|
92
|
+
* AIditor runtime catalog loading. When changing Add Menu schema or lint rules,
|
|
93
|
+
* update this module and `agent-kit/plugin/aiditor-add-menu.md` in the same change.
|
|
94
|
+
*/
|
|
95
|
+
type AddMenuBrowseSize = 'large' | 'medium' | 'small';
|
|
96
|
+
type AddMenuPresentation = {
|
|
97
|
+
type: 'image';
|
|
98
|
+
src: string;
|
|
99
|
+
} | {
|
|
100
|
+
type: 'gif';
|
|
101
|
+
src: string;
|
|
102
|
+
poster?: string;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'html-fragment';
|
|
105
|
+
html: string;
|
|
106
|
+
};
|
|
107
|
+
type AddMenuInteraction = {
|
|
108
|
+
mode: 'reference' | 'stage-place';
|
|
109
|
+
stagePromptTemplate?: string;
|
|
110
|
+
};
|
|
111
|
+
type AddMenuCatalogLintWarning = {
|
|
112
|
+
code: string;
|
|
113
|
+
message: string;
|
|
114
|
+
itemId?: string;
|
|
115
|
+
sourcePath?: string;
|
|
116
|
+
};
|
|
117
|
+
type AddMenuValidationError = {
|
|
118
|
+
path: string;
|
|
119
|
+
message: string;
|
|
120
|
+
code?: string;
|
|
121
|
+
};
|
|
122
|
+
type AddMenuItem = {
|
|
123
|
+
id: string;
|
|
124
|
+
title: string;
|
|
125
|
+
category: string;
|
|
126
|
+
prompt: string;
|
|
127
|
+
pluginName?: string;
|
|
128
|
+
packageName?: string;
|
|
129
|
+
subCategory?: string;
|
|
130
|
+
folderPath?: string[];
|
|
131
|
+
thumbnail?: string;
|
|
132
|
+
presentation?: AddMenuPresentation;
|
|
133
|
+
browse?: {
|
|
134
|
+
size?: AddMenuBrowseSize;
|
|
135
|
+
};
|
|
136
|
+
interaction?: AddMenuInteraction;
|
|
137
|
+
};
|
|
138
|
+
type AddMenuCatalogFile = {
|
|
139
|
+
items: AddMenuItem[];
|
|
140
|
+
};
|
|
141
|
+
declare function validateAddMenuItem(raw: unknown, itemPath: string): {
|
|
142
|
+
item: AddMenuItem | null;
|
|
143
|
+
errors: AddMenuValidationError[];
|
|
144
|
+
};
|
|
145
|
+
declare function validateAddMenuCatalogFile(raw: unknown, sourcePath: string): {
|
|
146
|
+
file: AddMenuCatalogFile | null;
|
|
147
|
+
errors: AddMenuValidationError[];
|
|
148
|
+
};
|
|
149
|
+
declare function lintAddMenuCatalog(items: AddMenuItem[], sourcePath: string): {
|
|
150
|
+
errors: AddMenuCatalogLintWarning[];
|
|
151
|
+
warnings: AddMenuCatalogLintWarning[];
|
|
152
|
+
};
|
|
153
|
+
/** Human-readable fix hints for validate-plugin output. */
|
|
154
|
+
declare const ADD_MENU_VALIDATION_SUGGESTIONS: Record<string, string>;
|
|
155
|
+
|
|
156
|
+
export { ADD_MENU_VALIDATION_SUGGESTIONS, type AddMenuBrowseSize, type AddMenuCatalogFile, type AddMenuCatalogLintWarning, type AddMenuInteraction, type AddMenuItem, type AddMenuPresentation, type AddMenuValidationError, type PluginContext, type ValidatePluginOptions, type ValidationError, type ValidationResult, type ValidationWarning, checkComponentPropsAndParams, lintAddMenuCatalog, validateAddMenuCatalogFile, validateAddMenuItem, validatePlugin };
|
package/dist/index.js
CHANGED
|
@@ -272,6 +272,769 @@ function checkParamsConsistency(paramsTypeNames, localInterfaces, contractImport
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
|
+
const REJECTED_ITEM_FIELDS = ["kind", "parameters", "component", "allowedScopes"];
|
|
276
|
+
const HTML_SOFT_LIMIT_BYTES = 8 * 1024;
|
|
277
|
+
const HTML_HARD_LIMIT_BYTES = 32 * 1024;
|
|
278
|
+
const FOLDER_PATH_MAX_SEGMENTS = 32;
|
|
279
|
+
const BLOCKED_TAGS = /<\s*(script|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\s*\1\s*>|<\s*(script|iframe|object|embed)\b[^>]*\/?>/gi;
|
|
280
|
+
const EVENT_HANDLER_ATTR = /\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
281
|
+
const JAVASCRIPT_URL = /\b(href|src|xlink:href)\s*=\s*("|')\s*javascript:/gi;
|
|
282
|
+
function isRecord(value) {
|
|
283
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
284
|
+
}
|
|
285
|
+
function byteLengthUtf8(value) {
|
|
286
|
+
return new TextEncoder().encode(value).length;
|
|
287
|
+
}
|
|
288
|
+
function detectUnsafeAddMenuHtmlFragment(html) {
|
|
289
|
+
if (BLOCKED_TAGS.test(html)) {
|
|
290
|
+
BLOCKED_TAGS.lastIndex = 0;
|
|
291
|
+
return {
|
|
292
|
+
code: "html-fragment-unsafe-markup",
|
|
293
|
+
message: "html-fragment must not include script, iframe, object, or embed"
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
BLOCKED_TAGS.lastIndex = 0;
|
|
297
|
+
if (EVENT_HANDLER_ATTR.test(html)) {
|
|
298
|
+
EVENT_HANDLER_ATTR.lastIndex = 0;
|
|
299
|
+
return {
|
|
300
|
+
code: "html-fragment-unsafe-markup",
|
|
301
|
+
message: "html-fragment must not include inline event handler attributes"
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
EVENT_HANDLER_ATTR.lastIndex = 0;
|
|
305
|
+
if (JAVASCRIPT_URL.test(html)) {
|
|
306
|
+
JAVASCRIPT_URL.lastIndex = 0;
|
|
307
|
+
return {
|
|
308
|
+
code: "html-fragment-unsafe-markup",
|
|
309
|
+
message: "html-fragment must not include javascript: URLs"
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
JAVASCRIPT_URL.lastIndex = 0;
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
function sanitizeAddMenuHtmlFragment(html) {
|
|
316
|
+
let result = html;
|
|
317
|
+
result = result.replace(BLOCKED_TAGS, "");
|
|
318
|
+
BLOCKED_TAGS.lastIndex = 0;
|
|
319
|
+
result = result.replace(EVENT_HANDLER_ATTR, "");
|
|
320
|
+
EVENT_HANDLER_ATTR.lastIndex = 0;
|
|
321
|
+
result = result.replace(JAVASCRIPT_URL, "");
|
|
322
|
+
JAVASCRIPT_URL.lastIndex = 0;
|
|
323
|
+
return result.trim();
|
|
324
|
+
}
|
|
325
|
+
function catalogWarning(code, message, itemId, sourcePath) {
|
|
326
|
+
return { code, message, ...itemId ? { itemId } : {}, ...sourcePath ? { sourcePath } : {} };
|
|
327
|
+
}
|
|
328
|
+
function requiredString(obj, field, itemPath, errors, code) {
|
|
329
|
+
const value = obj[field];
|
|
330
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
331
|
+
errors.push({
|
|
332
|
+
path: `${itemPath}.${field}`,
|
|
333
|
+
message: "required non-empty string",
|
|
334
|
+
...code ? { code } : {}
|
|
335
|
+
});
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
return value.trim();
|
|
339
|
+
}
|
|
340
|
+
function optionalString(obj, field) {
|
|
341
|
+
const value = obj[field];
|
|
342
|
+
if (value === void 0)
|
|
343
|
+
return void 0;
|
|
344
|
+
if (typeof value !== "string")
|
|
345
|
+
return void 0;
|
|
346
|
+
const trimmed = value.trim();
|
|
347
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
348
|
+
}
|
|
349
|
+
function validateInteraction(raw, itemPath, errors) {
|
|
350
|
+
if (raw === void 0)
|
|
351
|
+
return void 0;
|
|
352
|
+
if (!isRecord(raw)) {
|
|
353
|
+
errors.push({
|
|
354
|
+
path: itemPath,
|
|
355
|
+
message: "interaction must be an object",
|
|
356
|
+
code: "interaction-not-object"
|
|
357
|
+
});
|
|
358
|
+
return void 0;
|
|
359
|
+
}
|
|
360
|
+
const mode = raw.mode;
|
|
361
|
+
if (mode !== "reference" && mode !== "stage-place") {
|
|
362
|
+
errors.push({
|
|
363
|
+
path: `${itemPath}.mode`,
|
|
364
|
+
message: 'interaction.mode must be "reference" or "stage-place"',
|
|
365
|
+
code: "interaction-invalid-mode"
|
|
366
|
+
});
|
|
367
|
+
return void 0;
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
mode,
|
|
371
|
+
stagePromptTemplate: optionalString(raw, "stagePromptTemplate")
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function validatePresentation(raw, itemPath, errors) {
|
|
375
|
+
if (raw === void 0)
|
|
376
|
+
return void 0;
|
|
377
|
+
if (!isRecord(raw)) {
|
|
378
|
+
errors.push({
|
|
379
|
+
path: itemPath,
|
|
380
|
+
message: "presentation must be an object",
|
|
381
|
+
code: "presentation-not-object"
|
|
382
|
+
});
|
|
383
|
+
return void 0;
|
|
384
|
+
}
|
|
385
|
+
const type = raw.type;
|
|
386
|
+
if (type !== "image" && type !== "gif" && type !== "html-fragment") {
|
|
387
|
+
errors.push({
|
|
388
|
+
path: `${itemPath}.type`,
|
|
389
|
+
message: 'presentation.type must be "image", "gif", or "html-fragment"',
|
|
390
|
+
code: "presentation-unknown-type"
|
|
391
|
+
});
|
|
392
|
+
return void 0;
|
|
393
|
+
}
|
|
394
|
+
if (type === "image") {
|
|
395
|
+
const src = requiredString(raw, "src", itemPath, errors, "presentation-missing-src");
|
|
396
|
+
if (!src)
|
|
397
|
+
return void 0;
|
|
398
|
+
return { type: "image", src };
|
|
399
|
+
}
|
|
400
|
+
if (type === "gif") {
|
|
401
|
+
const src = requiredString(raw, "src", itemPath, errors, "presentation-missing-src");
|
|
402
|
+
if (!src)
|
|
403
|
+
return void 0;
|
|
404
|
+
return {
|
|
405
|
+
type: "gif",
|
|
406
|
+
src,
|
|
407
|
+
poster: optionalString(raw, "poster")
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if ("src" in raw) {
|
|
411
|
+
errors.push({
|
|
412
|
+
path: `${itemPath}.src`,
|
|
413
|
+
message: "html-fragment must use presentation.html, not presentation.src",
|
|
414
|
+
code: "html-fragment-src-not-allowed"
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
const htmlValue = raw.html;
|
|
418
|
+
if (typeof htmlValue !== "string" || htmlValue.trim().length === 0) {
|
|
419
|
+
errors.push({
|
|
420
|
+
path: `${itemPath}.html`,
|
|
421
|
+
message: "html-fragment requires non-empty presentation.html",
|
|
422
|
+
code: "html-fragment-missing-html"
|
|
423
|
+
});
|
|
424
|
+
return void 0;
|
|
425
|
+
}
|
|
426
|
+
const unsafe = detectUnsafeAddMenuHtmlFragment(htmlValue);
|
|
427
|
+
if (unsafe) {
|
|
428
|
+
errors.push({
|
|
429
|
+
path: `${itemPath}.html`,
|
|
430
|
+
message: unsafe.message,
|
|
431
|
+
code: unsafe.code
|
|
432
|
+
});
|
|
433
|
+
return void 0;
|
|
434
|
+
}
|
|
435
|
+
const sanitized = sanitizeAddMenuHtmlFragment(htmlValue);
|
|
436
|
+
if (byteLengthUtf8(sanitized) > HTML_HARD_LIMIT_BYTES) {
|
|
437
|
+
errors.push({
|
|
438
|
+
path: `${itemPath}.html`,
|
|
439
|
+
message: `html-fragment exceeds ${HTML_HARD_LIMIT_BYTES} bytes after sanitize`,
|
|
440
|
+
code: "html-fragment-oversize-hard"
|
|
441
|
+
});
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
return { type: "html-fragment", html: htmlValue };
|
|
445
|
+
}
|
|
446
|
+
const BROWSE_SIZES = /* @__PURE__ */ new Set(["large", "medium", "small"]);
|
|
447
|
+
function validateBrowse(raw, itemPath, errors) {
|
|
448
|
+
if (raw === void 0)
|
|
449
|
+
return void 0;
|
|
450
|
+
if (!isRecord(raw)) {
|
|
451
|
+
errors.push({
|
|
452
|
+
path: itemPath,
|
|
453
|
+
message: "browse must be an object",
|
|
454
|
+
code: "browse-not-object"
|
|
455
|
+
});
|
|
456
|
+
return void 0;
|
|
457
|
+
}
|
|
458
|
+
const sizeRaw = raw.size;
|
|
459
|
+
if (sizeRaw === void 0) {
|
|
460
|
+
return {};
|
|
461
|
+
}
|
|
462
|
+
if (typeof sizeRaw !== "string" || !BROWSE_SIZES.has(sizeRaw)) {
|
|
463
|
+
errors.push({
|
|
464
|
+
path: `${itemPath}.size`,
|
|
465
|
+
message: 'browse.size must be "large", "medium", or "small"',
|
|
466
|
+
code: "browse-unknown-size"
|
|
467
|
+
});
|
|
468
|
+
return void 0;
|
|
469
|
+
}
|
|
470
|
+
return { size: sizeRaw };
|
|
471
|
+
}
|
|
472
|
+
function validateFolderPath(raw, itemPath, errors) {
|
|
473
|
+
if (raw === void 0)
|
|
474
|
+
return void 0;
|
|
475
|
+
if (!Array.isArray(raw)) {
|
|
476
|
+
errors.push({
|
|
477
|
+
path: `${itemPath}.folderPath`,
|
|
478
|
+
message: "folderPath must be an array of strings",
|
|
479
|
+
code: "folder-path-not-array"
|
|
480
|
+
});
|
|
481
|
+
return void 0;
|
|
482
|
+
}
|
|
483
|
+
const segments = [];
|
|
484
|
+
for (let index = 0; index < raw.length; index++) {
|
|
485
|
+
const value = raw[index];
|
|
486
|
+
if (typeof value !== "string") {
|
|
487
|
+
errors.push({
|
|
488
|
+
path: `${itemPath}.folderPath[${index}]`,
|
|
489
|
+
message: "folderPath segments must be strings",
|
|
490
|
+
code: "folder-path-segment-type"
|
|
491
|
+
});
|
|
492
|
+
return void 0;
|
|
493
|
+
}
|
|
494
|
+
const trimmed = value.trim();
|
|
495
|
+
if (trimmed.length === 0) {
|
|
496
|
+
errors.push({
|
|
497
|
+
path: `${itemPath}.folderPath[${index}]`,
|
|
498
|
+
message: "folderPath segments must be non-empty",
|
|
499
|
+
code: "folder-path-empty-segment"
|
|
500
|
+
});
|
|
501
|
+
return void 0;
|
|
502
|
+
}
|
|
503
|
+
if (trimmed === ".." || trimmed.includes("/") || trimmed.includes("\\")) {
|
|
504
|
+
errors.push({
|
|
505
|
+
path: `${itemPath}.folderPath[${index}]`,
|
|
506
|
+
message: 'folderPath segments must not contain ".." or path separators',
|
|
507
|
+
code: "folder-path-invalid-segment"
|
|
508
|
+
});
|
|
509
|
+
return void 0;
|
|
510
|
+
}
|
|
511
|
+
if (segments.at(-1) === trimmed) {
|
|
512
|
+
errors.push({
|
|
513
|
+
path: `${itemPath}.folderPath[${index}]`,
|
|
514
|
+
message: "folderPath must not contain duplicate adjacent segments",
|
|
515
|
+
code: "folder-path-adjacent-duplicate"
|
|
516
|
+
});
|
|
517
|
+
return void 0;
|
|
518
|
+
}
|
|
519
|
+
segments.push(trimmed);
|
|
520
|
+
}
|
|
521
|
+
if (segments.length > FOLDER_PATH_MAX_SEGMENTS) {
|
|
522
|
+
errors.push({
|
|
523
|
+
path: `${itemPath}.folderPath`,
|
|
524
|
+
message: `folderPath exceeds ${FOLDER_PATH_MAX_SEGMENTS} segments`,
|
|
525
|
+
code: "folder-path-too-deep"
|
|
526
|
+
});
|
|
527
|
+
return void 0;
|
|
528
|
+
}
|
|
529
|
+
return segments.length > 0 ? segments : void 0;
|
|
530
|
+
}
|
|
531
|
+
function validateAddMenuItem(raw, itemPath) {
|
|
532
|
+
const errors = [];
|
|
533
|
+
if (!isRecord(raw)) {
|
|
534
|
+
return {
|
|
535
|
+
item: null,
|
|
536
|
+
errors: [
|
|
537
|
+
{ path: itemPath, message: "item must be an object", code: "item-not-object" }
|
|
538
|
+
]
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
for (const field of REJECTED_ITEM_FIELDS) {
|
|
542
|
+
if (field in raw) {
|
|
543
|
+
errors.push({
|
|
544
|
+
path: `${itemPath}.${field}`,
|
|
545
|
+
message: `field "${field}" is not allowed in Add Menu catalog items`,
|
|
546
|
+
code: `rejected-field-${field}`
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const id = requiredString(raw, "id", itemPath, errors, "missing-id");
|
|
551
|
+
const title = requiredString(raw, "title", itemPath, errors, "missing-title");
|
|
552
|
+
const category = requiredString(raw, "category", itemPath, errors, "missing-category");
|
|
553
|
+
const prompt = requiredString(raw, "prompt", itemPath, errors, "missing-prompt");
|
|
554
|
+
if (errors.length > 0 || !id || !title || !category || !prompt) {
|
|
555
|
+
return { item: null, errors };
|
|
556
|
+
}
|
|
557
|
+
const interaction = validateInteraction(raw.interaction, `${itemPath}.interaction`, errors);
|
|
558
|
+
const presentation = validatePresentation(raw.presentation, `${itemPath}.presentation`, errors);
|
|
559
|
+
const browse = validateBrowse(raw.browse, `${itemPath}.browse`, errors);
|
|
560
|
+
const folderPath = validateFolderPath(raw.folderPath, itemPath, errors);
|
|
561
|
+
if (errors.length > 0) {
|
|
562
|
+
return { item: null, errors };
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
item: {
|
|
566
|
+
id,
|
|
567
|
+
title,
|
|
568
|
+
category,
|
|
569
|
+
prompt,
|
|
570
|
+
pluginName: optionalString(raw, "pluginName"),
|
|
571
|
+
packageName: optionalString(raw, "packageName"),
|
|
572
|
+
subCategory: optionalString(raw, "subCategory"),
|
|
573
|
+
...folderPath ? { folderPath } : {},
|
|
574
|
+
thumbnail: optionalString(raw, "thumbnail"),
|
|
575
|
+
...presentation ? { presentation } : {},
|
|
576
|
+
...browse ? { browse } : {},
|
|
577
|
+
...interaction ? { interaction } : {}
|
|
578
|
+
},
|
|
579
|
+
errors
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
function validateAddMenuCatalogFile(raw, sourcePath) {
|
|
583
|
+
const errors = [];
|
|
584
|
+
if (!isRecord(raw)) {
|
|
585
|
+
return {
|
|
586
|
+
file: null,
|
|
587
|
+
errors: [
|
|
588
|
+
{
|
|
589
|
+
path: sourcePath,
|
|
590
|
+
message: "catalog file must be an object",
|
|
591
|
+
code: "catalog-not-object"
|
|
592
|
+
}
|
|
593
|
+
]
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (!Array.isArray(raw.items)) {
|
|
597
|
+
return {
|
|
598
|
+
file: null,
|
|
599
|
+
errors: [
|
|
600
|
+
{
|
|
601
|
+
path: `${sourcePath}.items`,
|
|
602
|
+
message: "items must be an array",
|
|
603
|
+
code: "catalog-items-not-array"
|
|
604
|
+
}
|
|
605
|
+
]
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const items = [];
|
|
609
|
+
raw.items.forEach((entry, index) => {
|
|
610
|
+
const result = validateAddMenuItem(entry, `${sourcePath}.items[${index}]`);
|
|
611
|
+
errors.push(...result.errors);
|
|
612
|
+
if (result.item)
|
|
613
|
+
items.push(result.item);
|
|
614
|
+
});
|
|
615
|
+
if (items.length === 0 && errors.length > 0) {
|
|
616
|
+
return { file: null, errors };
|
|
617
|
+
}
|
|
618
|
+
return { file: { items }, errors };
|
|
619
|
+
}
|
|
620
|
+
function normalizeAddMenuPresentation(item) {
|
|
621
|
+
if (item.presentation)
|
|
622
|
+
return item.presentation;
|
|
623
|
+
const thumbnail = item.thumbnail?.trim();
|
|
624
|
+
if (!thumbnail)
|
|
625
|
+
return void 0;
|
|
626
|
+
if (/\.gif$/i.test(thumbnail)) {
|
|
627
|
+
return { type: "gif", src: thumbnail };
|
|
628
|
+
}
|
|
629
|
+
return { type: "image", src: thumbnail };
|
|
630
|
+
}
|
|
631
|
+
function normalizeAddMenuBrowseSize(item) {
|
|
632
|
+
return item.browse?.size ?? "medium";
|
|
633
|
+
}
|
|
634
|
+
function hasSingleRootDiv(html) {
|
|
635
|
+
const trimmed = html.trim();
|
|
636
|
+
if (!trimmed.startsWith("<div"))
|
|
637
|
+
return false;
|
|
638
|
+
const openMatch = trimmed.match(/^<div\b[^>]*>/i);
|
|
639
|
+
if (!openMatch)
|
|
640
|
+
return false;
|
|
641
|
+
const afterOpen = trimmed.slice(openMatch[0].length);
|
|
642
|
+
const closeIdx = afterOpen.lastIndexOf("</div>");
|
|
643
|
+
if (closeIdx < 0)
|
|
644
|
+
return false;
|
|
645
|
+
const tail = afterOpen.slice(closeIdx + "</div>".length).trim();
|
|
646
|
+
return tail.length === 0;
|
|
647
|
+
}
|
|
648
|
+
function hasAtScopeStyle(html) {
|
|
649
|
+
return /<style\b[^>]*>[\s\S]*@scope\b/i.test(html);
|
|
650
|
+
}
|
|
651
|
+
function lintHtmlFragment(item, sourcePath) {
|
|
652
|
+
const errors = [];
|
|
653
|
+
const warnings = [];
|
|
654
|
+
const presentation = item.presentation;
|
|
655
|
+
if (!presentation || presentation.type !== "html-fragment") {
|
|
656
|
+
return { errors, warnings };
|
|
657
|
+
}
|
|
658
|
+
const rawHtml = presentation.html?.trim() ?? "";
|
|
659
|
+
if (!rawHtml) {
|
|
660
|
+
errors.push(
|
|
661
|
+
catalogWarning(
|
|
662
|
+
"html-fragment-missing-html",
|
|
663
|
+
"html-fragment requires non-empty presentation.html",
|
|
664
|
+
item.id,
|
|
665
|
+
sourcePath
|
|
666
|
+
)
|
|
667
|
+
);
|
|
668
|
+
return { errors, warnings };
|
|
669
|
+
}
|
|
670
|
+
const unsafe = detectUnsafeAddMenuHtmlFragment(rawHtml);
|
|
671
|
+
if (unsafe) {
|
|
672
|
+
errors.push(catalogWarning(unsafe.code, unsafe.message, item.id, sourcePath));
|
|
673
|
+
return { errors, warnings };
|
|
674
|
+
}
|
|
675
|
+
const sanitized = sanitizeAddMenuHtmlFragment(rawHtml);
|
|
676
|
+
const bytes = byteLengthUtf8(sanitized);
|
|
677
|
+
if (bytes > HTML_HARD_LIMIT_BYTES) {
|
|
678
|
+
errors.push(
|
|
679
|
+
catalogWarning(
|
|
680
|
+
"html-fragment-oversize-hard",
|
|
681
|
+
`html-fragment exceeds ${HTML_HARD_LIMIT_BYTES} bytes after sanitize (${bytes})`,
|
|
682
|
+
item.id,
|
|
683
|
+
sourcePath
|
|
684
|
+
)
|
|
685
|
+
);
|
|
686
|
+
} else if (bytes > HTML_SOFT_LIMIT_BYTES) {
|
|
687
|
+
warnings.push(
|
|
688
|
+
catalogWarning(
|
|
689
|
+
"html-fragment-oversize-soft",
|
|
690
|
+
`html-fragment exceeds ${HTML_SOFT_LIMIT_BYTES} bytes after sanitize (${bytes}) — consider trimming`,
|
|
691
|
+
item.id,
|
|
692
|
+
sourcePath
|
|
693
|
+
)
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
if (!hasSingleRootDiv(sanitized)) {
|
|
697
|
+
warnings.push(
|
|
698
|
+
catalogWarning(
|
|
699
|
+
"html-fragment-missing-root-div",
|
|
700
|
+
"html-fragment should use a single root <div>",
|
|
701
|
+
item.id,
|
|
702
|
+
sourcePath
|
|
703
|
+
)
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
if (!hasAtScopeStyle(sanitized)) {
|
|
707
|
+
warnings.push(
|
|
708
|
+
catalogWarning(
|
|
709
|
+
"html-fragment-missing-at-scope",
|
|
710
|
+
"html-fragment should include <style> with @scope for preview isolation",
|
|
711
|
+
item.id,
|
|
712
|
+
sourcePath
|
|
713
|
+
)
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return { errors, warnings };
|
|
717
|
+
}
|
|
718
|
+
function lintGifPoster(item, sourcePath) {
|
|
719
|
+
const presentation = normalizeAddMenuPresentation(item);
|
|
720
|
+
if (presentation?.type !== "gif" || presentation.poster?.trim())
|
|
721
|
+
return [];
|
|
722
|
+
return [
|
|
723
|
+
catalogWarning(
|
|
724
|
+
"gif-missing-poster",
|
|
725
|
+
`gif item "${item.id}" has no poster — add poster for prefers-reduced-motion accessibility`,
|
|
726
|
+
item.id,
|
|
727
|
+
sourcePath
|
|
728
|
+
)
|
|
729
|
+
];
|
|
730
|
+
}
|
|
731
|
+
function lintBrowseLargeWithoutPresentation(item, sourcePath) {
|
|
732
|
+
if (normalizeAddMenuBrowseSize(item) !== "large")
|
|
733
|
+
return [];
|
|
734
|
+
if (normalizeAddMenuPresentation(item))
|
|
735
|
+
return [];
|
|
736
|
+
return [
|
|
737
|
+
catalogWarning(
|
|
738
|
+
"browse-large-without-presentation",
|
|
739
|
+
`large browse item "${item.id}" has no presentation or thumbnail — add a preview`,
|
|
740
|
+
item.id,
|
|
741
|
+
sourcePath
|
|
742
|
+
)
|
|
743
|
+
];
|
|
744
|
+
}
|
|
745
|
+
function lintAddMenuCatalog(items, sourcePath) {
|
|
746
|
+
const errors = [];
|
|
747
|
+
const warnings = [];
|
|
748
|
+
for (const item of items) {
|
|
749
|
+
warnings.push(...lintGifPoster(item, sourcePath));
|
|
750
|
+
warnings.push(...lintBrowseLargeWithoutPresentation(item, sourcePath));
|
|
751
|
+
const htmlLint = lintHtmlFragment(item, sourcePath);
|
|
752
|
+
errors.push(...htmlLint.errors);
|
|
753
|
+
warnings.push(...htmlLint.warnings);
|
|
754
|
+
}
|
|
755
|
+
return { errors, warnings };
|
|
756
|
+
}
|
|
757
|
+
const ADD_MENU_VALIDATION_SUGGESTIONS = {
|
|
758
|
+
"gif-missing-poster": "Add presentation.poster with a static image for prefers-reduced-motion accessibility",
|
|
759
|
+
"html-fragment-missing-root-div": 'Wrap preview markup in a single root <div class="am-preview"> — see agent-kit/plugin/aiditor-add-menu.md',
|
|
760
|
+
"html-fragment-missing-at-scope": "Add <style> with @scope (.am-preview) inside the root div — see agent-kit/plugin/aiditor-add-menu.md",
|
|
761
|
+
"html-fragment-missing-html": "Set presentation.html with inline markup — external preview files are not supported",
|
|
762
|
+
"html-fragment-src-not-allowed": "Remove presentation.src; use presentation.html for html-fragment previews",
|
|
763
|
+
"html-fragment-unsafe-markup": "Remove scripts, event handlers, and javascript: URLs from presentation.html",
|
|
764
|
+
"html-fragment-oversize-soft": "Trim inline html or use a gif preview for rich motion",
|
|
765
|
+
"html-fragment-oversize-hard": "Reduce presentation.html below 32 KB after sanitize",
|
|
766
|
+
"browse-large-without-presentation": "Add presentation or thumbnail — large browse tiles need a visual preview",
|
|
767
|
+
"browse-unknown-size": "Set browse.size to large, medium, or small",
|
|
768
|
+
"presentation-unknown-type": "Set presentation.type to image, gif, or html-fragment",
|
|
769
|
+
"interaction-invalid-mode": "Set interaction.mode to reference (default) or stage-place",
|
|
770
|
+
"folder-path-not-array": "folderPath must be a yaml array of folder name strings",
|
|
771
|
+
"folder-path-segment-type": "Each folderPath entry must be a non-empty string",
|
|
772
|
+
"folder-path-empty-segment": "Remove empty folderPath segments",
|
|
773
|
+
"folder-path-invalid-segment": 'Use separate array elements per folder level — no "/" or "\\" in segment names',
|
|
774
|
+
"folder-path-adjacent-duplicate": "Remove duplicate consecutive folderPath segments",
|
|
775
|
+
"folder-path-too-deep": `Shorten folderPath to at most ${FOLDER_PATH_MAX_SEGMENTS} segments`,
|
|
776
|
+
"rejected-field-kind": "Remove kind — express item intent in prompt text",
|
|
777
|
+
"rejected-field-parameters": "Remove parameters — materialize values into prompt",
|
|
778
|
+
"rejected-field-component": "Remove component — use prompt plus contract paths in agent-kit",
|
|
779
|
+
"rejected-field-allowedScopes": "Remove allowedScopes — attachment scope is chosen in the AIditor UI",
|
|
780
|
+
"add-menu-missing-agentkit-handler": "Declare agentkit in plugin.yaml pointing at an agent-kit handler export; run jay-stack agent-kit (project yarn agent-kit) to materialize catalogs — not jay-stack setup",
|
|
781
|
+
"add-menu-legacy-setup-handler": "Move add-menu catalog writes from the setup handler to agentkit. Setup is for config/credentials only; run jay-stack agent-kit to generate agent-kit/aiditor/add-menu/*.yaml",
|
|
782
|
+
"deprecated-nested-setup-keys": "Replace nested setup.handler / setup.references with flat keys: setup: <handler> and agentkit: <handler>"
|
|
783
|
+
};
|
|
784
|
+
const ADD_MENU_CATALOG_REL_PATHS = [
|
|
785
|
+
"agent-kit/aiditor/add-menu.template.yaml",
|
|
786
|
+
"agent-kit/aiditor/add-menu.yaml"
|
|
787
|
+
];
|
|
788
|
+
const CONTRIBUTOR_GUIDE = "agent-kit/plugin/aiditor-add-menu.md";
|
|
789
|
+
const ADD_MENU_WRITE_MARKERS = [
|
|
790
|
+
"agent-kit/aiditor/add-menu",
|
|
791
|
+
"aiditor-add-menu-thumbnails",
|
|
792
|
+
"writeAddMenuCatalog",
|
|
793
|
+
"copyAiditorAddMenuThumbnails"
|
|
794
|
+
];
|
|
795
|
+
function isRelativeHandlerRef(value) {
|
|
796
|
+
return value.startsWith("./") || value.startsWith("../");
|
|
797
|
+
}
|
|
798
|
+
function resolveModulePath$1(basePath) {
|
|
799
|
+
for (const ext of ["", ".ts", ".js", "/index.ts", "/index.js"]) {
|
|
800
|
+
const candidate = basePath + ext;
|
|
801
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
802
|
+
return candidate;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return void 0;
|
|
806
|
+
}
|
|
807
|
+
function collectTypeScriptFiles(dir, depth = 0) {
|
|
808
|
+
if (depth > 4)
|
|
809
|
+
return [];
|
|
810
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
811
|
+
const files = [];
|
|
812
|
+
for (const entry of entries) {
|
|
813
|
+
const fullPath = path.join(dir, entry.name);
|
|
814
|
+
if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "test") {
|
|
815
|
+
files.push(...collectTypeScriptFiles(fullPath, depth + 1));
|
|
816
|
+
} else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) {
|
|
817
|
+
files.push(fullPath);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return files;
|
|
821
|
+
}
|
|
822
|
+
function resolveHandlerSourceFile(pluginPath, handlerRef, isNpmPackage) {
|
|
823
|
+
if (isRelativeHandlerRef(handlerRef)) {
|
|
824
|
+
return resolveModulePath$1(path.join(pluginPath, handlerRef)) ?? null;
|
|
825
|
+
}
|
|
826
|
+
const searchRoots = isNpmPackage ? [path.join(pluginPath, "lib"), path.join(pluginPath, "dist")] : [pluginPath];
|
|
827
|
+
for (const root of searchRoots) {
|
|
828
|
+
if (!fs.existsSync(root))
|
|
829
|
+
continue;
|
|
830
|
+
for (const file of collectTypeScriptFiles(root)) {
|
|
831
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
832
|
+
const definesHandler = new RegExp(
|
|
833
|
+
`export\\s+(?:async\\s+)?function\\s+${handlerRef}\\b`
|
|
834
|
+
).test(content);
|
|
835
|
+
const definesDefaultNamed = new RegExp(
|
|
836
|
+
`export\\s+default\\s+async\\s+function\\s+${handlerRef}\\b`
|
|
837
|
+
).test(content);
|
|
838
|
+
if (definesHandler || definesDefaultNamed) {
|
|
839
|
+
return file;
|
|
840
|
+
}
|
|
841
|
+
const reExportMatch = content.match(
|
|
842
|
+
new RegExp(
|
|
843
|
+
`export\\s*\\{[^}]*\\b${handlerRef}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`
|
|
844
|
+
)
|
|
845
|
+
);
|
|
846
|
+
if (reExportMatch) {
|
|
847
|
+
const importSpec = reExportMatch[1].replace(/\.js$/, "");
|
|
848
|
+
const resolved = resolveModulePath$1(path.resolve(path.dirname(file), importSpec));
|
|
849
|
+
if (resolved)
|
|
850
|
+
return resolved;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
function extractBalancedBlock(source, openBraceIndex) {
|
|
857
|
+
let depth = 0;
|
|
858
|
+
for (let index = openBraceIndex; index < source.length; index++) {
|
|
859
|
+
const char = source[index];
|
|
860
|
+
if (char === "{")
|
|
861
|
+
depth++;
|
|
862
|
+
else if (char === "}") {
|
|
863
|
+
depth--;
|
|
864
|
+
if (depth === 0) {
|
|
865
|
+
return source.slice(openBraceIndex, index + 1);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
function findFunctionBodyOpenBrace(source, searchFrom) {
|
|
872
|
+
const arrowMatch = /=>\s*\{/.exec(source.slice(searchFrom));
|
|
873
|
+
const parenMatch = /\)\s*(?::[^{]+)?\{/.exec(source.slice(searchFrom));
|
|
874
|
+
const candidates = [];
|
|
875
|
+
if (arrowMatch?.index !== void 0) {
|
|
876
|
+
candidates.push(searchFrom + arrowMatch.index + arrowMatch[0].length - 1);
|
|
877
|
+
}
|
|
878
|
+
if (parenMatch?.index !== void 0) {
|
|
879
|
+
candidates.push(searchFrom + parenMatch.index + parenMatch[0].length - 1);
|
|
880
|
+
}
|
|
881
|
+
if (candidates.length === 0)
|
|
882
|
+
return -1;
|
|
883
|
+
return Math.min(...candidates);
|
|
884
|
+
}
|
|
885
|
+
function extractFunctionBody(source, functionName) {
|
|
886
|
+
const patterns = [
|
|
887
|
+
new RegExp(`export\\s+async\\s+function\\s+${functionName}\\b`),
|
|
888
|
+
new RegExp(`export\\s+function\\s+${functionName}\\b`),
|
|
889
|
+
new RegExp(`export\\s+default\\s+async\\s+function\\s+${functionName}\\b`),
|
|
890
|
+
new RegExp(
|
|
891
|
+
`export\\s+const\\s+${functionName}\\s*=\\s*async\\s*\\([^)]*\\)\\s*(?::[^{]+)?=>\\s*\\{`
|
|
892
|
+
),
|
|
893
|
+
new RegExp(
|
|
894
|
+
`export\\s+const\\s+${functionName}\\s*=\\s*\\([^)]*\\)\\s*(?::[^{]+)?=>\\s*\\{`
|
|
895
|
+
)
|
|
896
|
+
];
|
|
897
|
+
for (const pattern of patterns) {
|
|
898
|
+
const match = pattern.exec(source);
|
|
899
|
+
if (!match)
|
|
900
|
+
continue;
|
|
901
|
+
const braceIndex = findFunctionBodyOpenBrace(source, match.index);
|
|
902
|
+
if (braceIndex === -1)
|
|
903
|
+
continue;
|
|
904
|
+
return extractBalancedBlock(source, braceIndex);
|
|
905
|
+
}
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
function extractDefaultExportFunctionBody(source) {
|
|
909
|
+
const patterns = [
|
|
910
|
+
/export\s+default\s+async\s+function\s+\w+\b/,
|
|
911
|
+
/export\s+default\s+async\s+function\s*\(/,
|
|
912
|
+
/export\s+default\s+async\s*\([^)]*\)\s*(?::[^{]+)?=>\s*\{/
|
|
913
|
+
];
|
|
914
|
+
for (const pattern of patterns) {
|
|
915
|
+
const match = pattern.exec(source);
|
|
916
|
+
if (!match)
|
|
917
|
+
continue;
|
|
918
|
+
const braceIndex = findFunctionBodyOpenBrace(source, match.index);
|
|
919
|
+
if (braceIndex === -1)
|
|
920
|
+
continue;
|
|
921
|
+
return extractBalancedBlock(source, braceIndex);
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
function handlerBodyWritesAddMenuCatalog(body) {
|
|
926
|
+
return ADD_MENU_WRITE_MARKERS.some((marker) => body.includes(marker));
|
|
927
|
+
}
|
|
928
|
+
function resolveSetupHandlerFunctionBody(pluginPath, handlerRef, isNpmPackage) {
|
|
929
|
+
const sourceFile = resolveHandlerSourceFile(pluginPath, handlerRef, isNpmPackage);
|
|
930
|
+
if (!sourceFile)
|
|
931
|
+
return null;
|
|
932
|
+
const source = fs.readFileSync(sourceFile, "utf-8");
|
|
933
|
+
if (isRelativeHandlerRef(handlerRef)) {
|
|
934
|
+
return extractDefaultExportFunctionBody(source) ?? extractFunctionBody(source, "setup") ?? extractFunctionBody(source, handlerRef);
|
|
935
|
+
}
|
|
936
|
+
return extractFunctionBody(source, handlerRef);
|
|
937
|
+
}
|
|
938
|
+
function suggestionForCode(code) {
|
|
939
|
+
if (!code)
|
|
940
|
+
return `See ${CONTRIBUTOR_GUIDE} for schema and validation rules`;
|
|
941
|
+
return ADD_MENU_VALIDATION_SUGGESTIONS[code] ?? `See ${CONTRIBUTOR_GUIDE} for schema and validation rules`;
|
|
942
|
+
}
|
|
943
|
+
function mapSchemaError(error, catalogPath) {
|
|
944
|
+
const code = error.code ?? "catalog-validation-error";
|
|
945
|
+
return {
|
|
946
|
+
type: "add-menu-catalog",
|
|
947
|
+
code,
|
|
948
|
+
message: `[${code}] ${error.message}`,
|
|
949
|
+
location: error.path || catalogPath,
|
|
950
|
+
suggestion: suggestionForCode(code)
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
function mapLintFinding(finding, catalogPath, severity) {
|
|
954
|
+
return {
|
|
955
|
+
type: "add-menu-catalog",
|
|
956
|
+
code: finding.code,
|
|
957
|
+
message: `[${finding.code}] ${finding.message}`,
|
|
958
|
+
location: finding.sourcePath ?? catalogPath,
|
|
959
|
+
itemId: finding.itemId,
|
|
960
|
+
suggestion: suggestionForCode(finding.code),
|
|
961
|
+
...severity === "warning" ? {} : {}
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function pluginShipsAddMenuCatalog(context) {
|
|
965
|
+
return ADD_MENU_CATALOG_REL_PATHS.some(
|
|
966
|
+
(relPath) => fs.existsSync(path.join(context.pluginPath, relPath))
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
function validateAddMenuAgentKitHandler(context, result) {
|
|
970
|
+
if (!pluginShipsAddMenuCatalog(context))
|
|
971
|
+
return;
|
|
972
|
+
const agentKitHandler = context.manifest.agentkit;
|
|
973
|
+
if (!agentKitHandler) {
|
|
974
|
+
result.warnings.push({
|
|
975
|
+
type: "add-menu-catalog",
|
|
976
|
+
code: "add-menu-missing-agentkit-handler",
|
|
977
|
+
message: "[add-menu-missing-agentkit-handler] Plugin ships add-menu catalog yaml but plugin.yaml has no agentkit handler — catalogs must be generated during jay-stack agent-kit (yarn agent-kit), not jay-stack setup",
|
|
978
|
+
location: "plugin.yaml",
|
|
979
|
+
suggestion: suggestionForCode("add-menu-missing-agentkit-handler")
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const setupHandler = typeof context.manifest.setup === "string" ? context.manifest.setup : void 0;
|
|
983
|
+
if (!setupHandler)
|
|
984
|
+
return;
|
|
985
|
+
const setupBody = resolveSetupHandlerFunctionBody(
|
|
986
|
+
context.pluginPath,
|
|
987
|
+
setupHandler,
|
|
988
|
+
context.isNpmPackage
|
|
989
|
+
);
|
|
990
|
+
if (!setupBody || !handlerBodyWritesAddMenuCatalog(setupBody))
|
|
991
|
+
return;
|
|
992
|
+
result.warnings.push({
|
|
993
|
+
type: "add-menu-catalog",
|
|
994
|
+
code: "add-menu-legacy-setup-handler",
|
|
995
|
+
message: "[add-menu-legacy-setup-handler] setup handler writes add-menu catalogs — move catalog materialization to agentkit and run jay-stack agent-kit (yarn agent-kit); keep setup for config and credentials only",
|
|
996
|
+
location: `plugin.yaml setup (${setupHandler})`,
|
|
997
|
+
suggestion: suggestionForCode("add-menu-legacy-setup-handler")
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
async function validateAddMenuCatalogFileAtPath(catalogPath, relPath, result) {
|
|
1001
|
+
let parsed;
|
|
1002
|
+
try {
|
|
1003
|
+
const content = await fs.promises.readFile(catalogPath, "utf-8");
|
|
1004
|
+
parsed = YAML.parse(content);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1007
|
+
result.errors.push({
|
|
1008
|
+
type: "add-menu-catalog",
|
|
1009
|
+
code: "catalog-yaml-parse-error",
|
|
1010
|
+
message: `Invalid add-menu catalog YAML: ${message}`,
|
|
1011
|
+
location: relPath,
|
|
1012
|
+
suggestion: `Check YAML syntax in the add-menu catalog file. See ${CONTRIBUTOR_GUIDE}`
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const validated = validateAddMenuCatalogFile(parsed, relPath);
|
|
1017
|
+
result.errors.push(...validated.errors.map((error) => mapSchemaError(error, relPath)));
|
|
1018
|
+
if (!validated.file?.items.length) {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const linted = lintAddMenuCatalog(validated.file.items, relPath);
|
|
1022
|
+
result.errors.push(
|
|
1023
|
+
...linted.errors.map((finding) => mapLintFinding(finding, relPath, "error"))
|
|
1024
|
+
);
|
|
1025
|
+
result.warnings.push(
|
|
1026
|
+
...linted.warnings.map((finding) => mapLintFinding(finding, relPath, "warning"))
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
async function validateAddMenuCatalog(context, result) {
|
|
1030
|
+
validateAddMenuAgentKitHandler(context, result);
|
|
1031
|
+
for (const relPath of ADD_MENU_CATALOG_REL_PATHS) {
|
|
1032
|
+
const catalogPath = path.join(context.pluginPath, relPath);
|
|
1033
|
+
if (!fs.existsSync(catalogPath))
|
|
1034
|
+
continue;
|
|
1035
|
+
await validateAddMenuCatalogFileAtPath(catalogPath, relPath, result);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
275
1038
|
async function validatePlugin(options = {}) {
|
|
276
1039
|
const pluginPath = options.pluginPath || process.cwd();
|
|
277
1040
|
if (options.local) {
|
|
@@ -338,6 +1101,7 @@ async function validatePluginPackage(pluginPath, options) {
|
|
|
338
1101
|
if (pluginManifest.dynamic_contracts) {
|
|
339
1102
|
await validateDynamicContracts(context, result);
|
|
340
1103
|
}
|
|
1104
|
+
await validateAddMenuCatalog(context, result);
|
|
341
1105
|
result.valid = result.errors.length === 0;
|
|
342
1106
|
return result;
|
|
343
1107
|
}
|
|
@@ -451,6 +1215,14 @@ async function validateSchema(context, result) {
|
|
|
451
1215
|
location: "plugin.yaml",
|
|
452
1216
|
suggestion: 'Specify the exported member name from the module (e.g., "moodTracker")'
|
|
453
1217
|
});
|
|
1218
|
+
} else {
|
|
1219
|
+
validateHandlerRef(
|
|
1220
|
+
contract.component,
|
|
1221
|
+
`Contract "${contract.name}" component`,
|
|
1222
|
+
`plugin.yaml contracts[${index}]`,
|
|
1223
|
+
context,
|
|
1224
|
+
result
|
|
1225
|
+
);
|
|
454
1226
|
}
|
|
455
1227
|
});
|
|
456
1228
|
}
|
|
@@ -483,6 +1255,24 @@ async function validateSchema(context, result) {
|
|
|
483
1255
|
suggestion: 'Specify prefix for dynamic contract names (e.g., "cms")'
|
|
484
1256
|
});
|
|
485
1257
|
}
|
|
1258
|
+
if (config.component) {
|
|
1259
|
+
validateHandlerRef(
|
|
1260
|
+
config.component,
|
|
1261
|
+
`dynamic_contracts[${prefix}] component`,
|
|
1262
|
+
`plugin.yaml dynamic_contracts`,
|
|
1263
|
+
context,
|
|
1264
|
+
result
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
if (config.generator) {
|
|
1268
|
+
validateHandlerRef(
|
|
1269
|
+
config.generator,
|
|
1270
|
+
`dynamic_contracts[${prefix}] generator`,
|
|
1271
|
+
`plugin.yaml dynamic_contracts`,
|
|
1272
|
+
context,
|
|
1273
|
+
result
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
486
1276
|
}
|
|
487
1277
|
}
|
|
488
1278
|
if (!manifest.contracts && !manifest.dynamic_contracts) {
|
|
@@ -553,6 +1343,23 @@ async function validateSchema(context, result) {
|
|
|
553
1343
|
});
|
|
554
1344
|
}
|
|
555
1345
|
}
|
|
1346
|
+
if (manifest.actions) {
|
|
1347
|
+
for (const entry of manifest.actions) {
|
|
1348
|
+
const exportName = typeof entry === "string" ? entry : entry.name;
|
|
1349
|
+
if (exportName) {
|
|
1350
|
+
validateHandlerRef(
|
|
1351
|
+
exportName,
|
|
1352
|
+
`Action "${exportName}"`,
|
|
1353
|
+
"plugin.yaml actions",
|
|
1354
|
+
context,
|
|
1355
|
+
result
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (manifest.init) {
|
|
1361
|
+
validateHandlerRef(manifest.init, "Init handler", "plugin.yaml init", context, result);
|
|
1362
|
+
}
|
|
556
1363
|
if (manifest.routes) {
|
|
557
1364
|
if (!Array.isArray(manifest.routes)) {
|
|
558
1365
|
result.errors.push({
|
|
@@ -584,6 +1391,14 @@ async function validateSchema(context, result) {
|
|
|
584
1391
|
location: "plugin.yaml",
|
|
585
1392
|
suggestion: "Specify the exported member name for the page component"
|
|
586
1393
|
});
|
|
1394
|
+
} else {
|
|
1395
|
+
validateHandlerRef(
|
|
1396
|
+
route.component,
|
|
1397
|
+
`Route "${route.path}" component`,
|
|
1398
|
+
`plugin.yaml routes`,
|
|
1399
|
+
context,
|
|
1400
|
+
result
|
|
1401
|
+
);
|
|
587
1402
|
}
|
|
588
1403
|
if (route.jayHtml) {
|
|
589
1404
|
validateDocFile(
|
|
@@ -623,22 +1438,112 @@ async function validateSchema(context, result) {
|
|
|
623
1438
|
suggestion: "Specify the relative path to the validator handler module"
|
|
624
1439
|
});
|
|
625
1440
|
}
|
|
626
|
-
if (validator.handler
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
location: "plugin.yaml validators",
|
|
635
|
-
suggestion: `Create the validator handler at ${handlerPath}.ts`
|
|
636
|
-
});
|
|
637
|
-
}
|
|
1441
|
+
if (validator.handler) {
|
|
1442
|
+
validateHandlerRef(
|
|
1443
|
+
validator.handler,
|
|
1444
|
+
`Validator "${validator.name}" handler`,
|
|
1445
|
+
"plugin.yaml validators",
|
|
1446
|
+
context,
|
|
1447
|
+
result
|
|
1448
|
+
);
|
|
638
1449
|
}
|
|
639
1450
|
});
|
|
640
1451
|
}
|
|
641
1452
|
}
|
|
1453
|
+
if (manifest.setup) {
|
|
1454
|
+
if (typeof manifest.setup !== "string") {
|
|
1455
|
+
result.errors.push({
|
|
1456
|
+
type: "schema",
|
|
1457
|
+
message: "Deprecated nested setup keys (setup.handler / setup.references) — use flat setup: and agentkit: in plugin.yaml",
|
|
1458
|
+
location: "plugin.yaml setup",
|
|
1459
|
+
suggestion: "Replace setup.handler with top-level setup: and setup.references with top-level agentkit:"
|
|
1460
|
+
});
|
|
1461
|
+
} else {
|
|
1462
|
+
validateHandlerRef(
|
|
1463
|
+
manifest.setup,
|
|
1464
|
+
"Setup handler",
|
|
1465
|
+
"plugin.yaml setup",
|
|
1466
|
+
context,
|
|
1467
|
+
result
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
if (manifest.agentkit) {
|
|
1472
|
+
validateHandlerRef(
|
|
1473
|
+
manifest.agentkit,
|
|
1474
|
+
"Agent-kit handler",
|
|
1475
|
+
"plugin.yaml agentkit",
|
|
1476
|
+
context,
|
|
1477
|
+
result
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
function checkExportExists(exportName, context) {
|
|
1482
|
+
const packageJsonPath = path.join(context.pluginPath, "package.json");
|
|
1483
|
+
if (!fs.existsSync(packageJsonPath))
|
|
1484
|
+
return true;
|
|
1485
|
+
let mainPath;
|
|
1486
|
+
try {
|
|
1487
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
1488
|
+
if (packageJson.exports?.["."]) {
|
|
1489
|
+
const entry = packageJson.exports["."];
|
|
1490
|
+
const entryPath = typeof entry === "string" ? entry : entry.default || entry.import;
|
|
1491
|
+
if (entryPath)
|
|
1492
|
+
mainPath = path.join(context.pluginPath, entryPath);
|
|
1493
|
+
}
|
|
1494
|
+
if (!mainPath && packageJson.main) {
|
|
1495
|
+
mainPath = path.join(context.pluginPath, packageJson.main);
|
|
1496
|
+
}
|
|
1497
|
+
} catch {
|
|
1498
|
+
return true;
|
|
1499
|
+
}
|
|
1500
|
+
if (!mainPath || !fs.existsSync(mainPath))
|
|
1501
|
+
return true;
|
|
1502
|
+
try {
|
|
1503
|
+
const content = fs.readFileSync(mainPath, "utf-8");
|
|
1504
|
+
const patterns = [
|
|
1505
|
+
new RegExp(`export\\s*\\{[^}]*\\b${exportName}\\b[^}]*\\}`, "m"),
|
|
1506
|
+
new RegExp(`export\\s+(?:async\\s+)?function\\s+${exportName}\\b`),
|
|
1507
|
+
new RegExp(`export\\s+(?:const|let|var)\\s+${exportName}\\b`)
|
|
1508
|
+
];
|
|
1509
|
+
return patterns.some((p) => p.test(content));
|
|
1510
|
+
} catch {
|
|
1511
|
+
return true;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function isRelativePath(value) {
|
|
1515
|
+
return value.startsWith("./") || value.startsWith("../");
|
|
1516
|
+
}
|
|
1517
|
+
function validateHandlerRef(value, label, location, context, result) {
|
|
1518
|
+
if (context.isNpmPackage) {
|
|
1519
|
+
if (isRelativePath(value)) {
|
|
1520
|
+
result.errors.push({
|
|
1521
|
+
type: "export-mismatch",
|
|
1522
|
+
message: `${label} "${value}" is a relative path, but NPM plugins must use an export name`,
|
|
1523
|
+
location,
|
|
1524
|
+
suggestion: `Export the function from the package entry point and use the export name instead of a path`
|
|
1525
|
+
});
|
|
1526
|
+
} else if (!checkExportExists(value, context)) {
|
|
1527
|
+
result.errors.push({
|
|
1528
|
+
type: "export-mismatch",
|
|
1529
|
+
message: `${label} "${value}" is not exported from the package`,
|
|
1530
|
+
location,
|
|
1531
|
+
suggestion: `Add "export { ${value} } from '...'" to the package entry point`
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
} else if (isRelativePath(value)) {
|
|
1535
|
+
const handlerPath = path.join(context.pluginPath, value);
|
|
1536
|
+
const extensions = ["", ".ts", ".js", "/index.ts", "/index.js"];
|
|
1537
|
+
const found = extensions.some((ext) => fs.existsSync(handlerPath + ext));
|
|
1538
|
+
if (!found) {
|
|
1539
|
+
result.errors.push({
|
|
1540
|
+
type: "file-missing",
|
|
1541
|
+
message: `${label} not found: ${value}`,
|
|
1542
|
+
location,
|
|
1543
|
+
suggestion: `Create the handler at ${handlerPath}.ts`
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
642
1547
|
}
|
|
643
1548
|
function resolveContractFile(contractSpec, context) {
|
|
644
1549
|
if (context.isNpmPackage) {
|
|
@@ -958,6 +1863,18 @@ async function validatePackageJson(context, result) {
|
|
|
958
1863
|
suggestion: 'Add "./plugin.yaml": "./plugin.yaml" to exports field'
|
|
959
1864
|
});
|
|
960
1865
|
}
|
|
1866
|
+
const agentKitDir = path.join(context.pluginPath, "agent-kit");
|
|
1867
|
+
if (fs.existsSync(agentKitDir) && fs.statSync(agentKitDir).isDirectory()) {
|
|
1868
|
+
const filesArray = packageJson.files;
|
|
1869
|
+
if (!filesArray || !filesArray.includes("agent-kit")) {
|
|
1870
|
+
result.warnings.push({
|
|
1871
|
+
type: "export-mismatch",
|
|
1872
|
+
message: 'agent-kit directory exists but is not listed in package.json "files"',
|
|
1873
|
+
location: packageJsonPath,
|
|
1874
|
+
suggestion: 'Add "agent-kit" to the "files" array so agent-kit files are shipped with the package'
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
961
1878
|
} catch (error) {
|
|
962
1879
|
result.errors.push({
|
|
963
1880
|
type: "schema",
|
|
@@ -1020,6 +1937,10 @@ async function validateDynamicContracts(context, result) {
|
|
|
1020
1937
|
}
|
|
1021
1938
|
}
|
|
1022
1939
|
export {
|
|
1940
|
+
ADD_MENU_VALIDATION_SUGGESTIONS,
|
|
1023
1941
|
checkComponentPropsAndParams,
|
|
1942
|
+
lintAddMenuCatalog,
|
|
1943
|
+
validateAddMenuCatalogFile,
|
|
1944
|
+
validateAddMenuItem,
|
|
1024
1945
|
validatePlugin
|
|
1025
1946
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/plugin-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "Validation tool for Jay Stack plugins",
|
|
@@ -21,19 +21,19 @@
|
|
|
21
21
|
"build:check-types": "tsc",
|
|
22
22
|
"clean": "rimraf dist",
|
|
23
23
|
"confirm": "npm run clean && npm run build && npm run build:check-types && npm run test",
|
|
24
|
-
"test": "
|
|
25
|
-
"test:watch": "
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@jay-framework/compiler-jay-html": "^0.
|
|
29
|
-
"@jay-framework/compiler-shared": "^0.
|
|
30
|
-
"@jay-framework/editor-protocol": "^0.
|
|
31
|
-
"@jay-framework/typescript-bridge": "^0.
|
|
28
|
+
"@jay-framework/compiler-jay-html": "^0.22.0",
|
|
29
|
+
"@jay-framework/compiler-shared": "^0.22.0",
|
|
30
|
+
"@jay-framework/editor-protocol": "^0.22.0",
|
|
31
|
+
"@jay-framework/typescript-bridge": "^0.22.0",
|
|
32
32
|
"chalk": "^4.1.2",
|
|
33
33
|
"yaml": "^2.3.4"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@jay-framework/dev-environment": "^0.
|
|
36
|
+
"@jay-framework/dev-environment": "^0.22.0",
|
|
37
37
|
"@types/node": "^22.15.21",
|
|
38
38
|
"rimraf": "^5.0.5",
|
|
39
39
|
"tsup": "^8.0.1",
|