@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.
@@ -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"}
@@ -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;