@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 ADDED
@@ -0,0 +1,7 @@
1
+ # @subbly/plugin-lint
2
+
3
+ ## 0.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - First release. The Subbly marketplace release gate, offline. The builder reads every plugin in one refresh and the refresh is all-or-nothing, so one error in one plugin blocks the release of all of them. This package runs the same 94 checks on your machine before you commit, over `marketplace.json`, every plugin manifest, `mcp.json` servers, skill and agent frontmatter, automation declarations against their files, cron floors, script lockfiles, plugin layout and sandbox paths written in prose. Three entry points share one engine: the `subbly-plugin-lint` command, which has no runtime dependencies so a bare CI job works, with `--strict`, `--format unix|json|github`, `--root`, `--no-git` and `--rules`; an ESLint plugin at `@subbly/plugin-lint/eslint` that shows the same findings inline as you type, absence checks included; and the package itself as a library exporting `lint()`, `RULES` and `FORMATTERS`.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @subbly/plugin-lint
2
+
3
+ The Subbly marketplace release gate, offline.
4
+
5
+ The builder reads every plugin in one refresh, and the refresh is all-or-nothing: one error in one plugin blocks the release of all of them. This package runs the same checks on your machine before you commit.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add -D @subbly/plugin-lint
11
+ ```
12
+
13
+ ## Command line
14
+
15
+ Run it from the marketplace root, the directory holding `marketplace.json`.
16
+
17
+ ```bash
18
+ subbly-plugin-lint # human output
19
+ subbly-plugin-lint --strict # warnings fail too
20
+ subbly-plugin-lint --format unix # file:line:col, for editors
21
+ subbly-plugin-lint --format json
22
+ subbly-plugin-lint --format github # GitHub Actions annotations
23
+ subbly-plugin-lint --root ../shop # lint another checkout
24
+ subbly-plugin-lint --no-git # skip the version-bump check
25
+ subbly-plugin-lint --rules # list every rule
26
+ ```
27
+
28
+ Exit code is 0 when there are no errors, 1 otherwise. It has no runtime dependencies, so it works in a bare CI job.
29
+
30
+ `--root` also turns the git checks off, since comparing against `origin/main` only makes sense in the checkout you are standing in.
31
+
32
+ ## ESLint
33
+
34
+ The plugin surfaces the same findings inline as you type. Set your editor's ESLint extension to validate `json` and `markdown`.
35
+
36
+ ```js
37
+ // eslint.config.mjs
38
+ import json from '@eslint/json'
39
+ import markdown from '@eslint/markdown'
40
+ import subbly from '@subbly/plugin-lint/eslint'
41
+
42
+ const gate = { 'subbly/errors': 'error', 'subbly/warnings': 'warn' }
43
+
44
+ export default [
45
+ {
46
+ files: ['marketplace.json', 'plugins/**/*.json'],
47
+ language: 'json/json',
48
+ plugins: { json, subbly },
49
+ rules: gate,
50
+ },
51
+ {
52
+ files: ['plugins/**/*.md'],
53
+ language: 'markdown/gfm',
54
+ languageOptions: { frontmatter: 'yaml' },
55
+ plugins: { markdown, subbly },
56
+ rules: gate,
57
+ },
58
+ ]
59
+ ```
60
+
61
+ The gate is cross-file: whether a `plugin.json` is valid depends on `marketplace.json`, and a missing `automations/<slug>.md` is a defect in a file that does not exist. ESLint visits one existing file at a time, so the engine runs once per invocation and hands each file the findings that belong to it. Every rule reaches the editor, absence checks included.
62
+
63
+ ## Library
64
+
65
+ ```js
66
+ import { lint, FORMATTERS, RULES } from '@subbly/plugin-lint'
67
+
68
+ const report = lint(process.cwd(), { git: false })
69
+ console.log(FORMATTERS.stylish(report))
70
+ console.log(report.errors.length)
71
+ ```
72
+
73
+ `lint(root, { git })` returns a report with `findings`, `errors` and `warnings`. Each finding carries `level`, `ruleId`, `file`, `message`, `line`, `col` and an optional `hint`. `RULES` maps every rule id to a one-line description.
74
+
75
+ ## What it checks
76
+
77
+ `marketplace.json` and every plugin manifest, `mcp.json` servers, skill and agent frontmatter, automation declarations against their files, cron floors, script lockfiles, plugin layout, and sandbox paths written in prose. Run `subbly-plugin-lint --rules` for the full list.
78
+
79
+ ## Two severities
80
+
81
+ **error** means the refresh throws, so no plugin in the marketplace releases.
82
+
83
+ **warn** means the builder accepts it, but it is wrong, dead, or broken at runtime.
84
+
85
+ ## Tests
86
+
87
+ ```bash
88
+ pnpm test
89
+ ```
90
+
91
+ Each case builds a throwaway marketplace in a temp directory, lints it, and asserts on the rule id that fired. Asserting on ids rather than message text means wording can change without breaking the suite.
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // Lints a Subbly marketplace against the builder's release gate.
3
+ //
4
+ // subbly-plugin-lint human output
5
+ // subbly-plugin-lint --format unix file:line:col, for editors and CI
6
+ // subbly-plugin-lint --format json
7
+ // subbly-plugin-lint --format github GitHub Actions annotations
8
+ // subbly-plugin-lint --strict warnings also fail
9
+ // subbly-plugin-lint --root <dir> lint somewhere other than the cwd
10
+ // subbly-plugin-lint --no-git skip the version-bump check
11
+ // subbly-plugin-lint --rules list every rule and exit
12
+ //
13
+ // Exit 0 when there are no errors, 1 otherwise.
14
+
15
+ import { lint } from '../src/rules.mjs'
16
+ import { FORMATTERS, RULES } from '../src/report.mjs'
17
+
18
+ const argv = process.argv.slice(2)
19
+
20
+ function flag(name, fallback) {
21
+ const i = argv.indexOf(`--${name}`)
22
+ if (i === -1) return fallback
23
+ const next = argv[i + 1]
24
+ return next && !next.startsWith('--') ? next : true
25
+ }
26
+
27
+ if (argv.includes('--rules')) {
28
+ const width = Math.max(...Object.keys(RULES).map((r) => r.length))
29
+ for (const [id, description] of Object.entries(RULES)) {
30
+ console.log(`${id.padEnd(width)} ${description}`)
31
+ }
32
+ console.log(`\n${Object.keys(RULES).length} rules.`)
33
+ process.exit(0)
34
+ }
35
+
36
+ const explicitRoot = flag('root', null)
37
+ const root = explicitRoot === null || explicitRoot === true ? process.cwd() : explicitRoot
38
+ const format = flag('format', 'stylish')
39
+ const strict = argv.includes('--strict')
40
+
41
+ if (!FORMATTERS[format]) {
42
+ console.error(`Unknown format ${JSON.stringify(format)}. Available: ${Object.keys(FORMATTERS).join(', ')}`)
43
+ process.exit(2)
44
+ }
45
+
46
+ // Comparing against origin/main only makes sense for the checkout you are in,
47
+ // so an explicit --root (a fixture, a temp copy) turns the git checks off.
48
+ const git = !argv.includes('--no-git') && explicitRoot === null
49
+
50
+ const report = lint(root, { git })
51
+ const output = FORMATTERS[format](report)
52
+ if (output.trim()) console.log(output)
53
+
54
+ process.exit(report.errors.length > 0 || (strict && report.warnings.length > 0) ? 1 : 0)
package/index.mjs ADDED
@@ -0,0 +1,11 @@
1
+ // The linter as a library.
2
+ //
3
+ // import { lint, FORMATTERS } from '@subbly/plugin-lint'
4
+ // const report = lint(process.cwd(), { git: false })
5
+ // console.log(FORMATTERS.stylish(report))
6
+ //
7
+ // The ESLint plugin lives behind its own entry: '@subbly/plugin-lint/eslint'.
8
+
9
+ export { lint, envVarName, PLUGIN_SCHEMA_URL, MCP_SCHEMA_URL, NAMESPACE, AGENT_TOOLS, DEFAULT_ENV_KEYS, CURRENT_LINK } from './src/rules.mjs'
10
+ export { RULES, FORMATTERS, Report } from './src/report.mjs'
11
+ export { SCHEDULE_FLOOR_MINUTES, parseCron, meetsFloor, CronUnsupported } from './src/cron.mjs'
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@subbly/plugin-lint",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "The Subbly marketplace release gate, offline. Lints marketplace.json, plugin manifests, mcp.json, skills, agents and automations.",
7
+ "license": "MIT",
8
+ "author": "Subbly LTD <oleg@subbly.co> (https://www.subbly.co)",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/subbly/subbly-plugin-examples.git",
12
+ "directory": "packages/plugin-lint"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "keywords": [
18
+ "subbly",
19
+ "plugin",
20
+ "marketplace",
21
+ "lint",
22
+ "eslint-plugin"
23
+ ],
24
+ "bin": {
25
+ "subbly-plugin-lint": "bin/subbly-plugin-lint.mjs"
26
+ },
27
+ "exports": {
28
+ ".": "./index.mjs",
29
+ "./eslint": "./src/eslint-plugin.mjs",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "src",
35
+ "index.mjs",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "scripts": {
43
+ "test": "node test/lint.test.mjs"
44
+ }
45
+ }
package/src/cron.mjs ADDED
@@ -0,0 +1,131 @@
1
+ // Cron parsing and the builder 15-minute schedule floor.
2
+ //
3
+ // Mirrors packages/core/src/lib/automations/schedule.ts: 101 firings sampled
4
+ // from 2026-01-01T00:00:00Z, rejected if any gap falls under the floor.
5
+
6
+ export const SCHEDULE_FLOOR_MINUTES = 15
7
+
8
+ const CRON_ALIASES = {
9
+ '@hourly': '0 * * * *',
10
+ '@daily': '0 0 * * *',
11
+ '@weekly': '0 0 * * 0',
12
+ '@monthly': '0 0 1 * *',
13
+ '@yearly': '0 0 1 1 *',
14
+ }
15
+ const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
16
+ const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
17
+
18
+ export class CronUnsupported extends Error {}
19
+
20
+ function expandField(spec, min, max, names) {
21
+ const out = new Set()
22
+ for (const part of spec.split(',')) {
23
+ const piece = part.trim()
24
+ if (piece === '') throw new Error('empty field element')
25
+ if (/[LW#?]/i.test(piece)) throw new CronUnsupported(piece)
26
+
27
+ const [rangePart, stepPart] = piece.split('/')
28
+ const step = stepPart === undefined ? 1 : Number(stepPart)
29
+ if (!Number.isInteger(step) || step < 1) throw new Error(`bad step "${stepPart}"`)
30
+
31
+ let lo
32
+ let hi
33
+ if (rangePart === '*') {
34
+ lo = min
35
+ hi = max
36
+ } else if (rangePart.includes('-')) {
37
+ const [a, b] = rangePart.split('-')
38
+ lo = toNumber(a, names)
39
+ hi = toNumber(b, names)
40
+ } else {
41
+ lo = toNumber(rangePart, names)
42
+ hi = stepPart === undefined ? lo : max
43
+ }
44
+ if (lo < min || hi > max || lo > hi) throw new Error(`value out of range in "${piece}"`)
45
+ for (let v = lo; v <= hi; v += step) out.add(v)
46
+ }
47
+ return out
48
+ }
49
+
50
+ function toNumber(token, names) {
51
+ const t = token.trim()
52
+ if (/^\d+$/.test(t)) return Number(t)
53
+ if (names) {
54
+ const i = names.indexOf(t.toLowerCase().slice(0, 3))
55
+ if (i !== -1) return i
56
+ }
57
+ throw new Error(`unrecognized value "${t}"`)
58
+ }
59
+
60
+ /** Returns {fields} or throws. Mirrors what cron-parser accepts, minus L/W/#. */
61
+ export function parseCron(expression) {
62
+ const expr = (CRON_ALIASES[expression.trim().toLowerCase()] ?? expression).trim()
63
+ const parts = expr.split(/\s+/)
64
+ if (parts.length !== 5 && parts.length !== 6) {
65
+ throw new Error(`expected 5 or 6 fields, got ${parts.length}`)
66
+ }
67
+ const [sec, min, hour, dom, mon, dow] = parts.length === 6 ? parts : ['0', ...parts]
68
+ const dowSet = expandField(dow, 0, 7, DAYS)
69
+ if (dowSet.has(7)) dowSet.add(0)
70
+ return {
71
+ seconds: expandField(sec, 0, 59),
72
+ minutes: expandField(min, 0, 59),
73
+ hours: expandField(hour, 0, 23),
74
+ dom: expandField(dom, 1, 31),
75
+ months: expandField(mon, 1, 12, MONTHS),
76
+ dow: dowSet,
77
+ domRestricted: dom.trim() !== '*',
78
+ dowRestricted: dow.trim() !== '*',
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Replicates scheduleMeetsFloor: 101 firings from 2026-01-01T00:00:00Z, reject
84
+ * if any gap is under 15 minutes. Iterates by day so a yearly cron stays cheap.
85
+ */
86
+ export function meetsFloor(cron) {
87
+ if (cron.seconds.size > 1) return false // sub-minute firings
88
+
89
+ const minutesOfDay = []
90
+ for (const h of [...cron.hours].sort((a, b) => a - b)) {
91
+ for (const m of [...cron.minutes].sort((a, b) => a - b)) minutesOfDay.push(h * 60 + m)
92
+ }
93
+ minutesOfDay.sort((a, b) => a - b)
94
+ if (minutesOfDay.length === 0) return true
95
+
96
+ // A same-day gap under the floor fails regardless of which days match.
97
+ for (let i = 1; i < minutesOfDay.length; i++) {
98
+ if (minutesOfDay[i] - minutesOfDay[i - 1] < SCHEDULE_FLOOR_MINUTES) return false
99
+ }
100
+
101
+ // Then walk real days for the wrap-around gap between consecutive fire days.
102
+ const start = Date.UTC(2026, 0, 1)
103
+ let firings = 0
104
+ let previousDayIndex = null
105
+ const DAY_LIMIT = 366 * 120
106
+
107
+ for (let day = 0; day < DAY_LIMIT && firings <= 101; day++) {
108
+ const date = new Date(start + day * 86400000)
109
+ if (!dayMatches(cron, date)) continue
110
+
111
+ if (previousDayIndex !== null) {
112
+ const dayGap = (day - previousDayIndex) * 1440
113
+ const wrap = dayGap - minutesOfDay[minutesOfDay.length - 1] + minutesOfDay[0]
114
+ if (wrap < SCHEDULE_FLOOR_MINUTES) return false
115
+ }
116
+ previousDayIndex = day
117
+ firings += minutesOfDay.length
118
+ }
119
+ return true
120
+ }
121
+
122
+ function dayMatches(cron, date) {
123
+ if (!cron.months.has(date.getUTCMonth() + 1)) return false
124
+ const domHit = cron.dom.has(date.getUTCDate())
125
+ const dowHit = cron.dow.has(date.getUTCDay())
126
+ // Standard cron: when both are restricted the match is OR, not AND.
127
+ if (cron.domRestricted && cron.dowRestricted) return domHit || dowHit
128
+ if (cron.domRestricted) return domHit
129
+ if (cron.dowRestricted) return dowHit
130
+ return true
131
+ }
@@ -0,0 +1,74 @@
1
+ // ESLint bridge.
2
+ //
3
+ // The release gate is cross-file: whether plugin.json is valid depends on
4
+ // marketplace.json, and a missing automations/<slug>.md is a defect in a file
5
+ // that does not exist. ESLint visits one existing file at a time, so rules
6
+ // cannot be written that way directly.
7
+ //
8
+ // Instead the engine runs once per ESLint invocation and each file is handed
9
+ // the findings that belong to it. Every rule in src/rules.mjs reaches
10
+ // the editor, absence-checks included, and the engine stays the one place a
11
+ // rule is written.
12
+
13
+ import { relative } from 'node:path'
14
+ import { lint } from './rules.mjs'
15
+
16
+ let cache = null
17
+
18
+ function findingsFor(cwd, filename) {
19
+ // ESLint lints files in one pass, so a single engine run covers all of them.
20
+ // Keyed on cwd so a watching editor re-runs when the workspace changes.
21
+ if (!cache || cache.cwd !== cwd) {
22
+ const byFile = new Map()
23
+ // git comparisons are for CI, not for a keystroke in the editor.
24
+ for (const finding of lint(cwd, { git: false }).findings) {
25
+ if (!byFile.has(finding.file)) byFile.set(finding.file, [])
26
+ byFile.get(finding.file).push(finding)
27
+ }
28
+ cache = { cwd, byFile, at: Date.now() }
29
+ }
30
+ return cache.byFile.get(relative(cwd, filename)) ?? []
31
+ }
32
+
33
+ /** Editors keep one process alive, so stale results need a short lifetime. */
34
+ const MAX_CACHE_MS = 1000
35
+
36
+ function level(want) {
37
+ return {
38
+ meta: {
39
+ type: want === 'error' ? 'problem' : 'suggestion',
40
+ docs: {
41
+ description:
42
+ want === 'error'
43
+ ? 'Release-gate errors: the marketplace refresh throws, so no plugin releases'
44
+ : 'Release-gate warnings: accepted by the builder, but wrong, dead, or broken at runtime',
45
+ },
46
+ schema: [],
47
+ },
48
+ create(context) {
49
+ if (cache && Date.now() - cache.at > MAX_CACHE_MS) cache = null
50
+
51
+ const report = () => {
52
+ for (const finding of findingsFor(context.cwd, context.filename)) {
53
+ if (finding.level !== want) continue
54
+ context.report({
55
+ loc: { line: finding.line, column: finding.col },
56
+ message: `${finding.message} [${finding.ruleId}]`,
57
+ })
58
+ }
59
+ }
60
+
61
+ // Document is the momoa (JSON) root; root is the mdast (markdown) one.
62
+ // Program covers a plain JS file, should this ever lint one.
63
+ return { 'Document:exit': report, 'root:exit': report, 'Program:exit': report }
64
+ },
65
+ }
66
+ }
67
+
68
+ export default {
69
+ meta: { name: 'subbly', version: '1.0.0' },
70
+ rules: {
71
+ errors: level('error'),
72
+ warnings: level('warn'),
73
+ },
74
+ }
@@ -0,0 +1,101 @@
1
+ // Line-aware frontmatter reader.
2
+ //
3
+ // The builder parses with front-matter + js-yaml safeLoad. We support the flat
4
+ // key/scalar/list shapes plugin content actually uses and refuse anything
5
+ // richer, rather than guessing and quietly disagreeing with js-yaml.
6
+ //
7
+ // Every key records the line it sits on so findings can point at it.
8
+
9
+ export function readFrontmatter(text) {
10
+ const stripped = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
11
+ const lines = stripped.split(/\r?\n/)
12
+ const opener = lines[0]?.trimEnd()
13
+
14
+ if (opener !== '---' && opener !== '= yaml =') {
15
+ // front-matter yields {} here, so the builder fails on the missing
16
+ // description rather than on a parse error.
17
+ return { fenced: false, attributes: {}, keyLines: {}, body: stripped, bodyLine: 1 }
18
+ }
19
+
20
+ let close = -1
21
+ for (let i = 1; i < lines.length; i++) {
22
+ const trimmed = lines[i].trimEnd()
23
+ if (trimmed === '---' || trimmed === '...' || trimmed === '= yaml =') {
24
+ close = i
25
+ break
26
+ }
27
+ }
28
+ if (close === -1) {
29
+ return { fenced: false, unterminated: true, attributes: {}, keyLines: {}, body: stripped, bodyLine: 1 }
30
+ }
31
+
32
+ const attributes = {}
33
+ const keyLines = {}
34
+ let pendingKey = null
35
+
36
+ for (let i = 1; i < close; i++) {
37
+ const raw = lines[i]
38
+ const lineNumber = i + 1
39
+ if (!raw.trim() || raw.trim().startsWith('#')) continue
40
+
41
+ const item = raw.match(/^\s+-\s*(.*)$/)
42
+ if (item && pendingKey) {
43
+ if (!Array.isArray(attributes[pendingKey])) attributes[pendingKey] = []
44
+ attributes[pendingKey].push(unquote(item[1]))
45
+ continue
46
+ }
47
+
48
+ const pair = raw.match(/^([A-Za-z0-9_$-]+)\s*:\s*(.*)$/)
49
+ if (!pair) {
50
+ return { fenced: true, unsupported: raw.trim(), unsupportedLine: lineNumber, attributes: {}, keyLines: {}, body: '', bodyLine: close + 2 }
51
+ }
52
+
53
+ const [, key, rawValue] = pair
54
+ if (key in keyLines) {
55
+ return { fenced: true, duplicate: key, duplicateLine: lineNumber, attributes: {}, keyLines: {}, body: '', bodyLine: close + 2 }
56
+ }
57
+ keyLines[key] = lineNumber
58
+
59
+ const value = rawValue.trim()
60
+ if (value === '') {
61
+ attributes[key] = null
62
+ pendingKey = key
63
+ } else if (value.startsWith('[') && value.endsWith(']')) {
64
+ const inner = value.slice(1, -1).trim()
65
+ attributes[key] = inner ? inner.split(',').map((s) => unquote(s.trim())) : []
66
+ pendingKey = null
67
+ } else {
68
+ attributes[key] = unquote(value)
69
+ pendingKey = null
70
+ }
71
+ }
72
+
73
+ return {
74
+ fenced: true,
75
+ attributes,
76
+ keyLines,
77
+ body: lines.slice(close + 1).join('\n'),
78
+ bodyLine: close + 2,
79
+ }
80
+ }
81
+
82
+ function unquote(value) {
83
+ const quoted =
84
+ (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
85
+ return quoted && value.length >= 2 ? value.slice(1, -1) : value
86
+ }
87
+
88
+ /** Line of the first match, for findings that point into the body. */
89
+ export function lineOf(text, needle, fallback = 1) {
90
+ const index = text.indexOf(needle)
91
+ if (index === -1) return fallback
92
+ return text.slice(0, index).split('\n').length
93
+ }
94
+
95
+ /** Column of the first match on its line, 1-based. */
96
+ export function colOf(text, needle, fallback = 1) {
97
+ const index = text.indexOf(needle)
98
+ if (index === -1) return fallback
99
+ const lineStart = text.lastIndexOf('\n', index) + 1
100
+ return index - lineStart + 1
101
+ }