@subbly/plugin-lint 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +91 -0
- package/bin/subbly-plugin-lint.mjs +54 -0
- package/index.mjs +11 -0
- package/package.json +45 -0
- package/src/cron.mjs +131 -0
- package/src/eslint-plugin.mjs +74 -0
- package/src/frontmatter.mjs +101 -0
- package/src/json-source.mjs +236 -0
- package/src/report.mjs +216 -0
- package/src/rules.mjs +1138 -0
package/src/rules.mjs
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
// The rules themselves.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors subbly-builder branch feat/plugins-mcp:
|
|
4
|
+
// packages/plugins/src/marketplace/{marketplace-manifest,plugin-manifest,mcp-config}.ts
|
|
5
|
+
// packages/core/src/types/plugin-manifest.ts
|
|
6
|
+
// packages/core/src/lib/automations/schedule.ts
|
|
7
|
+
//
|
|
8
|
+
// error = the refresh throws, so NO plugin in the marketplace releases.
|
|
9
|
+
// warn = the builder accepts it, but it is wrong, dead, or broken at runtime.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs'
|
|
12
|
+
import { join, basename, relative } from 'node:path'
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
import { Report } from './report.mjs'
|
|
16
|
+
import { parseJsonSource, locator } from './json-source.mjs'
|
|
17
|
+
import { readFrontmatter, lineOf, colOf } from './frontmatter.mjs'
|
|
18
|
+
import { parseCron, meetsFloor, CronUnsupported, SCHEDULE_FLOOR_MINUTES } from './cron.mjs'
|
|
19
|
+
|
|
20
|
+
// --- constants lifted verbatim from the builder -----------------------------
|
|
21
|
+
|
|
22
|
+
export const PLUGIN_SCHEMA_URL = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'
|
|
23
|
+
export const MCP_SCHEMA_URL = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'
|
|
24
|
+
export const NAMESPACE = 'co.subbly.builder'
|
|
25
|
+
|
|
26
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
27
|
+
const SLUG_MESSAGE =
|
|
28
|
+
'must be 1-64 lowercase alphanumerics with single - separators, starting and ending alphanumeric'
|
|
29
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
30
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[a-z0-9]+(\.[a-z0-9]+)*)?$/
|
|
31
|
+
const FIELD_KEY_PATTERN = /^[A-Z][A-Z0-9_]*$/
|
|
32
|
+
const CONNECTOR_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,49}$/
|
|
33
|
+
// The /i matters: skill and agent names allow uppercase and _, but never a dot.
|
|
34
|
+
const CONTENT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i
|
|
35
|
+
|
|
36
|
+
const MANIFEST_KEYS = ['$schema', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', 'extensions']
|
|
37
|
+
const NAMESPACE_KEYS = ['displayName', 'image', 'default', 'fields', 'automations', 'setup', 'connectors']
|
|
38
|
+
const FIELD_KEYS = ['key', 'label', 'type', 'required', 'secret', 'public', 'pattern', 'options']
|
|
39
|
+
const AUTOMATION_KEYS = ['name', 'schedule', 'model']
|
|
40
|
+
const AUTOMATION_MODELS = ['normal', 'intelligent-high']
|
|
41
|
+
const SERVER_KEYS = ['type', 'url', 'headers']
|
|
42
|
+
|
|
43
|
+
export const AGENT_TOOLS = ['read_file', 'write_file', 'edit_file', 'execute_command', 'get_stock_image', 'scrape_webpage', 'generate_image', 'restart_preview', 'apply_to_preview', 'mcp_run_tool', 'skill']
|
|
44
|
+
export const DEFAULT_ENV_KEYS = [
|
|
45
|
+
'SUBBLY_API_KEY',
|
|
46
|
+
'NEXT_PUBLIC_SUBBLY_API_KEY',
|
|
47
|
+
'NEXT_PUBLIC_SHOP_NAME',
|
|
48
|
+
'NEXT_PUBLIC_SHOP_CURRENCY',
|
|
49
|
+
'NEXT_PUBLIC_SUPPORT_EMAIL',
|
|
50
|
+
'NEXT_PUBLIC_SUPPORT_URL',
|
|
51
|
+
'NEXT_PUBLIC_TERMS_CONDITIONS_URL',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const MISPLACED = ['agents', 'automations', 'scripts', 'instructions.md']
|
|
55
|
+
|
|
56
|
+
// The builder-maintained symlink to the installed version, the only stable
|
|
57
|
+
// segment prose may use in a sandbox path.
|
|
58
|
+
export const CURRENT_LINK = 'current'
|
|
59
|
+
|
|
60
|
+
// --- helpers ---------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
63
|
+
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v)
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The literal environment variable a config field becomes.
|
|
67
|
+
* `PLUGIN_<SLUG>__<KEY>`, with NEXT_PUBLIC_ prepended to the whole name.
|
|
68
|
+
* The slug/key boundary is a double underscore, which a slug can never hold.
|
|
69
|
+
*/
|
|
70
|
+
export function envVarName(slug, key, isPublic = false) {
|
|
71
|
+
const name = `PLUGIN_${slug.replace(/-/g, '_').toUpperCase()}__${key}`
|
|
72
|
+
return isPublic ? `NEXT_PUBLIC_${name}` : name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readIfExists(path) {
|
|
76
|
+
try {
|
|
77
|
+
return readFileSync(path, 'utf8')
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function listDir(path) {
|
|
84
|
+
try {
|
|
85
|
+
return readdirSync(path, { withFileTypes: true })
|
|
86
|
+
} catch {
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reads a JSON file and returns its value plus a position locator. */
|
|
92
|
+
function readJson(path, file, report, ruleId, label) {
|
|
93
|
+
const raw = readIfExists(path)
|
|
94
|
+
if (raw === null) return { missing: true }
|
|
95
|
+
|
|
96
|
+
const parsed = parseJsonSource(raw)
|
|
97
|
+
if (!parsed.ok) {
|
|
98
|
+
report.error(ruleId, file, `Invalid JSON in ${label}: ${parsed.message}`, {
|
|
99
|
+
line: parsed.line,
|
|
100
|
+
col: parsed.col,
|
|
101
|
+
hint: 'no comments, no trailing commas, no JSON5',
|
|
102
|
+
})
|
|
103
|
+
return { invalid: true }
|
|
104
|
+
}
|
|
105
|
+
return { value: parsed.value, at: locator(parsed.root), raw }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A checker bound to one file, so rules read as assertions about a path. */
|
|
109
|
+
function checker(report, file, at) {
|
|
110
|
+
return {
|
|
111
|
+
string(ruleId, path, value, { min = 0, max = Infinity, trim = false, required = false, label = path } = {}) {
|
|
112
|
+
const pos = at(path)
|
|
113
|
+
if (value === undefined) {
|
|
114
|
+
if (required) report.error(ruleId, file, `${label} is required`, at(path === '' ? '' : path.split('.').slice(0, -1).join('.')))
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
if (typeof value !== 'string') {
|
|
118
|
+
report.error(ruleId, file, `${label}: expected string, received ${typeOf(value)}`, pos)
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
const measured = trim ? value.trim() : value
|
|
122
|
+
if (measured.length < min) {
|
|
123
|
+
report.error(ruleId, file, `${label}: too small, expected >=${min} characters`, pos)
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
if (measured.length > max) {
|
|
127
|
+
report.error(ruleId, file, `${label}: too big, expected <=${max} characters (has ${measured.length})`, pos)
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
return true
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
boolean(ruleId, path, value) {
|
|
134
|
+
if (value === undefined) return true
|
|
135
|
+
if (typeof value !== 'boolean') {
|
|
136
|
+
report.error(ruleId, file, `${path}: expected boolean, received ${typeOf(value)}`, at(path))
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
return true
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
/** Keys a strictObject would reject outright. */
|
|
143
|
+
strictKeys(ruleId, path, obj, allowed, label = path) {
|
|
144
|
+
for (const key of Object.keys(obj).filter((k) => !allowed.includes(k))) {
|
|
145
|
+
report.error(ruleId, file, `${label}: unrecognized key "${key}"`, {
|
|
146
|
+
...at(path ? `${path}.${key}` : key, { key: true }),
|
|
147
|
+
hint: `allowed: ${allowed.join(', ')}`,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/** Keys a plain z.object silently strips: accepted, but dead. */
|
|
153
|
+
looseKeys(ruleId, path, obj, allowed, label = path) {
|
|
154
|
+
for (const key of Object.keys(obj).filter((k) => !allowed.includes(k))) {
|
|
155
|
+
report.warn(ruleId, file, `${label}: unknown key "${key}" is silently stripped and does nothing`, {
|
|
156
|
+
...at(path ? `${path}.${key}` : key, { key: true }),
|
|
157
|
+
hint: `allowed: ${allowed.join(', ')}`,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- marketplace.json ------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
function lintMarketplaceManifest(root, report) {
|
|
167
|
+
const file = 'marketplace.json'
|
|
168
|
+
const path = join(root, file)
|
|
169
|
+
const doc = readJson(path, file, report, 'marketplace/parse', 'marketplace.json')
|
|
170
|
+
|
|
171
|
+
if (doc.missing) {
|
|
172
|
+
report.error('marketplace/parse', file, 'marketplace.json is missing from the repository root')
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
if (doc.invalid) return null
|
|
176
|
+
|
|
177
|
+
const { value: data, at } = doc
|
|
178
|
+
const check = checker(report, file, at)
|
|
179
|
+
|
|
180
|
+
if (!isObject(data)) {
|
|
181
|
+
report.error('marketplace/root-type', file, `expected object, received ${typeOf(data)}`, at(''))
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
check.looseKeys('marketplace/unknown-key', '', data, ['slug', 'version', 'plugins', '$schema'], '(root)')
|
|
186
|
+
|
|
187
|
+
if (check.string('marketplace/slug', 'slug', data.slug, { min: 1, max: 64, required: true })) {
|
|
188
|
+
if (!SLUG_PATTERN.test(data.slug)) report.error('marketplace/slug', file, `slug: ${SLUG_MESSAGE}`, at('slug'))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (check.string('marketplace/version', 'version', data.version, { max: 50, required: true })) {
|
|
192
|
+
if (!VERSION_PATTERN.test(data.version)) {
|
|
193
|
+
report.error(
|
|
194
|
+
'marketplace/version',
|
|
195
|
+
file,
|
|
196
|
+
'version must be X.Y.Z with an optional lowercase pre-release suffix like -beta or -beta.1',
|
|
197
|
+
at('version')
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (data.plugins === undefined) {
|
|
203
|
+
report.error('marketplace/plugins-type', file, 'plugins is required', at(''))
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
if (!Array.isArray(data.plugins)) {
|
|
207
|
+
report.error('marketplace/plugins-type', file, `plugins: expected array, received ${typeOf(data.plugins)}`, at('plugins'))
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
if (data.plugins.length === 0) {
|
|
211
|
+
report.warn('marketplace/plugins-empty', file, 'plugins is empty, which archives every plugin in this marketplace on release', at('plugins'))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const entries = []
|
|
215
|
+
const seen = new Set()
|
|
216
|
+
|
|
217
|
+
data.plugins.forEach((entry, i) => {
|
|
218
|
+
const base = `plugins[${i}]`
|
|
219
|
+
if (!isObject(entry)) {
|
|
220
|
+
report.error('marketplace/entry-slug', file, `${base}: expected object, received ${typeOf(entry)}`, at(base))
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
check.looseKeys('marketplace/unknown-key', base, entry, ['slug', 'published', 'source'])
|
|
224
|
+
|
|
225
|
+
let slug = null
|
|
226
|
+
if (check.string('marketplace/entry-slug', `${base}.slug`, entry.slug, { min: 1, max: 64, required: true })) {
|
|
227
|
+
if (SLUG_PATTERN.test(entry.slug)) slug = entry.slug
|
|
228
|
+
else report.error('marketplace/entry-slug', file, `${base}.slug: ${SLUG_MESSAGE}`, at(`${base}.slug`))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (slug) {
|
|
232
|
+
if (seen.has(slug)) {
|
|
233
|
+
report.error('marketplace/slug-collision', file, 'plugin slugs must be unique', at(`${base}.slug`))
|
|
234
|
+
} else {
|
|
235
|
+
seen.add(slug)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
check.boolean('marketplace/published', `${base}.published`, entry.published)
|
|
240
|
+
|
|
241
|
+
if (!isObject(entry.source)) {
|
|
242
|
+
report.error('marketplace/source-type', file, `${base}.source: expected object, received ${typeOf(entry.source)}`, at(base))
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
check.looseKeys('marketplace/unknown-key', `${base}.source`, entry.source, ['type', 'path'])
|
|
246
|
+
|
|
247
|
+
if (entry.source.type !== 'local') {
|
|
248
|
+
report.error(
|
|
249
|
+
'marketplace/source-type',
|
|
250
|
+
file,
|
|
251
|
+
`${base}.source.type: expected "local", received ${JSON.stringify(entry.source.type)}`,
|
|
252
|
+
at(`${base}.source.type`)
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let sourcePath = null
|
|
257
|
+
if (check.string('marketplace/source-path', `${base}.source.path`, entry.source.path, { min: 1, max: 200, required: true })) {
|
|
258
|
+
if (!entry.source.path.split('/').every((s) => SAFE_SEGMENT.test(s))) {
|
|
259
|
+
report.error('marketplace/source-path', file, `${base}.source.path: path must be made of safe segments`, {
|
|
260
|
+
...at(`${base}.source.path`),
|
|
261
|
+
hint: 'no leading /, no .., no empty or dot-leading segment',
|
|
262
|
+
})
|
|
263
|
+
} else {
|
|
264
|
+
sourcePath = entry.source.path
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (slug && sourcePath) {
|
|
269
|
+
if (basename(sourcePath) !== slug) {
|
|
270
|
+
report.warn(
|
|
271
|
+
'marketplace/path-convention',
|
|
272
|
+
file,
|
|
273
|
+
`${base}.source.path basename "${basename(sourcePath)}" does not match slug "${slug}"`,
|
|
274
|
+
{ ...at(`${base}.source.path`), hint: 'house convention is plugins/<slug>' }
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
entries.push({ slug, sourcePath, dir: join(root, sourcePath), at: at(`${base}.source.path`) })
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
if (!existsSync(entry.dir)) {
|
|
283
|
+
report.error('marketplace/source-missing', file, `source.path "${entry.sourcePath}" does not exist on disk`, entry.at)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const listed = new Set(entries.map((e) => e.sourcePath))
|
|
288
|
+
for (const dirent of listDir(join(root, 'plugins'))) {
|
|
289
|
+
if (!dirent.isDirectory()) continue
|
|
290
|
+
const candidate = `plugins/${dirent.name}`
|
|
291
|
+
if (!listed.has(candidate)) {
|
|
292
|
+
report.warn('marketplace/unlisted-directory', file, `directory ${candidate} is not listed in marketplace.json, so it never ships`, at('plugins'))
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { version: data.version, entries }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// --- plugin.json -----------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function lintPluginManifest(root, entry, report) {
|
|
302
|
+
const path = join(entry.dir, 'plugin.json')
|
|
303
|
+
const file = relative(root, path)
|
|
304
|
+
const doc = readJson(path, file, report, 'manifest/parse', `plugin.json of ${entry.slug}`)
|
|
305
|
+
|
|
306
|
+
if (doc.missing) {
|
|
307
|
+
report.error('manifest/missing', file, `plugin.json of ${entry.slug} is missing`, {
|
|
308
|
+
hint: 'every marketplace entry needs one at <source.path>/plugin.json',
|
|
309
|
+
})
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
if (doc.invalid) return null
|
|
313
|
+
|
|
314
|
+
const { value: data, at } = doc
|
|
315
|
+
const check = checker(report, file, at)
|
|
316
|
+
|
|
317
|
+
if (!isObject(data)) {
|
|
318
|
+
report.error('manifest/parse', file, `expected object, received ${typeOf(data)}`, at(''))
|
|
319
|
+
return null
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
check.strictKeys('manifest/unknown-key', '', data, MANIFEST_KEYS, '(root)')
|
|
323
|
+
|
|
324
|
+
if (data.$schema !== PLUGIN_SCHEMA_URL) {
|
|
325
|
+
report.error('manifest/schema-url', file, `$schema: expected ${JSON.stringify(PLUGIN_SCHEMA_URL)}`, at('$schema'))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (check.string('manifest/name-slug', 'name', data.name, { min: 1, max: 64, required: true })) {
|
|
329
|
+
if (!SLUG_PATTERN.test(data.name)) {
|
|
330
|
+
report.error('manifest/name-slug', file, `name: ${SLUG_MESSAGE}`, at('name'))
|
|
331
|
+
} else if (data.name !== entry.slug) {
|
|
332
|
+
report.error(
|
|
333
|
+
'manifest/name-slug',
|
|
334
|
+
file,
|
|
335
|
+
`name ${JSON.stringify(data.name)} must equal the marketplace entry slug ${JSON.stringify(entry.slug)}`,
|
|
336
|
+
at('name')
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (data.version !== undefined) {
|
|
342
|
+
report.warn('manifest/version-discarded', file, 'version is accepted and then discarded; marketplace.json version is the only release trigger', {
|
|
343
|
+
...at('version', { key: true }),
|
|
344
|
+
hint: 'remove it',
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (data.description === undefined) {
|
|
349
|
+
report.error('manifest/description', file, 'description is required', at(''))
|
|
350
|
+
} else {
|
|
351
|
+
check.string('manifest/description', 'description', data.description, { min: 1, max: 500, trim: true })
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (data.author !== undefined) {
|
|
355
|
+
if (!isObject(data.author)) {
|
|
356
|
+
report.error('manifest/author', file, `author: expected object, received ${typeOf(data.author)}`, at('author'))
|
|
357
|
+
} else {
|
|
358
|
+
check.strictKeys('manifest/author', 'author', data.author, ['name', 'email', 'url'])
|
|
359
|
+
check.string('manifest/author', 'author.name', data.author.name, { min: 1, max: 100, trim: true, required: true })
|
|
360
|
+
check.string('manifest/author', 'author.email', data.author.email, { max: 200, trim: true })
|
|
361
|
+
check.string('manifest/author', 'author.url', data.author.url, { max: 500, trim: true })
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
check.string('manifest/string-length', 'homepage', data.homepage, { max: 500, trim: true })
|
|
366
|
+
check.string('manifest/string-length', 'repository', data.repository, { max: 500, trim: true })
|
|
367
|
+
check.string('manifest/string-length', 'license', data.license, { max: 100, trim: true })
|
|
368
|
+
|
|
369
|
+
if (data.keywords !== undefined) {
|
|
370
|
+
if (!Array.isArray(data.keywords)) {
|
|
371
|
+
report.error('manifest/keywords', file, `keywords: expected array, received ${typeOf(data.keywords)}`, at('keywords'))
|
|
372
|
+
} else {
|
|
373
|
+
if (data.keywords.length > 20) {
|
|
374
|
+
report.error('manifest/keywords', file, `keywords: too big, expected <=20 items (has ${data.keywords.length})`, at('keywords'))
|
|
375
|
+
}
|
|
376
|
+
data.keywords.forEach((k, i) => check.string('manifest/keywords', `keywords[${i}]`, k, { min: 1, max: 50, trim: true }))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (data.extensions !== undefined && !isObject(data.extensions)) {
|
|
381
|
+
report.error('manifest/namespace-required', file, `extensions: expected object, received ${typeOf(data.extensions)}`, at('extensions'))
|
|
382
|
+
return null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const ext = data.extensions?.[NAMESPACE]
|
|
386
|
+
if (ext === undefined) {
|
|
387
|
+
report.error('manifest/namespace-required', file, `extensions["${NAMESPACE}"] is required`, at('extensions'))
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
const ns = `extensions.${NAMESPACE}`
|
|
391
|
+
if (!isObject(ext)) {
|
|
392
|
+
report.error('manifest/namespace-required', file, `extensions["${NAMESPACE}"]: expected object, received ${typeOf(ext)}`, at(ns))
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
check.strictKeys('manifest/namespace-unknown-key', ns, ext, NAMESPACE_KEYS, `extensions["${NAMESPACE}"]`)
|
|
397
|
+
check.string('manifest/display-name', `${ns}.displayName`, ext.displayName, {
|
|
398
|
+
min: 1, max: 100, trim: true, required: true, label: 'displayName',
|
|
399
|
+
})
|
|
400
|
+
check.string('manifest/image', `${ns}.image`, ext.image, { min: 1, max: 500, label: 'image' })
|
|
401
|
+
check.boolean('manifest/boolean', `${ns}.default`, ext.default)
|
|
402
|
+
check.boolean('manifest/boolean', `${ns}.setup`, ext.setup)
|
|
403
|
+
|
|
404
|
+
const fields = lintFields(report, file, at, check, ns, ext, entry.slug)
|
|
405
|
+
const automations = lintAutomationDeclarations(report, file, at, check, ns, ext)
|
|
406
|
+
const connectors = lintConnectors(report, file, at, check, ns, ext)
|
|
407
|
+
|
|
408
|
+
if (ext.default === true && fields.some((f) => f.required === true)) {
|
|
409
|
+
report.error(
|
|
410
|
+
'field/default-required',
|
|
411
|
+
file,
|
|
412
|
+
'a default plugin cannot declare required config fields, no user is present to fill them',
|
|
413
|
+
at(`${ns}.default`)
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { fieldKeys: fields.map((f) => f.key).filter(Boolean), automations, connectors, setup: ext.setup === true }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function lintFields(report, file, at, check, ns, ext, slug) {
|
|
421
|
+
if (ext.fields === undefined) return []
|
|
422
|
+
if (!Array.isArray(ext.fields)) {
|
|
423
|
+
report.error('field/type', file, `${ns}.fields: expected array, received ${typeOf(ext.fields)}`, at(`${ns}.fields`))
|
|
424
|
+
return []
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const seen = new Map()
|
|
428
|
+
ext.fields.forEach((field, i) => {
|
|
429
|
+
const p = `${ns}.fields[${i}]`
|
|
430
|
+
const label = `fields[${i}]`
|
|
431
|
+
if (!isObject(field)) {
|
|
432
|
+
report.error('field/type', file, `${label}: expected object, received ${typeOf(field)}`, at(p))
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
check.looseKeys('manifest/namespace-unknown-key', p, field, FIELD_KEYS, label)
|
|
436
|
+
|
|
437
|
+
if (check.string('field/key-format', `${p}.key`, field.key, { max: 50, required: true, label: `${label}.key` })) {
|
|
438
|
+
if (!FIELD_KEY_PATTERN.test(field.key)) {
|
|
439
|
+
report.error('field/key-format', file, `${label}.key "${field.key}" must match /^[A-Z][A-Z0-9_]*$/`, {
|
|
440
|
+
...at(`${p}.key`),
|
|
441
|
+
hint: 'uppercase, digits and underscores only, starting with a letter',
|
|
442
|
+
})
|
|
443
|
+
} else if (seen.has(field.key)) {
|
|
444
|
+
report.warn('field/key-duplicate', file, `${label}.key "${field.key}" duplicates fields[${seen.get(field.key)}]`, {
|
|
445
|
+
...at(`${p}.key`),
|
|
446
|
+
hint: 'both map to one env var, so the later declaration is silently ignored',
|
|
447
|
+
})
|
|
448
|
+
} else {
|
|
449
|
+
seen.set(field.key, i)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
check.string('field/label', `${p}.label`, field.label, { min: 1, max: 100, required: true, label: `${label}.label` })
|
|
454
|
+
|
|
455
|
+
if (field.type !== 'text' && field.type !== 'select') {
|
|
456
|
+
report.error('field/type', file, `${label}.type: expected "text" or "select", received ${JSON.stringify(field.type)}`, at(`${p}.type`))
|
|
457
|
+
}
|
|
458
|
+
check.boolean('manifest/boolean', `${p}.required`, field.required)
|
|
459
|
+
check.boolean('manifest/boolean', `${p}.secret`, field.secret)
|
|
460
|
+
check.boolean('manifest/boolean', `${p}.public`, field.public)
|
|
461
|
+
check.string('field/pattern', `${p}.pattern`, field.pattern, { max: 200, label: `${label}.pattern` })
|
|
462
|
+
|
|
463
|
+
if (field.options !== undefined) {
|
|
464
|
+
if (!Array.isArray(field.options)) {
|
|
465
|
+
report.error('field/options', file, `${label}.options: expected array, received ${typeOf(field.options)}`, at(`${p}.options`))
|
|
466
|
+
} else {
|
|
467
|
+
field.options.forEach((o, j) =>
|
|
468
|
+
check.string('field/options', `${p}.options[${j}]`, o, { min: 1, max: 100, label: `${label}.options[${j}]` })
|
|
469
|
+
)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (field.public && field.secret) {
|
|
474
|
+
report.error('field/public-secret', file, `${label}: public and secret are mutually exclusive`, at(`${p}.public`))
|
|
475
|
+
}
|
|
476
|
+
if (field.options !== undefined && field.type !== 'select') {
|
|
477
|
+
report.error('field/options', file, `${label}: options are only allowed on select fields`, at(`${p}.options`))
|
|
478
|
+
}
|
|
479
|
+
if (field.type === 'select' && (field.options === undefined || field.options.length === 0)) {
|
|
480
|
+
report.error('field/options', file, `${label}: select fields require options`, at(`${p}.type`))
|
|
481
|
+
}
|
|
482
|
+
if (field.public && field.secret !== true && /KEY|TOKEN|SECRET|PASSWORD/.test(field.key ?? '')) {
|
|
483
|
+
report.warn('field/public-credential', file, `${label}.key "${field.key}" is public, so its value ships into the browser bundle`, {
|
|
484
|
+
...at(`${p}.public`),
|
|
485
|
+
hint: 'never mark a credential public',
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The builder already prepends PLUGIN_<SLUG>_, so a key that repeats it
|
|
490
|
+
// produces PLUGIN_ACME_PLUGIN_ACME_TOKEN.
|
|
491
|
+
if (typeof field.key === 'string' && /^(NEXT_PUBLIC_)?PLUGIN_/.test(field.key)) {
|
|
492
|
+
report.warn('field/reserved-key', file, `${label}.key "${field.key}" restates a prefix the builder adds itself`, {
|
|
493
|
+
...at(`${p}.key`),
|
|
494
|
+
hint: `it becomes ${envVarName(slug, field.key, field.public === true)}`,
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
return ext.fields.filter(isObject)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function lintAutomationDeclarations(report, file, at, check, ns, ext) {
|
|
504
|
+
if (ext.automations === undefined) return []
|
|
505
|
+
if (!isObject(ext.automations)) {
|
|
506
|
+
report.error('automation/key-slug', file, `${ns}.automations: expected object keyed by slug, received ${typeOf(ext.automations)}`, at(`${ns}.automations`))
|
|
507
|
+
return []
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const slugs = []
|
|
511
|
+
for (const [slug, decl] of Object.entries(ext.automations)) {
|
|
512
|
+
const p = `${ns}.automations.${slug}`
|
|
513
|
+
const label = `automations["${slug}"]`
|
|
514
|
+
|
|
515
|
+
if (slug.length < 1 || slug.length > 64 || !SLUG_PATTERN.test(slug)) {
|
|
516
|
+
report.error('automation/key-slug', file, `${label}: key ${SLUG_MESSAGE}`, at(p, { key: true }))
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
if (!isObject(decl)) {
|
|
520
|
+
report.error('automation/unknown-key', file, `${label}: expected object, received ${typeOf(decl)}`, at(p))
|
|
521
|
+
continue
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
check.strictKeys('automation/unknown-key', p, decl, AUTOMATION_KEYS, label)
|
|
525
|
+
check.string('automation/name', `${p}.name`, decl.name, { min: 1, max: 100, trim: true, required: true, label: `${label}.name` })
|
|
526
|
+
|
|
527
|
+
if (check.string('automation/schedule-syntax', `${p}.schedule`, decl.schedule, { min: 1, max: 100, required: true, label: `${label}.schedule` })) {
|
|
528
|
+
lintSchedule(report, file, at(`${p}.schedule`), label, decl.schedule)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!AUTOMATION_MODELS.includes(decl.model)) {
|
|
532
|
+
report.error(
|
|
533
|
+
'automation/model',
|
|
534
|
+
file,
|
|
535
|
+
`${label}.model: expected ${AUTOMATION_MODELS.join(' | ')}, received ${JSON.stringify(decl.model)}`,
|
|
536
|
+
at(`${p}.model`)
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
slugs.push(slug)
|
|
540
|
+
}
|
|
541
|
+
return slugs
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function lintSchedule(report, file, pos, label, schedule) {
|
|
545
|
+
let cron
|
|
546
|
+
try {
|
|
547
|
+
cron = parseCron(schedule)
|
|
548
|
+
} catch (e) {
|
|
549
|
+
if (e instanceof CronUnsupported) {
|
|
550
|
+
report.warn('automation/schedule-unverified', file, `${label}.schedule contains "${e.message}", which cannot be checked offline`, {
|
|
551
|
+
...pos,
|
|
552
|
+
hint: 'the builder may accept it; confirm the 15-minute floor by hand',
|
|
553
|
+
})
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
report.error('automation/schedule-syntax', file, `${label}.schedule: invalid cron expression (${e.message})`, pos)
|
|
557
|
+
return
|
|
558
|
+
}
|
|
559
|
+
if (!meetsFloor(cron)) {
|
|
560
|
+
report.error('automation/schedule-floor', file, `${label}.schedule must fire at most every ${SCHEDULE_FLOOR_MINUTES} minutes`, pos)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function lintConnectors(report, file, at, check, ns, ext) {
|
|
565
|
+
if (ext.connectors === undefined) return []
|
|
566
|
+
if (!isObject(ext.connectors)) {
|
|
567
|
+
report.error('connector/auth', file, `${ns}.connectors: expected object, received ${typeOf(ext.connectors)}`, at(`${ns}.connectors`))
|
|
568
|
+
return []
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const names = []
|
|
572
|
+
for (const [name, marker] of Object.entries(ext.connectors)) {
|
|
573
|
+
const p = `${ns}.connectors.${name}`
|
|
574
|
+
const label = `connectors["${name}"]`
|
|
575
|
+
|
|
576
|
+
if (name.length < 1 || name.length > 100) {
|
|
577
|
+
report.error('connector/name-length', file, `${label}: connector name must be 1-100 characters`, at(p, { key: true }))
|
|
578
|
+
}
|
|
579
|
+
if (!isObject(marker)) {
|
|
580
|
+
report.error('connector/auth', file, `${label}: expected object, received ${typeOf(marker)}`, at(p))
|
|
581
|
+
continue
|
|
582
|
+
}
|
|
583
|
+
check.strictKeys('connector/auth', p, marker, ['auth'], label)
|
|
584
|
+
if (marker.auth !== 'oauth') {
|
|
585
|
+
report.error('connector/auth', file, `${label}.auth: expected "oauth", received ${JSON.stringify(marker.auth)}`, at(`${p}.auth`))
|
|
586
|
+
}
|
|
587
|
+
names.push(name)
|
|
588
|
+
}
|
|
589
|
+
return names
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- mcp.json --------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
function lintMcp(root, entry, manifest, report) {
|
|
595
|
+
const path = join(entry.dir, 'mcp.json')
|
|
596
|
+
const file = relative(root, path)
|
|
597
|
+
const connectors = manifest?.connectors ?? []
|
|
598
|
+
const doc = readJson(path, file, report, 'mcp/parse', `mcp.json of ${entry.slug}`)
|
|
599
|
+
|
|
600
|
+
if (doc.missing) {
|
|
601
|
+
if (connectors.length > 0) {
|
|
602
|
+
report.error(
|
|
603
|
+
'connector/no-mcp',
|
|
604
|
+
relative(root, join(entry.dir, 'plugin.json')),
|
|
605
|
+
`plugin.json declares connector ${connectors[0]}, but the plugin has no mcp.json`
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
if (doc.invalid) return
|
|
611
|
+
|
|
612
|
+
const { value: data, at } = doc
|
|
613
|
+
const check = checker(report, file, at)
|
|
614
|
+
|
|
615
|
+
if (!isObject(data)) {
|
|
616
|
+
report.error('mcp/parse', file, `expected object, received ${typeOf(data)}`, at(''))
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
check.strictKeys('mcp/unknown-key', '', data, ['$schema', 'mcpServers'], '(root)')
|
|
621
|
+
if (data.$schema !== MCP_SCHEMA_URL) {
|
|
622
|
+
report.error('mcp/schema-url', file, `$schema: expected ${JSON.stringify(MCP_SCHEMA_URL)}`, at('$schema'))
|
|
623
|
+
}
|
|
624
|
+
if (!isObject(data.mcpServers)) {
|
|
625
|
+
report.error('mcp/servers-type', file, `mcpServers: expected object, received ${typeOf(data.mcpServers)}`, at('mcpServers'))
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const declaredKeys = new Set([...(manifest?.fieldKeys ?? []), ...DEFAULT_ENV_KEYS])
|
|
630
|
+
const serverNames = new Set()
|
|
631
|
+
|
|
632
|
+
for (const [name, server] of Object.entries(data.mcpServers)) {
|
|
633
|
+
const p = `mcpServers.${name}`
|
|
634
|
+
const label = `mcpServers["${name}"]`
|
|
635
|
+
serverNames.add(name)
|
|
636
|
+
|
|
637
|
+
// The builder already addresses connectors as <slug>__<name>, so a name
|
|
638
|
+
// that restates the prefix produces alpha__alpha__api.
|
|
639
|
+
const prefix = `${entry.slug.replace(/-/g, '_')}__`
|
|
640
|
+
if (name.startsWith(prefix)) {
|
|
641
|
+
report.warn('connector/manual-prefix', file, `server name "${name}" restates the prefix the builder adds itself`, {
|
|
642
|
+
...at(p, { key: true }),
|
|
643
|
+
hint: `the builder namespaces it to ${prefix}${name}; drop the prefix`,
|
|
644
|
+
})
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (!CONNECTOR_NAME_PATTERN.test(name)) {
|
|
648
|
+
report.error('mcp/server-name', file, `${label}: name must match /^[a-z][a-z0-9_-]{0,49}$/`, {
|
|
649
|
+
...at(p, { key: true }),
|
|
650
|
+
hint: 'lowercase, no dots, starting with a letter',
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
if (!isObject(server)) {
|
|
654
|
+
report.error('mcp/server-type', file, `${label}: expected object, received ${typeOf(server)}`, at(p))
|
|
655
|
+
continue
|
|
656
|
+
}
|
|
657
|
+
check.strictKeys('mcp/unknown-key', p, server, SERVER_KEYS, label)
|
|
658
|
+
|
|
659
|
+
if (server.type !== 'streamable-http') {
|
|
660
|
+
report.error('mcp/server-type', file, `${label}.type: expected "streamable-http", received ${JSON.stringify(server.type)}`, {
|
|
661
|
+
...at(`${p}.type`),
|
|
662
|
+
hint: 'stdio and command-based servers cannot ship',
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (check.string('mcp/url', `${p}.url`, server.url, { min: 1, max: 500, required: true, label: `${label}.url` })) {
|
|
667
|
+
try {
|
|
668
|
+
new URL(server.url)
|
|
669
|
+
} catch {
|
|
670
|
+
report.error('mcp/url', file, `${label}.url is not a valid URL`, at(`${p}.url`))
|
|
671
|
+
}
|
|
672
|
+
if (!server.url.startsWith('https://')) {
|
|
673
|
+
report.error('mcp/https-only', file, `${label}.url must be an https url`, at(`${p}.url`))
|
|
674
|
+
}
|
|
675
|
+
if (/\$\{[^}]+\}/.test(server.url)) {
|
|
676
|
+
report.warn('mcp/url-interpolation', file, `${label}.url contains \${...}, which is never expanded and is sent literally`, at(`${p}.url`))
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (server.headers !== undefined) {
|
|
681
|
+
if (!isObject(server.headers)) {
|
|
682
|
+
report.error('mcp/header-shape', file, `${label}.headers: expected object, received ${typeOf(server.headers)}`, at(`${p}.headers`))
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
for (const [header, value] of Object.entries(server.headers)) {
|
|
686
|
+
const hp = `${p}.headers.${header}`
|
|
687
|
+
const hlabel = `${label}.headers["${header}"]`
|
|
688
|
+
if (header.length < 1 || header.length > 100) {
|
|
689
|
+
report.error('mcp/header-shape', file, `${hlabel}: header name must be 1-100 characters`, at(hp, { key: true }))
|
|
690
|
+
}
|
|
691
|
+
if (!check.string('mcp/header-shape', hp, value, { min: 1, max: 1000, label: hlabel })) continue
|
|
692
|
+
|
|
693
|
+
for (const match of value.matchAll(/\$\{([A-Z][A-Z0-9_]*)\}/g)) {
|
|
694
|
+
if (!declaredKeys.has(match[1])) {
|
|
695
|
+
report.error('mcp/undeclared-env', file, `server ${name} references config field ${match[1]}, which plugin.json does not declare`, {
|
|
696
|
+
...at(hp),
|
|
697
|
+
hint: `declare it under extensions["${NAMESPACE}"].fields, or use a builder default: ${DEFAULT_ENV_KEYS.join(', ')}`,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const match of value.matchAll(/\$\{([^}]*)\}/g)) {
|
|
702
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(match[1])) {
|
|
703
|
+
report.warn('mcp/lowercase-interpolation', file, `${hlabel}: \${${match[1]}} is not uppercase, so it is never expanded and is sent literally`, at(hp))
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const connector of connectors) {
|
|
711
|
+
if (!serverNames.has(connector)) {
|
|
712
|
+
report.error('connector/undefined-server', file, `plugin.json declares connector ${connector}, which mcp.json does not define`, at('mcpServers'))
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// --- skills ----------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
function lintSkills(root, entry, report) {
|
|
720
|
+
const skillsDir = join(entry.dir, 'skills')
|
|
721
|
+
if (!existsSync(skillsDir)) return
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
for (const dirent of listDir(skillsDir)) {
|
|
725
|
+
if (!dirent.isDirectory()) {
|
|
726
|
+
if (dirent.name.endsWith('.md')) {
|
|
727
|
+
report.warn('skill/loose-file', relative(root, join(skillsDir, dirent.name)), 'a loose file directly under skills/ is never loaded', {
|
|
728
|
+
hint: 'move it into skills/<name>/',
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const dir = join(skillsDir, dirent.name)
|
|
735
|
+
const skillPath = join(dir, 'SKILL.md')
|
|
736
|
+
const file = relative(root, skillPath)
|
|
737
|
+
|
|
738
|
+
if (!existsSync(skillPath)) {
|
|
739
|
+
report.error('skill/missing-file', relative(root, dir), `Invalid skills of ${entry.slug}: skill ${dirent.name} is missing SKILL.md`, {
|
|
740
|
+
hint: 'every directory under skills/ needs one, case-sensitive',
|
|
741
|
+
})
|
|
742
|
+
continue
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const text = readIfExists(skillPath) ?? ''
|
|
746
|
+
const fm = readFrontmatter(text)
|
|
747
|
+
|
|
748
|
+
if (fm.duplicate) {
|
|
749
|
+
report.error('skill/frontmatter', file, `frontmatter is unparseable: duplicated mapping key "${fm.duplicate}"`, { line: fm.duplicateLine })
|
|
750
|
+
continue
|
|
751
|
+
}
|
|
752
|
+
if (fm.unsupported) {
|
|
753
|
+
report.error('skill/frontmatter', file, `frontmatter line cannot be validated: "${fm.unsupported}"`, {
|
|
754
|
+
line: fm.unsupportedLine,
|
|
755
|
+
hint: 'keep frontmatter to flat key: value pairs and simple lists',
|
|
756
|
+
})
|
|
757
|
+
continue
|
|
758
|
+
}
|
|
759
|
+
if (fm.unterminated) {
|
|
760
|
+
report.error('skill/frontmatter', file, 'the frontmatter block is never closed', { line: 1, hint: 'close it with --- on its own line' })
|
|
761
|
+
continue
|
|
762
|
+
}
|
|
763
|
+
if (!fm.fenced) {
|
|
764
|
+
report.error('skill/frontmatter', file, 'no frontmatter found, so description is missing', {
|
|
765
|
+
line: 1,
|
|
766
|
+
hint: 'line 1 must be exactly --- with no blank line or indentation before it',
|
|
767
|
+
})
|
|
768
|
+
continue
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const attrs = fm.attributes
|
|
772
|
+
const descLine = fm.keyLines.description ?? 1
|
|
773
|
+
|
|
774
|
+
if (attrs.description === undefined || attrs.description === null) {
|
|
775
|
+
report.error('skill/description', file, `Invalid skill ${dirent.name}: description is required`, { line: 1 })
|
|
776
|
+
} else if (typeof attrs.description !== 'string') {
|
|
777
|
+
report.error('skill/description', file, 'description must be a string', { line: descLine })
|
|
778
|
+
} else if (attrs.description.length < 1 || attrs.description.length > 500) {
|
|
779
|
+
report.error('skill/description', file, `description must be 1-500 characters (has ${attrs.description.length})`, { line: descLine })
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const name = dirent.name
|
|
783
|
+
const nameLine = fm.keyLines.name ?? 1
|
|
784
|
+
|
|
785
|
+
if (name.length > 50) {
|
|
786
|
+
report.error('skill/name-format', file, `skill name is too long, expected <=50 characters`, { line: nameLine })
|
|
787
|
+
}
|
|
788
|
+
if (!CONTENT_NAME_PATTERN.test(name)) {
|
|
789
|
+
report.error('skill/name-format', file, `invalid skill name ${JSON.stringify(name)}`, {
|
|
790
|
+
line: nameLine,
|
|
791
|
+
hint: 'letters, digits, - and _ only; no dots, no spaces, no colons',
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (attrs.name === undefined) {
|
|
796
|
+
report.error('skill/name-mismatch', file, 'name is required and must equal the directory name', {
|
|
797
|
+
line: 1,
|
|
798
|
+
hint: `add "name: ${dirent.name}"`,
|
|
799
|
+
})
|
|
800
|
+
} else if (attrs.name !== dirent.name) {
|
|
801
|
+
report.error('skill/name-mismatch', file, `name ${JSON.stringify(attrs.name)} must equal the directory name ${JSON.stringify(dirent.name)}`, {
|
|
802
|
+
line: nameLine,
|
|
803
|
+
hint: 'the directory is the skill name',
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
for (const [key, line] of Object.entries(fm.keyLines)) {
|
|
807
|
+
if (key !== 'name' && key !== 'description') {
|
|
808
|
+
report.warn('skill/extra-frontmatter', file, `frontmatter key "${key}" is ignored by the builder but still shipped to the model on every use`, { line })
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (fm.body.trim() === '') {
|
|
812
|
+
report.warn('skill/empty-body', file, 'the skill body is empty, so the skill teaches the model nothing', { line: fm.bodyLine })
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// --- agents ----------------------------------------------------------------
|
|
818
|
+
|
|
819
|
+
function lintAgents(root, entry, report) {
|
|
820
|
+
const agentsDir = join(entry.dir, NAMESPACE, 'agents')
|
|
821
|
+
if (!existsSync(agentsDir)) return
|
|
822
|
+
|
|
823
|
+
for (const dirent of listDir(agentsDir)) {
|
|
824
|
+
if (!dirent.isDirectory()) {
|
|
825
|
+
if (dirent.name.endsWith('.md')) {
|
|
826
|
+
report.warn('agent/loose-file', relative(root, join(agentsDir, dirent.name)), 'a loose file directly under agents/ is never loaded', {
|
|
827
|
+
hint: 'move it into agents/<name>/AGENT.md',
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
continue
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const dir = join(agentsDir, dirent.name)
|
|
834
|
+
const path = join(dir, 'AGENT.md')
|
|
835
|
+
const file = relative(root, path)
|
|
836
|
+
|
|
837
|
+
if (!existsSync(path)) {
|
|
838
|
+
report.error('agent/missing-file', relative(root, dir), `Invalid agents of ${entry.slug}: agent ${dirent.name} is missing AGENT.md`, {
|
|
839
|
+
hint: 'every directory under agents/ needs one, case-sensitive',
|
|
840
|
+
})
|
|
841
|
+
continue
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const name = dirent.name
|
|
845
|
+
if (name.length > 50) {
|
|
846
|
+
report.error('agent/name-format', file, 'the agent directory name is too long, expected <=50 characters', { line: 1 })
|
|
847
|
+
}
|
|
848
|
+
if (!CONTENT_NAME_PATTERN.test(name)) {
|
|
849
|
+
report.error('agent/name-format', file, `Invalid agent ${name}: invalid agent name`, {
|
|
850
|
+
line: 1,
|
|
851
|
+
hint: 'the directory is the agent name: letters, digits, - and _ only, no dots',
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const text = readIfExists(path) ?? ''
|
|
856
|
+
const fm = readFrontmatter(text)
|
|
857
|
+
|
|
858
|
+
if (fm.duplicate) {
|
|
859
|
+
report.error('agent/frontmatter', file, `frontmatter is unparseable: duplicated mapping key "${fm.duplicate}"`, { line: fm.duplicateLine })
|
|
860
|
+
continue
|
|
861
|
+
}
|
|
862
|
+
if (fm.unsupported) {
|
|
863
|
+
report.error('agent/frontmatter', file, `frontmatter line cannot be validated: "${fm.unsupported}"`, {
|
|
864
|
+
line: fm.unsupportedLine,
|
|
865
|
+
hint: 'keep frontmatter to flat key: value pairs and simple lists',
|
|
866
|
+
})
|
|
867
|
+
continue
|
|
868
|
+
}
|
|
869
|
+
if (fm.unterminated) {
|
|
870
|
+
report.error('agent/frontmatter', file, 'the frontmatter block is never closed', { line: 1 })
|
|
871
|
+
continue
|
|
872
|
+
}
|
|
873
|
+
if (!fm.fenced) {
|
|
874
|
+
report.error('agent/frontmatter', file, 'no frontmatter found, so description is missing', {
|
|
875
|
+
line: 1,
|
|
876
|
+
hint: 'line 1 must be exactly --- with no blank line or indentation before it',
|
|
877
|
+
})
|
|
878
|
+
continue
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const attrs = fm.attributes
|
|
882
|
+
const descLine = fm.keyLines.description ?? 1
|
|
883
|
+
|
|
884
|
+
if (attrs.description === undefined || attrs.description === null) {
|
|
885
|
+
report.error('agent/description', file, `Invalid agent ${dirent.name}: description is required`, {
|
|
886
|
+
line: 1,
|
|
887
|
+
hint: 'it is the only signal the main agent has when choosing this subagent',
|
|
888
|
+
})
|
|
889
|
+
} else if (typeof attrs.description !== 'string') {
|
|
890
|
+
report.error('agent/description', file, 'description must be a string', { line: descLine })
|
|
891
|
+
} else if (attrs.description.length < 1 || attrs.description.length > 500) {
|
|
892
|
+
report.error('agent/description', file, `description must be 1-500 characters (has ${attrs.description.length})`, { line: descLine })
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (attrs.name === undefined) {
|
|
896
|
+
report.error('agent/name-mismatch', file, 'name is required and must equal the directory name', {
|
|
897
|
+
line: 1,
|
|
898
|
+
hint: `add "name: ${name}"`,
|
|
899
|
+
})
|
|
900
|
+
} else if (attrs.name !== name) {
|
|
901
|
+
report.error('agent/name-mismatch', file, `name ${JSON.stringify(attrs.name)} must equal the directory name ${JSON.stringify(name)}`, {
|
|
902
|
+
line: fm.keyLines.name,
|
|
903
|
+
hint: 'the directory is the agent name',
|
|
904
|
+
})
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
if (attrs.tools !== undefined && attrs.tools !== null) {
|
|
908
|
+
const list = Array.isArray(attrs.tools)
|
|
909
|
+
? attrs.tools
|
|
910
|
+
: String(attrs.tools).split(',').map((t) => t.trim()).filter(Boolean)
|
|
911
|
+
if (!(list.length === 1 && list[0] === '*')) {
|
|
912
|
+
const bad = list.filter((t) => t !== '*' && !AGENT_TOOLS.includes(t))
|
|
913
|
+
if (bad.length > 0) {
|
|
914
|
+
report.error('agent/disallowed-tools', file, `Invalid agent ${dirent.name}: disallowed tools: ${bad.map((b) => JSON.stringify(b)).join(', ')}`, {
|
|
915
|
+
line: fm.keyLines.tools ?? 1,
|
|
916
|
+
hint: `grantable: ${AGENT_TOOLS.join(', ')}`,
|
|
917
|
+
})
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (fm.body.trim() === '') {
|
|
923
|
+
report.error('agent/empty-body', file, `Invalid agent ${dirent.name}: agent markdown body is empty`, { line: fm.bodyLine })
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// --- automations -----------------------------------------------------------
|
|
929
|
+
|
|
930
|
+
function lintAutomationFiles(root, entry, declared, report) {
|
|
931
|
+
const dir = join(entry.dir, NAMESPACE, 'automations')
|
|
932
|
+
const present = new Set()
|
|
933
|
+
|
|
934
|
+
for (const dirent of listDir(dir)) {
|
|
935
|
+
if (!dirent.isFile() || !dirent.name.endsWith('.md')) continue
|
|
936
|
+
present.add(dirent.name.slice(0, -3))
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// The builder reports orphans first, so a real run can surface only this one.
|
|
940
|
+
const orphans = [...present].filter((s) => !declared.includes(s))
|
|
941
|
+
for (const orphan of orphans) {
|
|
942
|
+
report.error(
|
|
943
|
+
'automation/orphan-file',
|
|
944
|
+
relative(root, join(dir, `${orphan}.md`)),
|
|
945
|
+
`Invalid automations of ${entry.slug}: files without a manifest declaration: ${orphans.join(', ')}`,
|
|
946
|
+
{ line: 1, hint: `declare it in plugin.json under extensions["${NAMESPACE}"].automations, or delete it` }
|
|
947
|
+
)
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
for (const slug of declared) {
|
|
951
|
+
const path = join(dir, `${slug}.md`)
|
|
952
|
+
if (!present.has(slug)) {
|
|
953
|
+
report.error(
|
|
954
|
+
'automation/missing-file',
|
|
955
|
+
relative(root, join(entry.dir, 'plugin.json')),
|
|
956
|
+
`Invalid automations of ${entry.slug}: missing automations/${slug}.md`,
|
|
957
|
+
{ hint: `create ${NAMESPACE}/automations/${slug}.md` }
|
|
958
|
+
)
|
|
959
|
+
continue
|
|
960
|
+
}
|
|
961
|
+
const text = readIfExists(path) ?? ''
|
|
962
|
+
if (text.trim() === '') {
|
|
963
|
+
report.error('automation/empty-file', relative(root, path), `Invalid automations of ${entry.slug}: automations/${slug}.md is empty`, { line: 1 })
|
|
964
|
+
}
|
|
965
|
+
if (text.trimStart().startsWith('---')) {
|
|
966
|
+
report.warn('automation/frontmatter', relative(root, path), 'frontmatter here is not parsed or stripped, so it lands verbatim in the agent instruction', {
|
|
967
|
+
line: 1,
|
|
968
|
+
hint: 'name, schedule and model belong in plugin.json',
|
|
969
|
+
})
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function lintSetup(root, entry, manifest, report) {
|
|
975
|
+
if (!manifest?.setup) return
|
|
976
|
+
if (existsSync(join(entry.dir, 'skills', 'install', 'SKILL.md'))) return
|
|
977
|
+
report.error(
|
|
978
|
+
'setup/missing-install-skill',
|
|
979
|
+
relative(root, join(entry.dir, 'plugin.json')),
|
|
980
|
+
`setup of ${entry.slug} declares setup: true, but there is no skills/install skill for the setup chat to follow`,
|
|
981
|
+
{ hint: 'create skills/install/SKILL.md, or drop the setup flag' }
|
|
982
|
+
)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// --- layout, scripts, sandbox paths ----------------------------------------
|
|
986
|
+
|
|
987
|
+
function lintLayout(root, entry, report) {
|
|
988
|
+
for (const name of MISPLACED) {
|
|
989
|
+
if (existsSync(join(entry.dir, name))) {
|
|
990
|
+
report.warn('layout/root-content', relative(root, join(entry.dir, name)), 'builder content at the plugin root is never loaded', {
|
|
991
|
+
hint: `move it to ${NAMESPACE}/${name}`,
|
|
992
|
+
})
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
const scripts = join(entry.dir, NAMESPACE, 'scripts')
|
|
997
|
+
const pkgPath = join(scripts, 'package.json')
|
|
998
|
+
if (existsSync(pkgPath)) {
|
|
999
|
+
const file = relative(root, pkgPath)
|
|
1000
|
+
let pkg = null
|
|
1001
|
+
try {
|
|
1002
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
report.error('scripts/package-parse', file, `Invalid JSON in scripts/package.json: ${e.message}`)
|
|
1005
|
+
}
|
|
1006
|
+
const deps = pkg ? { ...pkg.dependencies, ...pkg.devDependencies } : {}
|
|
1007
|
+
const hasDeps = Object.keys(deps).length > 0
|
|
1008
|
+
const lock = join(scripts, 'pnpm-lock.yaml')
|
|
1009
|
+
if (hasDeps && !existsSync(lock)) {
|
|
1010
|
+
report.error('scripts/lockfile-missing', file, 'scripts/package.json declares dependencies but there is no pnpm-lock.yaml', {
|
|
1011
|
+
hint: 'install runs pnpm install --frozen-lockfile and fails without one',
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
if (!hasDeps && !existsSync(lock)) {
|
|
1015
|
+
report.warn('scripts/lockfile-absent', file, 'a dependency-free package.json with no lockfile makes the install step re-run on every sync')
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
walk(entry.dir, (path, node) => {
|
|
1020
|
+
const file = relative(root, path)
|
|
1021
|
+
if (node.isSymbolicLink()) {
|
|
1022
|
+
report.error('layout/symlink', file, 'symlinks under plugins/ bypass the release gate and then fail at runtime', {
|
|
1023
|
+
hint: 'commit a real file',
|
|
1024
|
+
})
|
|
1025
|
+
return false
|
|
1026
|
+
}
|
|
1027
|
+
if (node.isDirectory() && node.name === 'node_modules') {
|
|
1028
|
+
report.warn('layout/node-modules', file, 'committed node_modules ships into every sandbox', { hint: 'gitignore it' })
|
|
1029
|
+
return false
|
|
1030
|
+
}
|
|
1031
|
+
return true
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
lintSandboxPaths(root, entry, report)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function walk(dir, visit) {
|
|
1038
|
+
for (const dirent of listDir(dir)) {
|
|
1039
|
+
const path = join(dir, dirent.name)
|
|
1040
|
+
let stat
|
|
1041
|
+
try {
|
|
1042
|
+
stat = lstatSync(path)
|
|
1043
|
+
} catch {
|
|
1044
|
+
continue
|
|
1045
|
+
}
|
|
1046
|
+
const node = {
|
|
1047
|
+
name: dirent.name,
|
|
1048
|
+
isSymbolicLink: () => stat.isSymbolicLink(),
|
|
1049
|
+
isDirectory: () => stat.isDirectory() && !stat.isSymbolicLink(),
|
|
1050
|
+
}
|
|
1051
|
+
if (visit(path, node) === false) continue
|
|
1052
|
+
if (node.isDirectory()) walk(path, visit)
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Plugin prose never names a sandbox location. The builder tells the agent
|
|
1058
|
+
* where each plugin's scripts directory is, so a script is addressed as
|
|
1059
|
+
* `scripts/<file>` and everything else is discovered. A hardcoded path
|
|
1060
|
+
* leaks the sandbox layout and breaks when it moves.
|
|
1061
|
+
*/
|
|
1062
|
+
function lintSandboxPaths(root, entry, report) {
|
|
1063
|
+
const absolute = /\/project\/workspace\S*|\.subbly\/(?:plugins|data|worktrees)\S*/g
|
|
1064
|
+
const script = /(?<![\w./-])scripts\/([\w.-]+(?:\/[\w.-]+)*)/g
|
|
1065
|
+
const scriptsDir = join(entry.dir, NAMESPACE, 'scripts')
|
|
1066
|
+
|
|
1067
|
+
walk(entry.dir, (path, node) => {
|
|
1068
|
+
if (node.isDirectory() || !path.endsWith('.md')) return true
|
|
1069
|
+
const text = readIfExists(path)
|
|
1070
|
+
if (!text) return true
|
|
1071
|
+
|
|
1072
|
+
for (const [full] of text.matchAll(absolute)) {
|
|
1073
|
+
const where = { line: lineOf(text, full), col: colOf(text, full) }
|
|
1074
|
+
report.error('sandbox/path-absolute', relative(root, path), `sandbox path "${full}" hardcodes the sandbox layout`, { ...where, hint: 'name a script as scripts/<file>; the builder resolves the directory' })
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
for (const [full, file] of text.matchAll(script)) {
|
|
1078
|
+
if (existsSync(join(scriptsDir, file))) continue
|
|
1079
|
+
const where = { line: lineOf(text, full), col: colOf(text, full) }
|
|
1080
|
+
report.warn('sandbox/script-missing', relative(root, path), `"${full}" names a script the plugin does not ship`, { ...where, hint: `add ${NAMESPACE}/scripts/${file} or fix the reference` })
|
|
1081
|
+
}
|
|
1082
|
+
return true
|
|
1083
|
+
})
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// --- release bump ----------------------------------------------------------
|
|
1087
|
+
|
|
1088
|
+
function lintVersionBump(root, version, report) {
|
|
1089
|
+
let base = null
|
|
1090
|
+
for (const ref of ['origin/main', 'main']) {
|
|
1091
|
+
try {
|
|
1092
|
+
execFileSync('git', ['rev-parse', '--verify', ref], { cwd: root, stdio: 'pipe' })
|
|
1093
|
+
base = ref
|
|
1094
|
+
break
|
|
1095
|
+
} catch {
|
|
1096
|
+
/* try the next ref */
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (!base) return
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
const changed = execFileSync('git', ['diff', '--name-only', base, '--', 'plugins/'], { cwd: root, encoding: 'utf8' })
|
|
1103
|
+
.split('\n')
|
|
1104
|
+
.filter(Boolean)
|
|
1105
|
+
if (changed.length === 0) return
|
|
1106
|
+
|
|
1107
|
+
const baseVersion = JSON.parse(execFileSync('git', ['show', `${base}:marketplace.json`], { cwd: root, encoding: 'utf8' })).version
|
|
1108
|
+
if (baseVersion === version) {
|
|
1109
|
+
report.warn('marketplace/version-bump', 'marketplace.json', `${changed.length} file(s) under plugins/ changed since ${base}, but version is still ${version}`, {
|
|
1110
|
+
hint: 'a merge without a version bump releases nothing',
|
|
1111
|
+
})
|
|
1112
|
+
}
|
|
1113
|
+
} catch {
|
|
1114
|
+
/* no baseline to compare against */
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// --- entry point -----------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
export function lint(root, { git = true } = {}) {
|
|
1121
|
+
const report = new Report()
|
|
1122
|
+
const marketplace = lintMarketplaceManifest(root, report)
|
|
1123
|
+
if (!marketplace) return report
|
|
1124
|
+
|
|
1125
|
+
for (const entry of marketplace.entries) {
|
|
1126
|
+
if (!existsSync(entry.dir)) continue
|
|
1127
|
+
const manifest = lintPluginManifest(root, entry, report)
|
|
1128
|
+
lintMcp(root, entry, manifest, report)
|
|
1129
|
+
lintSkills(root, entry, report)
|
|
1130
|
+
lintAgents(root, entry, report)
|
|
1131
|
+
lintAutomationFiles(root, entry, manifest?.automations ?? [], report)
|
|
1132
|
+
lintSetup(root, entry, manifest, report)
|
|
1133
|
+
lintLayout(root, entry, report)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (git) lintVersionBump(root, marketplace.version, report)
|
|
1137
|
+
return report
|
|
1138
|
+
}
|