@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/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
+ }