@eslint-sh/eslint-plugin 0.1.0
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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/eslint-plugin.d.ts +317 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/language/selector-classes.d.ts +57 -0
- package/dist/language/selector-classes.d.ts.map +1 -0
- package/dist/language/selector-classes.js +55 -0
- package/dist/language/selector-classes.js.map +1 -0
- package/dist/language/shell-language-options.d.ts +104 -0
- package/dist/language/shell-language-options.d.ts.map +1 -0
- package/dist/language/shell-language-options.js +112 -0
- package/dist/language/shell-language-options.js.map +1 -0
- package/dist/language/shell-language.d.ts +84 -0
- package/dist/language/shell-language.d.ts.map +1 -0
- package/dist/language/shell-language.js +179 -0
- package/dist/language/shell-language.js.map +1 -0
- package/dist/language/shell-source-code.d.ts +121 -0
- package/dist/language/shell-source-code.d.ts.map +1 -0
- package/dist/language/shell-source-code.js +318 -0
- package/dist/language/shell-source-code.js.map +1 -0
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +29 -0
- package/dist/version.js.map +1 -0
- package/docs/custom-rule-tutorial.md +169 -0
- package/docs/examples/eslint.config.mjs +23 -0
- package/docs/examples/no-rm-rf-rule.js +74 -0
- package/docs/selectors.md +89 -0
- package/package.json +80 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { ConfigCommentParser, Directive, TextSourceCodeBase, VisitNodeStep, } from '@eslint/plugin-kit';
|
|
2
|
+
import { visitorKeys as shellVisitorKeys, walk } from 'sh-ast';
|
|
3
|
+
/**
|
|
4
|
+
* Shared instance — `ConfigCommentParser` is stateless (see
|
|
5
|
+
* `@eslint/plugin-kit`'s implementation), so every `ShellSourceCode`
|
|
6
|
+
* instance can safely reuse one parser rather than constructing its own.
|
|
7
|
+
*/
|
|
8
|
+
const commentParser = new ConfigCommentParser();
|
|
9
|
+
/**
|
|
10
|
+
* Builds the regex that selects which `Comment` nodes are directive/inline-
|
|
11
|
+
* config candidates at all, for a given `directivePrefix`. Mirrors
|
|
12
|
+
* `@eslint/json`'s `JSONSourceCode` `INLINE_CONFIG` constant
|
|
13
|
+
* (`/^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u`)
|
|
14
|
+
* generalized to an arbitrary prefix instead of the literal `"eslint"` —
|
|
15
|
+
* this is what makes `directivePrefix` configurable end to end rather than
|
|
16
|
+
* only in `getDisableDirectives`/`applyInlineConfig`'s own label matching.
|
|
17
|
+
*/
|
|
18
|
+
function buildInlineConfigPattern(directivePrefix) {
|
|
19
|
+
const escapedPrefix = directivePrefix.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
20
|
+
return new RegExp(`^\\s*${escapedPrefix}(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\\s|$)`, 'u');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Maps each of the four recognized directive labels (`<prefix>-disable`,
|
|
24
|
+
* `<prefix>-enable`, `<prefix>-disable-next-line`, `<prefix>-disable-line`)
|
|
25
|
+
* to the `DirectiveType` ESLint core expects — computed once per
|
|
26
|
+
* `directivePrefix` rather than re-templated on every comment.
|
|
27
|
+
*/
|
|
28
|
+
function buildDirectiveLabels(directivePrefix) {
|
|
29
|
+
return new Map([
|
|
30
|
+
[`${directivePrefix}-disable`, 'disable'],
|
|
31
|
+
[`${directivePrefix}-enable`, 'enable'],
|
|
32
|
+
[`${directivePrefix}-disable-next-line`, 'disable-next-line'],
|
|
33
|
+
[`${directivePrefix}-disable-line`, 'disable-line'],
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reads a `Comment` node's text (mvdan/sh's `syntax.Comment.Text`,
|
|
38
|
+
* normalized/lowercased to `text` — verified against a real `parseSync`
|
|
39
|
+
* call: the leading `#` is already excluded, e.g. the source
|
|
40
|
+
* `# eslint-disable-next-line sh/foo -- reason` produces
|
|
41
|
+
* `comment.text === " eslint-disable-next-line sh/foo -- reason"`).
|
|
42
|
+
* `ShNode`'s index signature types every field `unknown`, so this narrows
|
|
43
|
+
* defensively rather than assuming the shape.
|
|
44
|
+
*/
|
|
45
|
+
function getCommentText(comment) {
|
|
46
|
+
const { text } = comment;
|
|
47
|
+
return typeof text === 'string' ? text : '';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Narrows an unknown field value to a normalized `ShNode` — used while
|
|
51
|
+
* traversing (see `ShellSourceCode.traverse()` below) to tell an actual
|
|
52
|
+
* child node apart from a scalar field (e.g. `Lit.value: string`) or
|
|
53
|
+
* `undefined`.
|
|
54
|
+
* Deliberately duck-typed (checks only `type`, matching
|
|
55
|
+
* `ShellLanguage.nodeTypeKey`) rather than re-validating the full `ShNode`
|
|
56
|
+
* shape — every value reachable through a `visitorKeys` field on a bridge
|
|
57
|
+
* AST is either a normalized node or absent, by construction of
|
|
58
|
+
* `sh-ast`'s `normalize()`.
|
|
59
|
+
*/
|
|
60
|
+
function isShNode(value) {
|
|
61
|
+
return (typeof value === 'object' &&
|
|
62
|
+
value !== null &&
|
|
63
|
+
typeof value.type === 'string');
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* `ShellSourceCode` — the `SourceCode` object ESLint uses to work with a
|
|
67
|
+
* parsed shell file (design/ARCHITECTURE.md, "SourceCode and Language
|
|
68
|
+
* objects"). Extends `@eslint/plugin-kit`'s `TextSourceCodeBase`, which
|
|
69
|
+
* already implements `getLoc`/`getRange`/`getText`/`lines` (both by reading
|
|
70
|
+
* the normalized node's own `loc`/`range` fields — see
|
|
71
|
+
* `sh-ast`'s "Normalized node shape"), `getLocFromIndex`, and
|
|
72
|
+
* `getIndexFromLoc`; this class adds the two members the base class
|
|
73
|
+
* deliberately leaves unimplemented: `traverse()` (base throws "Not
|
|
74
|
+
* implemented") and `getParent()` (base throws "Not implemented", and
|
|
75
|
+
* `getAncestors()` — already implemented generically in terms of
|
|
76
|
+
* `getParent()` — falls out for free).
|
|
77
|
+
*
|
|
78
|
+
* Directive support (`getDisableDirectives`, `getInlineConfigNodes`,
|
|
79
|
+
* `applyInlineConfig`) recognizes all four disable-comment forms
|
|
80
|
+
* (`<prefix>-disable`, `<prefix>-enable`, `<prefix>-disable-line`,
|
|
81
|
+
* `<prefix>-disable-next-line`) plus whole-file inline rule config
|
|
82
|
+
* (bare `<prefix> <rule-config>`), sourced from `Comment` nodes gathered via
|
|
83
|
+
* `sh-ast`'s structural `walk()` and parsed with
|
|
84
|
+
* `@eslint/plugin-kit`'s `ConfigCommentParser` — the same building blocks
|
|
85
|
+
* `@eslint/json`'s `JSONSourceCode` uses, generalized so the `<prefix>` is
|
|
86
|
+
* configurable (default `"eslint"`; see `ShellLanguageOptions.directivePrefix`)
|
|
87
|
+
* rather than hardcoded to `"eslint"`.
|
|
88
|
+
*
|
|
89
|
+
* Selector-class support (`matchesSelectorClass`, the six launch esquery
|
|
90
|
+
* classes) is implemented on `ShellLanguage` — see `shell-language.ts` and
|
|
91
|
+
* `selector-classes.ts`.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
export class ShellSourceCode extends TextSourceCodeBase {
|
|
96
|
+
/**
|
|
97
|
+
* Parent-node lookup populated the first time `traverse()` runs. Keyed by
|
|
98
|
+
* node identity (not by range or any other derived key) so it works even
|
|
99
|
+
* for nodes whose range/loc coincide with another node's (e.g. a
|
|
100
|
+
* single-child wrapper).
|
|
101
|
+
*/
|
|
102
|
+
#parents = new WeakMap();
|
|
103
|
+
/**
|
|
104
|
+
* Cached traversal steps — the AST never mutates after `parse()` returns
|
|
105
|
+
* it, so `traverse()` only needs to compute this once (mirroring
|
|
106
|
+
* `@eslint/json`'s `JSONSourceCode`).
|
|
107
|
+
*/
|
|
108
|
+
#steps;
|
|
109
|
+
/**
|
|
110
|
+
* The prefix directive/inline-config comments must start with (see
|
|
111
|
+
* `ShellLanguageOptions.directivePrefix`; resolved/defaulted by
|
|
112
|
+
* `ShellLanguage.createSourceCode` before this constructor ever runs).
|
|
113
|
+
*/
|
|
114
|
+
#directivePrefix;
|
|
115
|
+
/**
|
|
116
|
+
* `<prefix>-disable`/`-enable`/`-disable-line`/`-disable-next-line` → the
|
|
117
|
+
* `DirectiveType` ESLint core expects — computed once from
|
|
118
|
+
* `#directivePrefix` rather than re-templated per comment.
|
|
119
|
+
*/
|
|
120
|
+
#directiveLabels;
|
|
121
|
+
/**
|
|
122
|
+
* Selects which `Comment` nodes are directive/inline-config candidates at
|
|
123
|
+
* all, before `ConfigCommentParser.parseDirective` is even invoked.
|
|
124
|
+
*/
|
|
125
|
+
#inlineConfigPattern;
|
|
126
|
+
/**
|
|
127
|
+
* Every `Comment` node in the AST, gathered once (cached) via
|
|
128
|
+
* `sh-ast`'s `walk()` — deliberately *not* derived from
|
|
129
|
+
* `traverse()`/`visitorKeys` even though, verified against a real parse,
|
|
130
|
+
* `comments` and `last` already appear in the generated `visitorKeys`
|
|
131
|
+
* table (so `traverse()` does visit `Comment` nodes today too). `walk()`
|
|
132
|
+
* discovers children structurally rather than through that table (see its
|
|
133
|
+
* own doc comment: the hand-written table once missed a field and
|
|
134
|
+
* silently under-walked), so comment collection keeps working even if a
|
|
135
|
+
* future `visitorKeys` regeneration omits a comment-bearing field.
|
|
136
|
+
*/
|
|
137
|
+
#comments;
|
|
138
|
+
/**
|
|
139
|
+
* Cached result of {@link ShellSourceCode.getInlineConfigNodes} — ESLint core calls it up
|
|
140
|
+
* to twice per lint run (once directly when warning about disabled inline
|
|
141
|
+
* config, once indirectly via `getDisableDirectives`/`applyInlineConfig`).
|
|
142
|
+
*/
|
|
143
|
+
#inlineConfigNodes;
|
|
144
|
+
constructor(options) {
|
|
145
|
+
super(options);
|
|
146
|
+
// Defensive default for direct construction outside the `ShellLanguage`
|
|
147
|
+
// pipeline (e.g. unit tests) — `ShellLanguage.createSourceCode` always
|
|
148
|
+
// supplies the already-normalized, defaulted value.
|
|
149
|
+
this.#directivePrefix = options.directivePrefix ?? 'eslint';
|
|
150
|
+
this.#directiveLabels = buildDirectiveLabels(this.#directivePrefix);
|
|
151
|
+
this.#inlineConfigPattern = buildInlineConfigPattern(this.#directivePrefix);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Returns the parent of `node`, or `undefined` for the root `File` node
|
|
155
|
+
* (or any node visited before `traverse()` has run once). ESLint always
|
|
156
|
+
* calls `traverse()` before any rule can call `getParent`/`getAncestors`,
|
|
157
|
+
* so in practice the map is always populated by the time a rule observes
|
|
158
|
+
* it.
|
|
159
|
+
*/
|
|
160
|
+
getParent(node) {
|
|
161
|
+
return this.#parents.get(node);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Traverses the AST pre-order, yielding a `VisitNodeStep` on node enter
|
|
165
|
+
* (`phase: 1`) and another on node exit (`phase: 2`) for every node —
|
|
166
|
+
* exactly the two-phase shape `@eslint/json`/`@eslint/css` produce, which
|
|
167
|
+
* is what lets rule listeners use `NodeType` and `NodeType:exit` esquery
|
|
168
|
+
* selectors. Children are discovered via `sh-ast`'s generated,
|
|
169
|
+
* frozen `visitorKeys` table (per node `type`) — **not** structurally
|
|
170
|
+
* (contrast `sh-ast`'s own `walk()`, which deliberately walks
|
|
171
|
+
* structurally so its correctness never depends on that table) — because
|
|
172
|
+
* ESLint's traversal contract is defined in terms of a language's
|
|
173
|
+
* declared `visitorKeys`, and this class's whole job is to honor that
|
|
174
|
+
* contract for shell ASTs.
|
|
175
|
+
*/
|
|
176
|
+
traverse() {
|
|
177
|
+
if (this.#steps) {
|
|
178
|
+
return this.#steps.values();
|
|
179
|
+
}
|
|
180
|
+
const steps = [];
|
|
181
|
+
this.#steps = steps;
|
|
182
|
+
const visit = (node, parent) => {
|
|
183
|
+
if (parent) {
|
|
184
|
+
this.#parents.set(node, parent);
|
|
185
|
+
}
|
|
186
|
+
steps.push(new VisitNodeStep({ target: node, phase: 1, args: [node, parent] }));
|
|
187
|
+
const keys = shellVisitorKeys[node.type] ?? [];
|
|
188
|
+
for (const key of keys) {
|
|
189
|
+
const value = node[key];
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
for (const child of value) {
|
|
192
|
+
if (isShNode(child)) {
|
|
193
|
+
visit(child, node);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (isShNode(value)) {
|
|
198
|
+
visit(value, node);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
steps.push(new VisitNodeStep({ target: node, phase: 2, args: [node, parent] }));
|
|
202
|
+
};
|
|
203
|
+
visit(this.ast, undefined);
|
|
204
|
+
return steps.values();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Returns every `Comment` node in the AST, in source order, gathering them
|
|
208
|
+
* (once, cached) via `sh-ast`'s structural `walk()` rather than
|
|
209
|
+
* `traverse()` — see `#comments`'s doc comment for why.
|
|
210
|
+
*
|
|
211
|
+
* `walk()` discovers children in AST field/insertion order (e.g.
|
|
212
|
+
* `Stmt.comments` before descending into `Stmt.cmd`'s own nested
|
|
213
|
+
* comments), which is not guaranteed to match *source* order — a comment
|
|
214
|
+
* trailing an early statement can be visited after a comment buried
|
|
215
|
+
* inside a later command substitution's own sub-tree, depending on field
|
|
216
|
+
* layout. `getDisableDirectives()`'s `disable`/`enable` ranges happen to
|
|
217
|
+
* be safe regardless (ESLint core re-sorts directives by location
|
|
218
|
+
* internally), but `applyInlineConfig()`'s "last inline-config comment
|
|
219
|
+
* wins" semantics for a repeated whole-file rule config, and any external
|
|
220
|
+
* caller of `getInlineConfigNodes()`, both depend on source order. Sorting
|
|
221
|
+
* once here — rather than requiring every consumer to re-sort — is the
|
|
222
|
+
* fix that's correct regardless of how `walk()` happens to traverse.
|
|
223
|
+
*/
|
|
224
|
+
#getComments() {
|
|
225
|
+
if (!this.#comments) {
|
|
226
|
+
const comments = [];
|
|
227
|
+
walk(this.ast, (node) => {
|
|
228
|
+
if (node.type === 'Comment') {
|
|
229
|
+
comments.push(node);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
comments.sort((a, b) => a.range[0] - b.range[0] || a.range[1] - b.range[1]);
|
|
233
|
+
this.#comments = comments;
|
|
234
|
+
}
|
|
235
|
+
return this.#comments;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Returns every `Comment` node whose text is a directive or inline-config
|
|
239
|
+
* candidate for this source code's `directivePrefix` — the pool both
|
|
240
|
+
* {@link ShellSourceCode.getDisableDirectives} and {@link ShellSourceCode.applyInlineConfig} draw from,
|
|
241
|
+
* and the array ESLint core itself asks for directly (to warn "has no
|
|
242
|
+
* effect" when `linterOptions.noInlineConfig` is set — see
|
|
243
|
+
* `eslint`'s `lib/linter/linter.js`, the `warnInlineConfig` branch).
|
|
244
|
+
*/
|
|
245
|
+
getInlineConfigNodes() {
|
|
246
|
+
this.#inlineConfigNodes ??= this.#getComments().filter((comment) => this.#inlineConfigPattern.test(getCommentText(comment)));
|
|
247
|
+
return this.#inlineConfigNodes;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Returns the disable/enable directives found among
|
|
251
|
+
* {@link ShellSourceCode.getInlineConfigNodes}'s comments, recognizing all four forms —
|
|
252
|
+
* `<prefix>-disable`, `<prefix>-enable`, `<prefix>-disable-line`,
|
|
253
|
+
* `<prefix>-disable-next-line` — with `-- justification` capture, per the
|
|
254
|
+
* `@eslint/core` `Directive` contract. Mirrors `@eslint/json`'s
|
|
255
|
+
* `JSONSourceCode.getDisableDirectives` (verified against its published
|
|
256
|
+
* source as the named ecosystem precedent), generalized to a configurable
|
|
257
|
+
* prefix instead of the hardcoded `"eslint"` label.
|
|
258
|
+
*
|
|
259
|
+
* Unlike `JSONSourceCode` (whose block comments can span multiple
|
|
260
|
+
* physical lines, making `<prefix>-disable-line` spanning lines a real,
|
|
261
|
+
* reportable problem), mvdan/sh's `#` line comments cannot contain an
|
|
262
|
+
* embedded newline — the parser always terminates a comment at end of
|
|
263
|
+
* line — so that particular problem can never arise here and is not
|
|
264
|
+
* checked for.
|
|
265
|
+
*/
|
|
266
|
+
getDisableDirectives() {
|
|
267
|
+
const problems = [];
|
|
268
|
+
const directives = [];
|
|
269
|
+
for (const comment of this.getInlineConfigNodes()) {
|
|
270
|
+
const parsed = commentParser.parseDirective(getCommentText(comment));
|
|
271
|
+
if (!parsed) {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const directiveType = this.#directiveLabels.get(parsed.label);
|
|
275
|
+
if (directiveType) {
|
|
276
|
+
directives.push(new Directive({
|
|
277
|
+
type: directiveType,
|
|
278
|
+
node: comment,
|
|
279
|
+
value: parsed.value,
|
|
280
|
+
justification: parsed.justification,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { problems, directives };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Returns the whole-file inline rule configuration found among
|
|
288
|
+
* {@link ShellSourceCode.getInlineConfigNodes}'s comments — a bare `<prefix> <rule-config>`
|
|
289
|
+
* comment (e.g. `# eslint sh/rule-a: "error"`), parsed with
|
|
290
|
+
* `ConfigCommentParser.parseJSONLikeConfig`. Mirrors `@eslint/json`'s
|
|
291
|
+
* `JSONSourceCode.applyInlineConfig`. This is the surface
|
|
292
|
+
* `linterOptions.noInlineConfig: true` suppresses (ESLint core skips
|
|
293
|
+
* calling this method — and `getDisableDirectives`, together, since both
|
|
294
|
+
* are gated by the same flag in `eslint`'s own `linter.js` — and instead
|
|
295
|
+
* calls `getInlineConfigNodes` to emit a "has no effect" warning per
|
|
296
|
+
* comment; see design/ARCHITECTURE.md, "SourceCode and Language objects").
|
|
297
|
+
*/
|
|
298
|
+
applyInlineConfig() {
|
|
299
|
+
const problems = [];
|
|
300
|
+
const configs = [];
|
|
301
|
+
for (const comment of this.getInlineConfigNodes()) {
|
|
302
|
+
const parsed = commentParser.parseDirective(getCommentText(comment));
|
|
303
|
+
if (parsed?.label !== this.#directivePrefix) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const parseResult = commentParser.parseJSONLikeConfig(parsed.value);
|
|
307
|
+
const loc = this.getLoc(comment);
|
|
308
|
+
if (parseResult.ok) {
|
|
309
|
+
configs.push({ config: { rules: parseResult.config }, loc });
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
problems.push({ ruleId: null, message: parseResult.error.message, loc });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { configs, problems };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
//# sourceMappingURL=shell-source-code.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-source-code.js","sourceRoot":"","sources":["../../src/language/shell-source-code.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads this package's own version from its `package.json` at runtime.
|
|
3
|
+
* Never hardcoded: the plugin's `meta.version` must always reflect the
|
|
4
|
+
* published version, verified by a test asserting the two match (see
|
|
5
|
+
* `test/plugin-meta.test.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Works identically whether this module runs as `src/version.ts` (vitest)
|
|
8
|
+
* or the compiled `dist/version.js` (published package), since both sit one
|
|
9
|
+
* directory below the package root that contains `package.json`.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare function readPackageVersion(): string;
|
|
14
|
+
//# sourceMappingURL=version.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAkB3C"}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* Reads this package's own version from its `package.json` at runtime.
|
|
6
|
+
* Never hardcoded: the plugin's `meta.version` must always reflect the
|
|
7
|
+
* published version, verified by a test asserting the two match (see
|
|
8
|
+
* `test/plugin-meta.test.ts`).
|
|
9
|
+
*
|
|
10
|
+
* Works identically whether this module runs as `src/version.ts` (vitest)
|
|
11
|
+
* or the compiled `dist/version.js` (published package), since both sit one
|
|
12
|
+
* directory below the package root that contains `package.json`.
|
|
13
|
+
*
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function readPackageVersion() {
|
|
17
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const packageJsonPath = join(packageRoot, 'package.json');
|
|
19
|
+
const raw = readFileSync(packageJsonPath, 'utf8');
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (typeof parsed === 'object' &&
|
|
22
|
+
parsed !== null &&
|
|
23
|
+
!Array.isArray(parsed) &&
|
|
24
|
+
typeof parsed.version === 'string') {
|
|
25
|
+
return parsed.version;
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`@eslint-sh/eslint-plugin: package.json at ${packageJsonPath} is missing a string "version" field`);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,kBAAkB;IAChC,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACxE,MAAM,eAAe,GAAG,IAAI,CAAC,WAAW,EAAE,cAAc,CAAC,CAAC;IAC1D,MAAM,GAAG,GAAG,YAAY,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IAClD,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAExC,IACE,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,KAAK,IAAI;QACf,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC;QACtB,OAAQ,MAAkC,CAAC,OAAO,KAAK,QAAQ,EAC/D,CAAC;QACD,OAAQ,MAAkC,CAAC,OAAiB,CAAC;IAC/D,CAAC;IAED,MAAM,IAAI,KAAK,CACb,6CAA6C,eAAe,sCAAsC,CACnG,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Custom-rule tutorial: from a cookbook selector to a firing rule
|
|
2
|
+
|
|
3
|
+
The [selector cookbook](./selectors.md) is a reference for selector strings; this page bridges
|
|
4
|
+
one of those selectors to a complete, runnable ESLint rule: a `{ meta, create(context) }` module,
|
|
5
|
+
a `context.report` call with a message and location, and the exact flat-config (`eslint.config.mjs`)
|
|
6
|
+
wiring — `plugins`, `rules`, `language` — needed to run it.
|
|
7
|
+
|
|
8
|
+
Every code block on this page is the literal, shipped file it names — not a paraphrase — and both
|
|
9
|
+
files are executed verbatim in CI (`test/custom-rule-tutorial.test.ts` imports
|
|
10
|
+
[`examples/no-rm-rf-rule.js`](./examples/no-rm-rf-rule.js) and
|
|
11
|
+
[`examples/eslint.config.mjs`](./examples/eslint.config.mjs) directly and runs them through a real
|
|
12
|
+
`eslint` `Linter.verify()`): the rule fires on a fixture script and does not fire on a clean one,
|
|
13
|
+
so this tutorial cannot silently drift from working code. Both example files ship with this
|
|
14
|
+
package (`docs/examples/`), so you can copy them into your own project as-is.
|
|
15
|
+
|
|
16
|
+
## 1. Pick a selector from the cookbook
|
|
17
|
+
|
|
18
|
+
The cookbook's `:command` [launch class](./selectors.md#the-six-launch-classes) matches every
|
|
19
|
+
`CallExpr` node — every shell command invocation. That's the selector this tutorial's rule listens
|
|
20
|
+
on; `create()` then narrows further (is the command `rm`? does it pass a combined `-rf`/`-fr`
|
|
21
|
+
flag?) the same way a hand-written rule narrows any other selector-first ESLint rule.
|
|
22
|
+
|
|
23
|
+
## 2. Write the rule module
|
|
24
|
+
|
|
25
|
+
`docs/examples/no-rm-rf-rule.js`:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
/**
|
|
29
|
+
* The complete "flag `rm -rf`" custom rule from the custom-rule tutorial
|
|
30
|
+
* (`../custom-rule-tutorial.md`). Plain, directly-runnable JavaScript (no
|
|
31
|
+
* compile step) — this file *is* the tutorial's rule-module code fence,
|
|
32
|
+
* copy-paste-verbatim; `test/custom-rule-tutorial.test.ts` imports this
|
|
33
|
+
* exact file (not a reconstruction) and asserts the markdown fence matches
|
|
34
|
+
* it byte-for-byte, so the two can't silently drift.
|
|
35
|
+
*
|
|
36
|
+
* Built on the `:command` launch class from the selector cookbook
|
|
37
|
+
* (`../selectors.md`) — `:command` matches every `CallExpr`, so `create()`
|
|
38
|
+
* narrows further by inspecting the command word and its flag arguments,
|
|
39
|
+
* exactly the pattern the cookbook's intro describes ("writing a rule as a
|
|
40
|
+
* selector string rather than a `create()` full of manual type checks" —
|
|
41
|
+
* here the selector does the node-type narrowing, `create()` only does the
|
|
42
|
+
* domain-specific check).
|
|
43
|
+
*
|
|
44
|
+
* Flag words are resolved with `sh-ast/analyze`'s `resolveWord` rather than
|
|
45
|
+
* a hand-rolled scan: a word is only ever treated as the literal text
|
|
46
|
+
* `-rf`/`-fr` when it is *statically* that string (bare or quoted literal
|
|
47
|
+
* concatenations); a word containing a `ParamExp`/`CmdSubst`/`ArithmExp`/
|
|
48
|
+
* `ProcSubst` resolves to `null` ("unknown") rather than being silently
|
|
49
|
+
* erased into a static match or a static non-match — the same static-word
|
|
50
|
+
* contract `design/PACKAGES.md` documents for `resolveWordText`.
|
|
51
|
+
*
|
|
52
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
53
|
+
* @see ../selectors.md — the `:command` launch class
|
|
54
|
+
*/
|
|
55
|
+
import { resolveWord } from 'sh-ast/analyze';
|
|
56
|
+
|
|
57
|
+
const noRmRf = {
|
|
58
|
+
meta: {
|
|
59
|
+
type: 'problem',
|
|
60
|
+
docs: {
|
|
61
|
+
description: 'disallow `rm -rf`/`rm -fr` invocations',
|
|
62
|
+
},
|
|
63
|
+
schema: [],
|
|
64
|
+
messages: {
|
|
65
|
+
rmRf: 'Avoid `rm -rf`; it silently deletes data with no confirmation. Prefer a scoped, reviewed removal.',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
return {
|
|
70
|
+
':command'(node) {
|
|
71
|
+
const args = node.args ?? [];
|
|
72
|
+
const words = args.map((arg) => {
|
|
73
|
+
const resolved = resolveWord(arg, { context: 'command-argument' });
|
|
74
|
+
return resolved.static ? resolved.text : null;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (words[0] !== 'rm') {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// `rm -- -rf` and `rm build -- -rf` are not `rm -rf` — everything
|
|
82
|
+
// after a `--` end-of-options marker is an operand (a filename),
|
|
83
|
+
// never a flag (POSIX Utility Syntax Guidelines, guideline 10).
|
|
84
|
+
const endOfOptions = words.indexOf('--');
|
|
85
|
+
const flagCandidates = words.slice(1, endOfOptions === -1 ? undefined : endOfOptions);
|
|
86
|
+
|
|
87
|
+
const isRecursiveForce = flagCandidates.some(
|
|
88
|
+
(word) =>
|
|
89
|
+
word !== null &&
|
|
90
|
+
(/^-[a-z]*r[a-z]*f[a-z]*$/i.test(word) || /^-[a-z]*f[a-z]*r[a-z]*$/i.test(word)),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (isRecursiveForce) {
|
|
94
|
+
context.report({ node, loc: node.loc, messageId: 'rmRf' });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export default noRmRf;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A few things worth calling out:
|
|
105
|
+
|
|
106
|
+
- The listener key, `':command'`, is a selector string — one of the [six launch
|
|
107
|
+
classes](./selectors.md#the-six-launch-classes) — not a bare node-type name. ESLint's
|
|
108
|
+
`esquery`-based rule-listener matching (via `ShellLanguage.matchesSelectorClass`) treats it
|
|
109
|
+
exactly like any other selector key such as `CallExpr` or `CallExpr[args.0.parts.0.value='jq']`.
|
|
110
|
+
- `resolveWord(arg, { context: 'command-argument' })` — from `sh-ast/analyze` — is the correct way
|
|
111
|
+
to ask "is this word statically the string X": it returns `{ static: true, text }` for a bare or
|
|
112
|
+
quoted literal (`rm`, `'r'm`, `"-rf"` all resolve), and `{ static: false, reason }` the moment any
|
|
113
|
+
part of the word depends on a `ParamExp`/`CmdSubst`/`ArithmExp`/`ProcSubst` — never inventing a
|
|
114
|
+
value for a dynamic word.
|
|
115
|
+
- `context.report({ node, loc: node.loc, messageId: 'rmRf' })` reports with both a message (via
|
|
116
|
+
`meta.messages.rmRf`) and an explicit location (`node.loc`, `ShellSourceCode`'s
|
|
117
|
+
`{ start: { line, column }, end: { line, column } }` shape) — the two pieces every ESLint lint
|
|
118
|
+
message needs.
|
|
119
|
+
|
|
120
|
+
## 3. Wire it into a flat config
|
|
121
|
+
|
|
122
|
+
`docs/examples/eslint.config.mjs`:
|
|
123
|
+
|
|
124
|
+
```js
|
|
125
|
+
// eslint.config.mjs
|
|
126
|
+
//
|
|
127
|
+
// The custom-rule tutorial's exact flat-config wiring
|
|
128
|
+
// (`../custom-rule-tutorial.md`, step 3). `test/custom-rule-tutorial.test.ts`
|
|
129
|
+
// imports this file's default export directly and runs it through a real
|
|
130
|
+
// `eslint` `Linter.verify()` — not a reconstruction of this shape — so the
|
|
131
|
+
// documented wiring is genuinely tested.
|
|
132
|
+
import sh from '@eslint-sh/eslint-plugin'; // or the umbrella: import sh from 'eslint-sh'
|
|
133
|
+
import noRmRf from './no-rm-rf-rule.js';
|
|
134
|
+
|
|
135
|
+
export default [
|
|
136
|
+
{
|
|
137
|
+
files: ['**/*.sh', '**/*.bash'],
|
|
138
|
+
plugins: {
|
|
139
|
+
sh,
|
|
140
|
+
'my-rules': { rules: { 'no-rm-rf': noRmRf } },
|
|
141
|
+
},
|
|
142
|
+
language: 'sh/sh',
|
|
143
|
+
rules: {
|
|
144
|
+
'my-rules/no-rm-rf': 'error',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- `plugins.sh` supplies `languages.sh` (the `ShellLanguage`/`ShellSourceCode` core) — required for
|
|
151
|
+
`language: 'sh/sh'` to resolve.
|
|
152
|
+
- `plugins['my-rules']` is a second, minimal plugin object whose only job is to register the rule
|
|
153
|
+
module from step 2 under a name (`no-rm-rf`); any plugin key works, `'my-rules'` is just a
|
|
154
|
+
placeholder for your own project's or plugin's namespace.
|
|
155
|
+
- `rules['my-rules/no-rm-rf']` turns the rule on, the same as any other ESLint rule entry.
|
|
156
|
+
|
|
157
|
+
## 4. What "firing" looks like
|
|
158
|
+
|
|
159
|
+
Given
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
rm -rf "$BUILD_DIR"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
the rule reports once, at the `CallExpr`'s location, with the message text from
|
|
166
|
+
`meta.messages.rmRf`. Given a clean script with no `rm -rf`/`rm -fr` invocation, the rule produces
|
|
167
|
+
no messages at all — both cases are asserted in `test/custom-rule-tutorial.test.ts` against a real
|
|
168
|
+
`eslint` `Linter.verify()` run (not a hand-built approximation of one), using the real
|
|
169
|
+
`docs/examples/eslint.config.mjs` shown in step 3.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// eslint.config.mjs
|
|
2
|
+
//
|
|
3
|
+
// The custom-rule tutorial's exact flat-config wiring
|
|
4
|
+
// (`../custom-rule-tutorial.md`, step 3). `test/custom-rule-tutorial.test.ts`
|
|
5
|
+
// imports this file's default export directly and runs it through a real
|
|
6
|
+
// `eslint` `Linter.verify()` — not a reconstruction of this shape — so the
|
|
7
|
+
// documented wiring is genuinely tested.
|
|
8
|
+
import sh from '@eslint-sh/eslint-plugin'; // or the umbrella: import sh from 'eslint-sh'
|
|
9
|
+
import noRmRf from './no-rm-rf-rule.js';
|
|
10
|
+
|
|
11
|
+
export default [
|
|
12
|
+
{
|
|
13
|
+
files: ['**/*.sh', '**/*.bash'],
|
|
14
|
+
plugins: {
|
|
15
|
+
sh,
|
|
16
|
+
'my-rules': { rules: { 'no-rm-rf': noRmRf } },
|
|
17
|
+
},
|
|
18
|
+
language: 'sh/sh',
|
|
19
|
+
rules: {
|
|
20
|
+
'my-rules/no-rm-rf': 'error',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The complete "flag `rm -rf`" custom rule from the custom-rule tutorial
|
|
3
|
+
* (`../custom-rule-tutorial.md`). Plain, directly-runnable JavaScript (no
|
|
4
|
+
* compile step) — this file *is* the tutorial's rule-module code fence,
|
|
5
|
+
* copy-paste-verbatim; `test/custom-rule-tutorial.test.ts` imports this
|
|
6
|
+
* exact file (not a reconstruction) and asserts the markdown fence matches
|
|
7
|
+
* it byte-for-byte, so the two can't silently drift.
|
|
8
|
+
*
|
|
9
|
+
* Built on the `:command` launch class from the selector cookbook
|
|
10
|
+
* (`../selectors.md`) — `:command` matches every `CallExpr`, so `create()`
|
|
11
|
+
* narrows further by inspecting the command word and its flag arguments,
|
|
12
|
+
* exactly the pattern the cookbook's intro describes ("writing a rule as a
|
|
13
|
+
* selector string rather than a `create()` full of manual type checks" —
|
|
14
|
+
* here the selector does the node-type narrowing, `create()` only does the
|
|
15
|
+
* domain-specific check).
|
|
16
|
+
*
|
|
17
|
+
* Flag words are resolved with `sh-ast/analyze`'s `resolveWord` rather than
|
|
18
|
+
* a hand-rolled scan: a word is only ever treated as the literal text
|
|
19
|
+
* `-rf`/`-fr` when it is *statically* that string (bare or quoted literal
|
|
20
|
+
* concatenations); a word containing a `ParamExp`/`CmdSubst`/`ArithmExp`/
|
|
21
|
+
* `ProcSubst` resolves to `null` ("unknown") rather than being silently
|
|
22
|
+
* erased into a static match or a static non-match — the same static-word
|
|
23
|
+
* contract `design/PACKAGES.md` documents for `resolveWordText`.
|
|
24
|
+
*
|
|
25
|
+
* @see ../custom-rule-tutorial.md — the tutorial this rule backs
|
|
26
|
+
* @see ../selectors.md — the `:command` launch class
|
|
27
|
+
*/
|
|
28
|
+
import { resolveWord } from 'sh-ast/analyze';
|
|
29
|
+
|
|
30
|
+
const noRmRf = {
|
|
31
|
+
meta: {
|
|
32
|
+
type: 'problem',
|
|
33
|
+
docs: {
|
|
34
|
+
description: 'disallow `rm -rf`/`rm -fr` invocations',
|
|
35
|
+
},
|
|
36
|
+
schema: [],
|
|
37
|
+
messages: {
|
|
38
|
+
rmRf: 'Avoid `rm -rf`; it silently deletes data with no confirmation. Prefer a scoped, reviewed removal.',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
return {
|
|
43
|
+
':command'(node) {
|
|
44
|
+
const args = node.args ?? [];
|
|
45
|
+
const words = args.map((arg) => {
|
|
46
|
+
const resolved = resolveWord(arg, { context: 'command-argument' });
|
|
47
|
+
return resolved.static ? resolved.text : null;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (words[0] !== 'rm') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// `rm -- -rf` and `rm build -- -rf` are not `rm -rf` — everything
|
|
55
|
+
// after a `--` end-of-options marker is an operand (a filename),
|
|
56
|
+
// never a flag (POSIX Utility Syntax Guidelines, guideline 10).
|
|
57
|
+
const endOfOptions = words.indexOf('--');
|
|
58
|
+
const flagCandidates = words.slice(1, endOfOptions === -1 ? undefined : endOfOptions);
|
|
59
|
+
|
|
60
|
+
const isRecursiveForce = flagCandidates.some(
|
|
61
|
+
(word) =>
|
|
62
|
+
word !== null &&
|
|
63
|
+
(/^-[a-z]*r[a-z]*f[a-z]*$/i.test(word) || /^-[a-z]*f[a-z]*r[a-z]*$/i.test(word)),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (isRecursiveForce) {
|
|
67
|
+
context.report({ node, loc: node.loc, messageId: 'rmRf' });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default noRmRf;
|