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