@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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Position-aware JSON reader.
|
|
2
|
+
//
|
|
3
|
+
// JSON.parse throws away source locations, so a finding can only name a file.
|
|
4
|
+
// This parses to the same values while recording, for every node, where its key
|
|
5
|
+
// and its value start, which is what turns a message into an editor squiggle.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @returns {{ok: true, root: Node, value: any} | {ok: false, line: number, col: number, message: string}}
|
|
9
|
+
* A Node is {value, keyLine, keyCol, line, col, children: Map|Array}.
|
|
10
|
+
*/
|
|
11
|
+
export function parseJsonSource(text) {
|
|
12
|
+
const state = { text, pos: 0, line: 1, col: 1 }
|
|
13
|
+
try {
|
|
14
|
+
skipWhitespace(state)
|
|
15
|
+
const root = parseValue(state)
|
|
16
|
+
skipWhitespace(state)
|
|
17
|
+
if (state.pos < text.length) fail(state, `unexpected character ${JSON.stringify(text[state.pos])}`)
|
|
18
|
+
return { ok: true, root, value: root.value }
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e instanceof JsonSourceError) return { ok: false, line: e.line, col: e.col, message: e.message }
|
|
21
|
+
throw e
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class JsonSourceError extends Error {
|
|
26
|
+
constructor(message, line, col) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.line = line
|
|
29
|
+
this.col = col
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fail(state, message) {
|
|
34
|
+
throw new JsonSourceError(message, state.line, state.col)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function advance(state, count = 1) {
|
|
38
|
+
for (let i = 0; i < count; i++) {
|
|
39
|
+
if (state.text[state.pos] === '\n') {
|
|
40
|
+
state.line++
|
|
41
|
+
state.col = 1
|
|
42
|
+
} else {
|
|
43
|
+
state.col++
|
|
44
|
+
}
|
|
45
|
+
state.pos++
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function skipWhitespace(state) {
|
|
50
|
+
while (state.pos < state.text.length && /\s/.test(state.text[state.pos])) advance(state)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseValue(state) {
|
|
54
|
+
skipWhitespace(state)
|
|
55
|
+
const line = state.line
|
|
56
|
+
const col = state.col
|
|
57
|
+
const char = state.text[state.pos]
|
|
58
|
+
|
|
59
|
+
if (char === undefined) fail(state, 'unexpected end of input')
|
|
60
|
+
if (char === '{') return parseObject(state, line, col)
|
|
61
|
+
if (char === '[') return parseArray(state, line, col)
|
|
62
|
+
if (char === '"') return { value: parseString(state), line, col, children: null }
|
|
63
|
+
|
|
64
|
+
const literal = /^(true|false|null)/.exec(state.text.slice(state.pos))
|
|
65
|
+
if (literal) {
|
|
66
|
+
advance(state, literal[0].length)
|
|
67
|
+
return { value: literal[0] === 'true' ? true : literal[0] === 'false' ? false : null, line, col, children: null }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const number = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(state.text.slice(state.pos))
|
|
71
|
+
if (number) {
|
|
72
|
+
advance(state, number[0].length)
|
|
73
|
+
return { value: Number(number[0]), line, col, children: null }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fail(state, `unexpected character ${JSON.stringify(char)}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseObject(state, line, col) {
|
|
80
|
+
advance(state) // {
|
|
81
|
+
const children = new Map()
|
|
82
|
+
const value = {}
|
|
83
|
+
skipWhitespace(state)
|
|
84
|
+
|
|
85
|
+
if (state.text[state.pos] === '}') {
|
|
86
|
+
advance(state)
|
|
87
|
+
return { value, line, col, children }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (;;) {
|
|
91
|
+
skipWhitespace(state)
|
|
92
|
+
const keyLine = state.line
|
|
93
|
+
const keyCol = state.col
|
|
94
|
+
if (state.text[state.pos] !== '"') fail(state, 'expected a property name in double quotes')
|
|
95
|
+
const key = parseString(state)
|
|
96
|
+
|
|
97
|
+
skipWhitespace(state)
|
|
98
|
+
if (state.text[state.pos] !== ':') fail(state, 'expected ":" after the property name')
|
|
99
|
+
advance(state)
|
|
100
|
+
|
|
101
|
+
const child = parseValue(state)
|
|
102
|
+
child.keyLine = keyLine
|
|
103
|
+
child.keyCol = keyCol
|
|
104
|
+
children.set(key, child)
|
|
105
|
+
value[key] = child.value
|
|
106
|
+
|
|
107
|
+
skipWhitespace(state)
|
|
108
|
+
if (state.text[state.pos] === ',') {
|
|
109
|
+
advance(state)
|
|
110
|
+
skipWhitespace(state)
|
|
111
|
+
// A trailing comma is legal JSON5 but not JSON, and the builder uses JSON.parse.
|
|
112
|
+
if (state.text[state.pos] === '}') fail(state, 'trailing comma is not valid JSON')
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (state.text[state.pos] === '}') {
|
|
116
|
+
advance(state)
|
|
117
|
+
return { value, line, col, children }
|
|
118
|
+
}
|
|
119
|
+
fail(state, 'expected "," or "}"')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseArray(state, line, col) {
|
|
124
|
+
advance(state) // [
|
|
125
|
+
const children = []
|
|
126
|
+
const value = []
|
|
127
|
+
skipWhitespace(state)
|
|
128
|
+
|
|
129
|
+
if (state.text[state.pos] === ']') {
|
|
130
|
+
advance(state)
|
|
131
|
+
return { value, line, col, children }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (;;) {
|
|
135
|
+
const child = parseValue(state)
|
|
136
|
+
children.push(child)
|
|
137
|
+
value.push(child.value)
|
|
138
|
+
|
|
139
|
+
skipWhitespace(state)
|
|
140
|
+
if (state.text[state.pos] === ',') {
|
|
141
|
+
advance(state)
|
|
142
|
+
skipWhitespace(state)
|
|
143
|
+
if (state.text[state.pos] === ']') fail(state, 'trailing comma is not valid JSON')
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if (state.text[state.pos] === ']') {
|
|
147
|
+
advance(state)
|
|
148
|
+
return { value, line, col, children }
|
|
149
|
+
}
|
|
150
|
+
fail(state, 'expected "," or "]"')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseString(state) {
|
|
155
|
+
advance(state) // opening quote
|
|
156
|
+
let out = ''
|
|
157
|
+
for (;;) {
|
|
158
|
+
const char = state.text[state.pos]
|
|
159
|
+
if (char === undefined) fail(state, 'unterminated string')
|
|
160
|
+
if (char === '"') {
|
|
161
|
+
advance(state)
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
if (char === '\\') {
|
|
165
|
+
advance(state)
|
|
166
|
+
const escape = state.text[state.pos]
|
|
167
|
+
const simple = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }
|
|
168
|
+
if (escape in simple) {
|
|
169
|
+
out += simple[escape]
|
|
170
|
+
advance(state)
|
|
171
|
+
} else if (escape === 'u') {
|
|
172
|
+
const hex = state.text.slice(state.pos + 1, state.pos + 5)
|
|
173
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail(state, 'invalid \\u escape')
|
|
174
|
+
out += String.fromCharCode(parseInt(hex, 16))
|
|
175
|
+
advance(state, 5)
|
|
176
|
+
} else {
|
|
177
|
+
fail(state, `invalid escape \\${escape}`)
|
|
178
|
+
}
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
out += char
|
|
182
|
+
advance(state)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolves a dotted/indexed path to a position.
|
|
188
|
+
*
|
|
189
|
+
* `at('plugins[0].source.type')` gives the value position; pass `key: true`
|
|
190
|
+
* for the property name instead, which is what an unknown-key finding wants.
|
|
191
|
+
* Falls back to the nearest resolvable ancestor so a finding always lands
|
|
192
|
+
* somewhere sensible rather than at 1:1.
|
|
193
|
+
*/
|
|
194
|
+
export function locator(root) {
|
|
195
|
+
return function at(path, { key = false } = {}) {
|
|
196
|
+
if (!root) return { line: 1, col: 1 }
|
|
197
|
+
if (!path) return { line: root.line, col: root.col }
|
|
198
|
+
|
|
199
|
+
const steps = String(path)
|
|
200
|
+
.replace(/\[(\d+)\]/g, '.$1')
|
|
201
|
+
.split('.')
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
|
|
204
|
+
let node = root
|
|
205
|
+
let deepest = root
|
|
206
|
+
let i = 0
|
|
207
|
+
while (i < steps.length) {
|
|
208
|
+
if (!node?.children) break
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(node.children)) {
|
|
211
|
+
const next = node.children[Number(steps[i])]
|
|
212
|
+
if (!next) break
|
|
213
|
+
node = deepest = next
|
|
214
|
+
i++
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Keys may themselves contain dots (co.subbly.builder), so rejoin the
|
|
219
|
+
// longest run of steps that names a real child before giving up.
|
|
220
|
+
let matched = null
|
|
221
|
+
for (let take = steps.length - i; take >= 1; take--) {
|
|
222
|
+
const candidate = steps.slice(i, i + take).join('.')
|
|
223
|
+
const next = node.children.get(candidate)
|
|
224
|
+
if (next) {
|
|
225
|
+
matched = { next, take }
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!matched) break
|
|
230
|
+
node = deepest = matched.next
|
|
231
|
+
i += matched.take
|
|
232
|
+
}
|
|
233
|
+
if (key && deepest.keyLine !== undefined) return { line: deepest.keyLine, col: deepest.keyCol }
|
|
234
|
+
return { line: deepest.line, col: deepest.col }
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Findings, rule registry and output formats.
|
|
2
|
+
|
|
3
|
+
/** Every rule the linter can report, with the docs anchor explaining it. */
|
|
4
|
+
export const RULES = {
|
|
5
|
+
// marketplace.json
|
|
6
|
+
'marketplace/parse': 'marketplace.json must be valid JSON',
|
|
7
|
+
'marketplace/root-type': 'the root must be an object',
|
|
8
|
+
'marketplace/slug': 'slug must be a valid slug',
|
|
9
|
+
'marketplace/version': 'version must be X.Y.Z with an optional lowercase pre-release suffix',
|
|
10
|
+
'marketplace/plugins-type': 'plugins must be an array',
|
|
11
|
+
'marketplace/plugins-empty': 'an empty plugins array archives every plugin',
|
|
12
|
+
'marketplace/entry-slug': 'each entry needs a valid slug',
|
|
13
|
+
'marketplace/slug-collision': 'entry slugs must be unique',
|
|
14
|
+
'marketplace/published': 'published must be a boolean',
|
|
15
|
+
'marketplace/source-type': 'source.type must be "local"',
|
|
16
|
+
'marketplace/source-path': 'source.path must be made of safe segments',
|
|
17
|
+
'marketplace/source-missing': 'source.path must exist on disk',
|
|
18
|
+
'marketplace/path-convention': 'source.path should be plugins/<slug>',
|
|
19
|
+
'marketplace/unlisted-directory': 'a plugin directory no entry points at never ships',
|
|
20
|
+
'marketplace/unknown-key': 'unknown keys are silently stripped',
|
|
21
|
+
'marketplace/version-bump': 'content changed without a version bump releases nothing',
|
|
22
|
+
|
|
23
|
+
// plugin.json
|
|
24
|
+
'manifest/parse': 'plugin.json must be valid JSON',
|
|
25
|
+
'manifest/missing': 'every marketplace entry needs a plugin.json',
|
|
26
|
+
'manifest/unknown-key': 'the manifest root is strict; unknown keys reject the release',
|
|
27
|
+
'manifest/schema-url': '$schema must be the exact Agent Plugins 1.0.0 URL',
|
|
28
|
+
'manifest/name-slug': 'name must be a valid slug and equal the marketplace entry slug',
|
|
29
|
+
'manifest/description': 'description is required, 1-500 characters',
|
|
30
|
+
'manifest/version-discarded': 'a manifest version is accepted and then ignored',
|
|
31
|
+
'manifest/author': 'author is a strict object',
|
|
32
|
+
'manifest/string-length': 'string fields have maximum lengths',
|
|
33
|
+
'manifest/keywords': 'keywords is an array of at most 20 short strings',
|
|
34
|
+
'manifest/namespace-required': 'extensions["co.subbly.builder"] is required',
|
|
35
|
+
'manifest/namespace-unknown-key': 'the namespace is strict; unknown keys reject the release',
|
|
36
|
+
'manifest/display-name': 'displayName is required, 1-100 characters',
|
|
37
|
+
'manifest/image': 'image is 1-500 characters',
|
|
38
|
+
'manifest/boolean': 'flags must be booleans',
|
|
39
|
+
|
|
40
|
+
// config fields
|
|
41
|
+
'field/key-format': 'field keys must match /^[A-Z][A-Z0-9_]*$/',
|
|
42
|
+
'field/key-duplicate': 'a duplicate field key silently overrides the first',
|
|
43
|
+
'field/label': 'label is required, 1-100 characters',
|
|
44
|
+
'field/type': 'type must be "text" or "select"',
|
|
45
|
+
'field/options': 'options belong to select fields and select fields require them',
|
|
46
|
+
'field/public-secret': 'public and secret are mutually exclusive',
|
|
47
|
+
'field/public-credential': 'a public field ships its value into the browser bundle',
|
|
48
|
+
'field/pattern': 'pattern is at most 200 characters',
|
|
49
|
+
'field/default-required': 'a default plugin cannot declare required fields',
|
|
50
|
+
'field/reserved-key': 'a field key must not restate the PLUGIN_ prefix the builder adds',
|
|
51
|
+
|
|
52
|
+
// automations
|
|
53
|
+
'automation/key-slug': 'automation keys must be valid slugs',
|
|
54
|
+
'automation/unknown-key': 'a declaration holds exactly name, schedule and model',
|
|
55
|
+
'automation/name': 'name is required, 1-100 characters',
|
|
56
|
+
'automation/schedule-syntax': 'schedule must be a cron expression the builder can parse',
|
|
57
|
+
'automation/schedule-floor': 'schedule must fire at most every 15 minutes',
|
|
58
|
+
'automation/schedule-unverified': 'this cron syntax cannot be checked offline',
|
|
59
|
+
'automation/model': 'model must be normal or intelligent-high',
|
|
60
|
+
'automation/missing-file': 'every declared automation needs its markdown file',
|
|
61
|
+
'automation/orphan-file': 'every automation file needs a declaration',
|
|
62
|
+
'automation/empty-file': 'an automation file must not be empty',
|
|
63
|
+
'automation/frontmatter': 'automation frontmatter is not stripped and leaks into the instruction',
|
|
64
|
+
'setup/missing-install-skill': 'setup: true requires a skills/install skill',
|
|
65
|
+
|
|
66
|
+
// connectors and mcp.json
|
|
67
|
+
'connector/auth': 'a connector marker is exactly { "auth": "oauth" }',
|
|
68
|
+
'connector/name-length': 'connector names are 1-100 characters',
|
|
69
|
+
'connector/undefined-server': 'a declared connector needs a matching mcp.json server',
|
|
70
|
+
'connector/no-mcp': 'declaring a connector requires an mcp.json',
|
|
71
|
+
'connector/manual-prefix': 'a server name that restates the <slug>__ prefix gets it doubled',
|
|
72
|
+
'mcp/parse': 'mcp.json must be valid JSON',
|
|
73
|
+
'mcp/unknown-key': 'mcp.json is strict',
|
|
74
|
+
'mcp/schema-url': '$schema must be the exact MCP 1.0.0 URL',
|
|
75
|
+
'mcp/servers-type': 'mcpServers must be an object',
|
|
76
|
+
'mcp/server-name': 'server names must match /^[a-z][a-z0-9_-]{0,49}$/',
|
|
77
|
+
'mcp/server-type': 'servers must be streamable-http',
|
|
78
|
+
'mcp/https-only': 'server URLs must be https',
|
|
79
|
+
'mcp/url': 'server URLs must parse and be at most 500 characters',
|
|
80
|
+
'mcp/url-interpolation': '${...} in a URL is never expanded',
|
|
81
|
+
'mcp/header-shape': 'header names and values have length limits',
|
|
82
|
+
'mcp/undeclared-env': 'an interpolated ${KEY} must be a declared field or a builder default',
|
|
83
|
+
'mcp/lowercase-interpolation': 'only uppercase ${KEY} is expanded',
|
|
84
|
+
|
|
85
|
+
// skills
|
|
86
|
+
'skill/missing-file': 'every directory under skills/ needs a SKILL.md',
|
|
87
|
+
'skill/loose-file': 'a loose file under skills/ is never loaded',
|
|
88
|
+
'skill/frontmatter': 'SKILL.md must open with a parseable frontmatter block',
|
|
89
|
+
'skill/description': 'description is required, 1-500 characters',
|
|
90
|
+
'skill/name-format': 'the resolved skill name must be a valid content name',
|
|
91
|
+
'skill/name-mismatch': 'name is required and must equal the directory name',
|
|
92
|
+
'skill/extra-frontmatter': 'unknown frontmatter is ignored but still costs context',
|
|
93
|
+
'skill/empty-body': 'a skill with no body teaches nothing',
|
|
94
|
+
|
|
95
|
+
// agents
|
|
96
|
+
'agent/missing-file': 'every directory under agents/ needs an AGENT.md',
|
|
97
|
+
'agent/loose-file': 'a loose file under agents/ is never loaded',
|
|
98
|
+
'agent/name-format': 'the directory name is the agent name and must be a valid content name',
|
|
99
|
+
'agent/frontmatter': 'agent markdown must open with a parseable frontmatter block',
|
|
100
|
+
'agent/description': 'description is required, 1-500 characters',
|
|
101
|
+
'agent/name-mismatch': 'name is required and must equal the directory name',
|
|
102
|
+
'agent/disallowed-tools': 'tools must come from the grantable set',
|
|
103
|
+
'agent/empty-body': 'the agent body is required',
|
|
104
|
+
|
|
105
|
+
// layout, scripts, sandbox
|
|
106
|
+
'layout/root-content': 'builder content at the plugin root is never loaded',
|
|
107
|
+
'layout/symlink': 'symlinks bypass the release gate and fail at runtime',
|
|
108
|
+
'layout/node-modules': 'committed node_modules ships into every sandbox',
|
|
109
|
+
'scripts/lockfile-missing': 'declared dependencies need a committed pnpm-lock.yaml',
|
|
110
|
+
'scripts/lockfile-absent': 'no lockfile makes the install step re-run on every sync',
|
|
111
|
+
'scripts/package-parse': 'scripts/package.json must be valid JSON',
|
|
112
|
+
'sandbox/path-absolute': 'plugin prose never hardcodes a sandbox path; name scripts as scripts/<file>',
|
|
113
|
+
'sandbox/script-missing': 'a scripts/<file> reference must match a shipped script',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class Report {
|
|
117
|
+
constructor() {
|
|
118
|
+
this.findings = []
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
add(level, ruleId, file, message, { line = 1, col = 1, hint } = {}) {
|
|
122
|
+
if (!RULES[ruleId]) throw new Error(`unregistered rule id: ${ruleId}`)
|
|
123
|
+
this.findings.push({ level, ruleId, file, message, line, col, hint })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
error(ruleId, file, message, opts) {
|
|
127
|
+
this.add('error', ruleId, file, message, opts)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
warn(ruleId, file, message, opts) {
|
|
131
|
+
this.add('warn', ruleId, file, message, opts)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get errors() {
|
|
135
|
+
return this.findings.filter((f) => f.level === 'error')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get warnings() {
|
|
139
|
+
return this.findings.filter((f) => f.level === 'warn')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- formatters ------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function sorted(findings) {
|
|
146
|
+
return [...findings].sort(
|
|
147
|
+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function group(findings) {
|
|
152
|
+
const byFile = new Map()
|
|
153
|
+
for (const f of sorted(findings)) {
|
|
154
|
+
if (!byFile.has(f.file)) byFile.set(f.file, [])
|
|
155
|
+
byFile.get(f.file).push(f)
|
|
156
|
+
}
|
|
157
|
+
return byFile
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const FORMATTERS = {
|
|
161
|
+
/** Human default: grouped by file, aligned, rule id on every line. */
|
|
162
|
+
stylish(report) {
|
|
163
|
+
const lines = []
|
|
164
|
+
for (const [file, list] of group(report.findings)) {
|
|
165
|
+
lines.push('', file)
|
|
166
|
+
const width = Math.max(...list.map((f) => `${f.line}:${f.col}`.length))
|
|
167
|
+
for (const f of list) {
|
|
168
|
+
const at = `${f.line}:${f.col}`.padEnd(width)
|
|
169
|
+
lines.push(` ${at} ${f.level === 'error' ? 'error' : 'warn '} ${f.message} ${f.ruleId}`)
|
|
170
|
+
if (f.hint) lines.push(` ${' '.repeat(width)} ${f.hint}`)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const { length: errors } = report.errors
|
|
174
|
+
const { length: warnings } = report.warnings
|
|
175
|
+
lines.push('')
|
|
176
|
+
lines.push(
|
|
177
|
+
report.findings.length === 0
|
|
178
|
+
? 'Clean: the marketplace passes the release gate.'
|
|
179
|
+
: `${errors} error(s), ${warnings} warning(s).`
|
|
180
|
+
)
|
|
181
|
+
if (errors > 0) {
|
|
182
|
+
lines.push('Errors fail the refresh, which blocks the release of EVERY plugin in this marketplace.')
|
|
183
|
+
}
|
|
184
|
+
return lines.join('\n')
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* file:line:col: severity: message [rule]
|
|
189
|
+
*
|
|
190
|
+
* What the VS Code problem matcher reads. "warning" rather than "warn"
|
|
191
|
+
* because that is the severity word the matcher understands.
|
|
192
|
+
*/
|
|
193
|
+
unix(report) {
|
|
194
|
+
return sorted(report.findings)
|
|
195
|
+
.map((f) => `${f.file}:${f.line}:${f.col}: ${f.level === 'error' ? 'error' : 'warning'}: ${f.message} [${f.ruleId}]`)
|
|
196
|
+
.join('\n')
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
json(report) {
|
|
200
|
+
return JSON.stringify(
|
|
201
|
+
{ ok: report.errors.length === 0, errors: report.errors, warnings: report.warnings },
|
|
202
|
+
null,
|
|
203
|
+
2
|
|
204
|
+
)
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
/** GitHub Actions annotations, so CI marks the exact line in the diff. */
|
|
208
|
+
github(report) {
|
|
209
|
+
return sorted(report.findings)
|
|
210
|
+
.map((f) => {
|
|
211
|
+
const kind = f.level === 'error' ? 'error' : 'warning'
|
|
212
|
+
return `::${kind} file=${f.file},line=${f.line},col=${f.col},title=${f.ruleId}::${f.message}`
|
|
213
|
+
})
|
|
214
|
+
.join('\n')
|
|
215
|
+
},
|
|
216
|
+
}
|