eslint-sh 0.5.0 → 0.7.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/README.md CHANGED
@@ -1,17 +1,33 @@
1
1
  # eslint-sh
2
2
 
3
- Shell script parsing and syntax-error reporting for ESLint, with custom-rule authoring over a
4
- typed shell AST.
3
+ Shell script linting for ESLint: enforce project conventions and contracts with built-in rules,
4
+ shared presets, and custom rules over a typed shell AST.
5
5
 
6
6
  This is the canonical quickstart for the `eslint-sh` / `@eslint-sh/eslint-plugin` project — start
7
7
  here regardless of which of the two packages you install.
8
8
 
9
- **Roadmap:** built-in lint rules and shared configs are planned but not implemented yet.
9
+ Built-in rules ship in four presets: `configs.recommended`, `configs['agent-authored']`
10
+ and the deployment-target packs `configs['bash-3.2']` and `configs['posix-portable']` (see
11
+ [Presets](#presets)). Browse the
12
+ [rules index](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/rules/README.md)
13
+ for each rule's purpose, preset membership, and autofix availability.
14
+
15
+ **Run both eslint-sh and shellcheck.** shellcheck catches shell correctness problems such as
16
+ quoting and word-splitting mistakes. eslint-sh enforces your project's policy: strict mode,
17
+ command declarations, network wrappers, and other conventions. It complements shellcheck; it
18
+ does not aim to reproduce shellcheck's diagnostic catalog. See
19
+ [running eslint-sh beside shellcheck](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/shellcheck.md)
20
+ for who owns what, and the
21
+ [design rationale](https://github.com/mike-north/eslint-sh/blob/main/design/README.md#eslint-sh-and-shellcheck).
10
22
 
11
23
  ## Installation
12
24
 
13
- Requires `eslint@^10` as a peer dependency (already listed as a peer in `eslint-sh`'s
14
- `package.json`, so a compatible `eslint` install satisfies it automatically):
25
+ Requires Node.js 22.13 or later on the 22 line, or Node.js 24 or later (`engines.node` is
26
+ `^22.13.0 || >=24`, the range ESLint 10 itself supports, without the end-of-life Node 20 line).
27
+
28
+ Also requires `eslint@^10.2.0` (ESLint 10.2 or any later 10.x release) as a peer dependency (already
29
+ listed as a peer in `eslint-sh`'s `package.json`, so a compatible `eslint` install satisfies it
30
+ automatically):
15
31
 
16
32
  ```sh
17
33
  npm install --save-dev eslint-sh
@@ -19,8 +35,10 @@ npm install --save-dev eslint-sh
19
35
 
20
36
  ## Usage
21
37
 
22
- This release reports shell syntax (parse) errors only — no lint rules ship yet, so a "0 problems"
23
- result means the script parsed without a syntax error, not that it is problem-free.
38
+ Start with `sh.configs.recommended`. It supplies the shell language and plugin registration,
39
+ reports syntax errors, and enables the convention-free rules: require a shebang and strict
40
+ mode, disallow `eval` and dynamic sourcing, and warn about literal secrets in flags. You supply
41
+ the paths to your shell scripts.
24
42
 
25
43
  ```js
26
44
  // eslint.config.mjs
@@ -29,29 +47,262 @@ import sh from 'eslint-sh'; // equivalent to: import sh from '@eslint-sh/eslint-
29
47
 
30
48
  export default defineConfig([
31
49
  {
32
- files: ['**/*.sh', '**/*.bash'],
33
- plugins: { sh },
34
- language: 'sh/sh',
35
- languageOptions: { dialect: 'bash', shellVersion: '3.2' },
50
+ // Extensionless scripts need `bin/?*`: ESLint never lints a file matched only by `bin/*` or `bin/**`.
51
+ files: ['**/*.sh', '**/*.bash', 'bin/?*'],
52
+ extends: [sh.configs.recommended],
36
53
  },
37
54
  ]);
38
55
  ```
39
56
 
57
+ For example, save this as `bin/deploy` (or `scripts/example.sh`):
58
+
59
+ ```sh
60
+ #!/usr/bin/env bash
61
+ set -euo pipefail
62
+ if [[ "$#" -gt 0 ]]; then echo "$1"; fi
63
+ ```
64
+
65
+ Run `npx eslint .` to lint your scripts, or `npx eslint . --fix` to apply available autofixes.
66
+ This script passes the recommended rules. A script without a shebang or strict mode reports
67
+ errors, and `--fix` can insert both. A clean result means the enabled rules found no problems;
68
+ keep running shellcheck alongside eslint-sh for general shell correctness checks.
69
+
70
+ Shell scripts are often extensionless (`bin/deploy`), so list the directories that hold them
71
+ with a pattern like `bin/?*`. A pattern that ends in `*` or `/**` (`bin/*`, `bin/**`) never makes a
72
+ file lintable on its own, and ESLint skips the file without a warning. See
73
+ [My extensionless scripts aren't being linted](#troubleshooting) below.
74
+
40
75
  `languageOptions` is entirely optional — omitting it is equivalent to
41
76
  `languageOptions: { dialect: 'bash' }`, since `dialect` defaults to `"bash"` (see the
42
77
  `languageOptions` reference table below). This README's own [custom-rule tutorial](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/custom-rule-tutorial.md)
43
78
  config omits `languageOptions` for exactly that reason — it targets bash, the default, so
44
- there's nothing to override; add it explicitly (as above) when you need a non-default
45
- `dialect`, a `shellVersion` hint, or a custom `directivePrefix`.
79
+ there's nothing to override. Add `languageOptions: { dialect: 'posix' }` to your config entry
80
+ when targeting POSIX shell, or set a `shellVersion` hint or custom `directivePrefix` as needed.
81
+
82
+ ### CommonJS config
83
+
84
+ In a package without `"type": "module"` (the ESLint default), `eslint.config.js` is CommonJS, as
85
+ is `eslint.config.cjs` in any package. `eslint-sh` is ESM-only, so `require('eslint-sh')` returns
86
+ the module namespace and the plugin object is its `.default`. Named exports such as
87
+ `commentPragma` are ordinary properties of the `require` result. Without `.default`, `sh.configs`
88
+ is `undefined` and ESLint fails with `Cannot read properties of undefined (reading 'recommended')`.
89
+
90
+ ```js
91
+ // eslint.config.js (CommonJS package) or eslint.config.cjs
92
+ const { defineConfig } = require('eslint/config');
93
+ const sh = require('eslint-sh').default;
94
+ // Named exports need no `.default`.
95
+ const { commentPragma } = require('eslint-sh');
96
+
97
+ module.exports = defineConfig([
98
+ {
99
+ files: ['**/*.sh'],
100
+ extends: [sh.configs.recommended],
101
+ settings: { sh: { declarationConvention: commentPragma } },
102
+ rules: { 'sh/no-undeclared-commands': 'error' },
103
+ },
104
+ ]);
105
+ ```
106
+
107
+ ## Presets
108
+
109
+ Each preset is a single config object that supplies `plugins: { sh }` and `language: 'sh/sh'` and
110
+ has no `files` key. Extend it from an entry that lists your shell scripts:
111
+
112
+ ```js
113
+ import { defineConfig } from 'eslint/config';
114
+ import sh from 'eslint-sh';
115
+
116
+ export default defineConfig([{ files: ['**/*.sh', 'bin/?*'], extends: [sh.configs.recommended] }]);
117
+ ```
118
+
119
+ - **`sh.configs.recommended`** enables the rules that assume no project conventions:
120
+ `sh/require-strict-mode`, `sh/require-shebang`, `sh/no-eval`, `sh/no-unchecked-cd` and
121
+ `sh/shebang-matches-dialect` at `error`, and
122
+ `sh/no-secret-flags` and `sh/quote-expansions` at `warn`.
123
+ - **`sh.configs['agent-authored']`** is for scripts that are machine-written and human-reviewed.
124
+ It enables every `recommended` rule plus the contract rules, all at `error`:
125
+ `sh/no-dynamic-invocation`, `sh/no-secret-env-reads`, `sh/no-undeclared-commands`,
126
+ `sh/no-unused-declarations`, `sh/require-network-wrapper`, `sh/require-tty-guard` and `sh/max-script-complexity`
127
+ (which caps function count, nesting depth and statement count).
128
+ `sh/no-secret-flags` and `sh/quote-expansions` are `error` here too.
129
+ - **`sh.configs['bash-3.2']`** is for scripts that must run on the Bash 3.2 macOS ships as
130
+ `/bin/bash`. It sets `languageOptions: { dialect: 'bash', shellVersion: '3.2' }` and enables every
131
+ version-gated `compat/*` rule at `error`.
132
+ - **`sh.configs['posix-portable']`** reports Bash-only constructs that fail on dash, including
133
+ `[[`, `let` and `source`, but not `local`, `echo -e` or the others it leaves to shellcheck, so a
134
+ clean run does not prove a script runs under every POSIX `sh`. It sets
135
+ `languageOptions: { dialect: 'posix' }` and enables the `compat/*` rules and
136
+ `sh/shebang-matches-dialect` at `error`, and `sh/require-strict-mode` with `set -eu`.
137
+ Compose them after `recommended`:
138
+ `extends: [sh.configs.recommended, sh.configs['posix-portable']]`. See
139
+ [version compatibility](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/compat.md).
140
+
141
+ Three `agent-authored` rules report nothing until you configure them. `sh/no-undeclared-commands`
142
+ and `sh/no-unused-declarations` need a declaration convention bound in
143
+ `settings.sh.declarationConvention`, and `sh/require-network-wrapper` needs the wrapper your
144
+ network commands must run through, set once in `settings.sh.networkWrapper`. The declaration rules
145
+ read that setting too, so they count `curl` in `with-retry curl …` as used:
146
+
147
+ ```js
148
+ import { defineConfig } from 'eslint/config';
149
+ import sh, { commentPragma } from 'eslint-sh';
150
+
151
+ export default defineConfig([
152
+ {
153
+ files: ['**/*.sh', 'bin/?*'],
154
+ extends: [sh.configs['agent-authored']],
155
+ settings: { sh: { declarationConvention: commentPragma, networkWrapper: 'with-retry' } },
156
+ },
157
+ ]);
158
+ ```
46
159
 
47
160
  ## `languageOptions` reference
48
161
 
49
162
  | Option | Allowed values | Default | Effect |
50
163
  | ----------------- | ----------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
164
  | `dialect` | `"bash"`, `"posix"`, `"mksh"`, `"bats"`, `"zsh"` | `"bash"` | Selects which shell grammar the parser accepts. Enforcement is partial: some non-dialect constructs are rejected (e.g. bash array literals under `posix`), but others (e.g. `[[ ... ]]`, `let`) are accepted as ordinary commands — see the [per-dialect enforcement table](https://github.com/mike-north/sh-ast/blob/main/packages/sh-ast/README.md#dialect-enforcement). |
52
- | `shellVersion` | A numeric `MAJOR` or `MAJOR.MINOR` string, e.g. `"5"` or `"3.2"` | unset | Reserved for the rule layer (e.g. a future bash-3.2 compatibility check); has no effect on parsing today since no rules ship yet. |
165
+ | `shellVersion` | A numeric `MAJOR` or `MAJOR.MINOR` string, e.g. `"5"` or `"3.2"` | unset | The Bash version your scripts must run on. It has no effect on parsing; the `compat/*` rules read it to report features that version lacks. Unset means the newest version. See [version compatibility](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/compat.md). |
53
166
  | `directivePrefix` | Lowercase, hyphen-separated words, e.g. `"eslint"` or `"shellcheck-sh"` | `"eslint"` | The prefix directive/inline-config comments must start with — e.g. the default recognizes `# eslint-disable-next-line ...`. |
54
167
 
168
+ ## Continuous integration
169
+
170
+ Lint with the same command you run locally, `eslint .`, against the config from
171
+ [Usage](#usage). The config's `files` list decides which scripts are linted, including
172
+ extensionless ones such as `bin/deploy`. Listing paths on the command line does not replace it:
173
+ ESLint only lints a file the config's `files` patterns match. Here is a complete GitHub Actions
174
+ job:
175
+
176
+ ```yaml
177
+ name: Lint shell scripts
178
+ on:
179
+ push:
180
+ branches: [main]
181
+ pull_request:
182
+ permissions:
183
+ contents: read
184
+ jobs:
185
+ lint-shell:
186
+ runs-on: ubuntu-latest
187
+ steps:
188
+ - uses: actions/checkout@v4
189
+ - uses: actions/setup-node@v7
190
+ with:
191
+ node-version: 22
192
+ cache: npm
193
+ - run: npm ci
194
+ - name: eslint-sh
195
+ run: npx eslint . --max-warnings 0
196
+ - name: shellcheck
197
+ run: |
198
+ git ls-files -z '*.sh' '*.bash' 'bin/*' |
199
+ xargs -0 sh -c '[ "$#" -eq 0 ] || exec shellcheck -- "$@"' sh
200
+ ```
201
+
202
+ It assumes `eslint` and `eslint-sh` are in your `devDependencies` with a committed
203
+ `package-lock.json`; use your package manager's frozen install (`pnpm install --frozen-lockfile`)
204
+ otherwise. `permissions: contents: read` keeps the job's `GITHUB_TOKEN` read-only:
205
+ linting needs nothing more. The `uses:` lines name each action's major version for readability;
206
+ to guard against a moved tag, pin each one to a full commit SHA with the version as a trailing
207
+ comment (`actions/checkout@<40-character SHA> # v4.4.0`), as
208
+ [this repository's workflows](https://github.com/mike-north/eslint-sh/tree/main/.github/workflows)
209
+ do, and let Dependabot's `github-actions` updates bump them. `ubuntu-latest` runners ship with shellcheck. The step is separate because shellcheck
210
+ is a different tool with its own exit code; see
211
+ [running eslint-sh beside shellcheck](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/shellcheck.md).
212
+
213
+ The shellcheck step checks every tracked `*.sh` and `*.bash` file plus everything tracked under
214
+ `bin/`, so it also works in a project that has no `bin/` or `scripts/` folder (it then checks
215
+ nothing and exits 0). Adapt the pathspecs to where your scripts live. shellcheck needs a shebang
216
+ or a `# shellcheck shell=bash` directive to know a file's dialect: an extensionless file without
217
+ either fails with SC2148, so keep non-shell files out of `bin/`.
218
+
219
+ Exit codes of `eslint`:
220
+
221
+ | Exit code | Meaning |
222
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------ |
223
+ | `0` | No errors. Warnings alone (`sh/quote-expansions` and `sh/no-secret-flags` under `recommended`) do not fail the job. |
224
+ | `1` | At least one error, or more warnings than `--max-warnings` allows (`ESLint found too many warnings (maximum: 0).`). |
225
+ | `2` | ESLint could not run: for example a CLI pattern that matches no files, or an invalid config. Treat it as a failed job as well. |
226
+
227
+ `--max-warnings 0` in the job above makes warnings fail it. Drop the flag if you want warnings
228
+ reported but not blocking.
229
+
230
+ **Pre-commit hook.** To lint only the shell files in a commit, including extensionless ones,
231
+ select the staged paths with git and pass them to ESLint. Save this as `.git/hooks/pre-commit`
232
+ (or run it from your hook manager) and make it executable:
233
+
234
+ ```sh
235
+ #!/bin/sh
236
+ git diff --cached --name-only -z --diff-filter=ACM -- '*.sh' '*.bash' 'bin/*' |
237
+ xargs -0 sh -c '[ "$#" -eq 0 ] || exec npx eslint --max-warnings 0 --no-warn-ignored -- "$@"' sh
238
+ ```
239
+
240
+ The pathspec `'bin/*'` selects extensionless scripts that a `*.sh` pattern would miss; match it
241
+ to the `files` patterns in your config. `--no-warn-ignored` keeps a staged file that your config
242
+ does not cover from failing the hook as an "ignored file" warning. File names are passed
243
+ NUL-separated, so names with spaces work, and the hook does nothing when no shell file is staged.
244
+
245
+ **Linting code you don't control.** The job above runs the checkout's own config and install,
246
+ which is right for your own repository. When CI lints contributions from people who don't own the
247
+ policy, a script can switch rules off with `# eslint-disable` comments, and the checkout's
248
+ `eslint.config.mjs` is code the contributor wrote. See
249
+ [linting code you don't control](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/untrusted-code.md)
250
+ for a config that ignores those comments and a command that runs ESLint from outside the checkout.
251
+
252
+ ## Editor setup
253
+
254
+ Editors that run ESLint through its language server, such as the VS Code ESLint extension, run
255
+ the `eslint` installed in your project. They read the same `eslint.config.mjs` as the command
256
+ line, so the plugin and the `files` patterns from [Usage](#usage) apply with no editor-specific
257
+ configuration of eslint-sh. Install `eslint` and `eslint-sh` in the project, not globally.
258
+
259
+ **VS Code.** What the
260
+ [ESLint extension's documentation](https://github.com/microsoft/vscode-eslint#readme) says, which
261
+ has not been tried against eslint-sh in an editor (see "What was and was not verified" below):
262
+
263
+ - The extension only activates for the language ids in its `eslint.probe` list by default
264
+ (JavaScript, TypeScript, HTML, Markdown, JSON and similar, but not `shellscript`). Enforce
265
+ validation of shell files by listing the language id in `eslint.validate`.
266
+ - Fix on save is the `source.fixAll.eslint` code action.
267
+
268
+ ```json
269
+ {
270
+ "eslint.validate": ["shellscript"],
271
+ "editor.codeActionsOnSave": {
272
+ "source.fixAll.eslint": "explicit"
273
+ }
274
+ }
275
+ ```
276
+
277
+ Put it in `.vscode/settings.json` to share it with your team. Two things are expected but not
278
+ confirmed, so check them (items 3 and 4 below) rather than assuming: that fix on save applies
279
+ eslint-sh's autofixes, such as inserting a missing shebang and strict mode, and that the
280
+ `shellscript` language id is what VS Code assigns to your scripts, including extensionless ones.
281
+ The extension's documentation says ESLint v9 and later use flat config files, which is what
282
+ `eslint.config.mjs` is, so `eslint.useFlatConfig` should not be needed; that is also unconfirmed.
283
+
284
+ **What was and was not verified.**
285
+
286
+ - Verified: the command-line behavior this section relies on (the `files` patterns, extensionless
287
+ scripts, and the exit codes above), by running it from packed tarballs of both packages in a
288
+ separate project, and by a test in this repository that runs the documented commands.
289
+ - Not verified: everything about VS Code. No editor session was driven, and the ESLint extension
290
+ was not installed. The settings above come from the extension's documentation only, and the
291
+ extension version tested is therefore none.
292
+
293
+ Before relying on this setup, check by hand in VS Code with the ESLint extension:
294
+
295
+ 1. Open a `.sh` file and a shebang-less `bin/` script in a project with `eslint-sh` installed.
296
+ Do diagnostics from eslint-sh rules (for example `sh/require-shebang`) appear in the Problems
297
+ panel with and without `eslint.validate`? If they appear without it, the setting is not needed.
298
+ 2. Does the extension's output channel ("ESLint") report any error loading the plugin or config?
299
+ 3. Remove the shebang from a `.sh` file and save. Is it inserted along with strict mode, once
300
+ `source.fixAll.eslint` is enabled?
301
+ 4. Does an extensionless script with a shebang, such as `bin/deploy`, get diagnostics? What does
302
+ VS Code show as its language in the status bar, with and without the shebang? If it is not
303
+ `shellscript` (shown as "Shell Script"), the extension will not validate it under
304
+ `eslint.validate: ["shellscript"]`.
305
+
55
306
  ## Troubleshooting
56
307
 
57
308
  Every worked example below (config, script, and error output) is extracted straight out of this
@@ -107,6 +358,36 @@ from what the plugin actually does.
107
358
  The fix for both is the same one-line addition: set `language: 'sh/sh'` alongside
108
359
  `plugins: { sh }` in the config object.
109
360
 
361
+ - **My extensionless scripts aren't being linted.** ESLint treats a `files` pattern that ends in
362
+ `*` or `/**` as one that only adds settings to files another pattern already matched. Such a
363
+ pattern never makes a file lintable on its own. So with this config:
364
+
365
+ ```js
366
+ export default [{ files: ['**/*.sh', 'bin/*'], plugins: { sh }, language: 'sh/sh' }];
367
+ ```
368
+
369
+ `eslint .` lints `bin/build.sh` but skips the extensionless `bin/deploy`. It prints no warning
370
+ and still exits 0. Use a pattern that matches the file by itself:
371
+
372
+ ```js
373
+ export default [{ files: ['**/*.sh', 'bin/?*'], plugins: { sh }, language: 'sh/sh' }];
374
+ ```
375
+
376
+ Each of these patterns lints `bin/deploy`:
377
+ - `bin/?*`: every file directly in `bin/`.
378
+ - `bin/!(*.*)`: only the files in `bin/` whose names have no dot, so `bin/helper.js` stays out.
379
+ - `bin/**/?*`: every file in `bin/` and its subdirectories.
380
+ - `bin/deploy`: just that one file.
381
+
382
+ To check whether ESLint will lint a file, print the config it resolves for that file:
383
+
384
+ ```sh
385
+ npx eslint --print-config bin/deploy
386
+ ```
387
+
388
+ It prints `undefined` when no `files` pattern makes the file lintable. When one does, it prints
389
+ the resolved config, which includes `"language": "sh/sh"`.
390
+
110
391
  - **A script I believe is valid reports a parse error.** The parser is dialect-sensitive: a
111
392
  construct that's valid in one shell grammar may be rejected in another. Given this config:
112
393
 
@@ -136,17 +417,51 @@ from what the plugin actually does.
136
417
  Set `languageOptions.dialect` to match the shell the script is actually written for. Note that
137
418
  dialect enforcement is partial by design: some bash-isms — notably `[[ ... ]]` and `let` — are
138
419
  not parse errors under `dialect: 'posix'`; they silently parse as ordinary commands instead, so
139
- a clean `posix` run does not by itself prove a script is POSIX-compliant. See the
420
+ a clean `posix` run does not by itself prove a script is POSIX-compliant. The `posix-portable`
421
+ preset reports `[[`, `let` and `source`, and
422
+ [names what it does not report](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/compat.md#what-posix-portable-does-not-report). See the
140
423
  [per-dialect construct enforcement audit](https://github.com/mike-north/sh-ast/blob/main/packages/sh-ast/README.md#dialect-enforcement)
141
424
  in `sh-ast` for the full accept/reject matrix and rationale.
142
425
 
143
- - **Setting `shellVersion` doesn't change anything.** That's expected today — see the
144
- `languageOptions` reference table above: `shellVersion` is reserved for a future rule layer and
145
- has no effect on parsing while no lint rules ship.
146
- - **`# eslint-disable-next-line ...` comments don't seem to do anything.** No lint rules ship yet
147
- in this release, so there's nothing for a disable comment to suppress until you register a
148
- custom rule (see the `@eslint-sh/eslint-plugin` README's "Writing your own rules" section). If
149
- you've registered a custom rule and the directive still isn't recognized, check
426
+ - **Every JavaScript file reports a shell parsing error.** `sh.configs.recommended` has no
427
+ `files` key by design: it lints exactly the paths of the config entry it is used in. Used on its
428
+ own, it has no paths, so it applies to every file ESLint lints, including your JavaScript.
429
+ eslint-sh recognizes common non-shell file types (JavaScript, TypeScript, JSON, Markdown, CSS,
430
+ HTML, Vue, Svelte, YAML) and reports one message per file instead of a misleading shell syntax
431
+ error. Given this config:
432
+
433
+ ```js
434
+ export default defineConfig([sh.configs.recommended]);
435
+ ```
436
+
437
+ linting `index.js`:
438
+
439
+ ```js
440
+ if (a) {
441
+ b();
442
+ }
443
+ ```
444
+
445
+ produces:
446
+
447
+ ```text
448
+ Parsing error: This JavaScript file (.js) is being linted as shell: a config entry selects language 'sh/sh' (for example sh.configs.recommended) without a files list. Wrap the preset in an entry with files, e.g. { files: ['**/*.sh'], extends: [sh.configs.recommended] }
449
+ ```
450
+
451
+ The fix is to wrap the preset in an entry whose `files` lists your shell scripts:
452
+
453
+ ```js
454
+ export default defineConfig([{ files: ['**/*.sh'], extends: [sh.configs.recommended] }]);
455
+ ```
456
+
457
+ - **Setting `shellVersion` doesn't change anything.** `shellVersion` has no effect on parsing,
458
+ and only the version-gated `compat/*` rules read it (`sh/compat/no-append-all-redirect`, `sh/compat/no-associative-arrays`, `sh/compat/no-brace-sequence-padding-step`, `sh/compat/no-case-attributes`, `sh/compat/no-case-fallthrough`, `sh/compat/no-case-modification`, `sh/compat/no-coproc`, `sh/compat/no-declare-g`, `sh/compat/no-epoch-variables`, `sh/compat/no-fd-variable`, `sh/compat/no-globstar`, `sh/compat/no-lastpipe-semantics`, `sh/compat/no-mapfile`, `sh/compat/no-nameref`, `sh/compat/no-negative-array-index`, `sh/compat/no-nounset-empty-array`, `sh/compat/no-parameter-transformation`, `sh/compat/no-pipe-all`, `sh/compat/no-printf-time-format`, `sh/compat/no-test-v`, `sh/compat/no-unicode-escape` and `sh/compat/no-wait-n`). The `bash-3.2` and `posix-portable` presets enable them; to pick rules yourself,
459
+ enable the ones you want, for example `'sh/compat/no-mapfile': 'error'`. See
460
+ [version compatibility](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/compat.md).
461
+ - **`# eslint-disable-next-line ...` comments don't seem to do anything.** First check that the
462
+ rule is enabled, through a preset or your `rules` config. For example,
463
+ `# eslint-disable-next-line sh/no-eval -- reviewed dynamic evaluation` suppresses an enabled
464
+ `sh/no-eval` finding on the following line. If the directive still isn't recognized, check
150
465
  `languageOptions.directivePrefix` — it must match the prefix your comment uses (the default
151
466
  `"eslint"` only recognizes `# eslint-disable-next-line ...`, not a custom prefix).
152
467
  - **A disable directive's rule id doesn't match anything I've registered.** An unknown rule id in
@@ -161,16 +476,46 @@ from what the plugin actually does.
161
476
  language ids in its message (current ESLint 10.x behavior). This plugin registers exactly one
162
477
  language: `sh` (selected as `language: 'sh/sh'`).
163
478
 
479
+ ## Writing custom rules
480
+
481
+ `eslint-sh` re-exports everything a custom rule imports, so a project that installs only
482
+ `eslint-sh` needs no other package, even under pnpm's strict `node_modules` layout:
483
+
484
+ ```js
485
+ // The plugin's named exports: the rule helper defineShellRule, view-aware helpers such as
486
+ // resolveWord, and the view and rule types.
487
+ import { defineShellRule, resolveWord } from 'eslint-sh';
488
+ // sh-ast's root entry point: parse, walk, and print faithful records.
489
+ import { parseSync, printSync, walk } from 'eslint-sh/syntax';
490
+ // sh-ast/analyze: command enumeration and wrapper resolution over faithful records.
491
+ import { DEFAULT_TRANSPARENT_WRAPPERS, enumerateCommands } from 'eslint-sh/syntax/analyze';
492
+ ```
493
+
494
+ `eslint-sh` re-exports `@eslint-sh/eslint-plugin`'s named exports, and `eslint-sh/syntax` and
495
+ `eslint-sh/syntax/analyze` are `@eslint-sh/eslint-plugin/syntax` and
496
+ `@eslint-sh/eslint-plugin/syntax/analyze`. Those re-export `sh-ast` and `sh-ast/analyze` from the
497
+ plugin's own `sh-ast` dependency, the same module instance its language parses with. Don't import
498
+ `sh-ast` directly.
499
+
500
+ **Versioning.** `eslint-sh/syntax` and `eslint-sh/syntax/analyze` track the plugin's pinned
501
+ `sh-ast` minor (currently `sh-ast@^0.7.0`). Under 0.x, a different `sh-ast` minor is a breaking
502
+ change, so an `sh-ast` 0.x minor bump is a minor bump of this package, and the changelog entry
503
+ names the new `sh-ast` version.
504
+
505
+ See the `@eslint-sh/eslint-plugin` package's README for the full API and the
506
+ [custom-rule tutorial](https://github.com/mike-north/eslint-sh/blob/main/packages/eslint-plugin/docs/custom-rule-tutorial.md).
507
+
164
508
  ## Status
165
509
 
166
510
  Thin umbrella re-exporting `@eslint-sh/eslint-plugin`'s plugin object unchanged as this
167
511
  package's default export — `import sh from "eslint-sh"` and `import sh from "@eslint-sh/eslint-plugin"`
168
- refer to the same object. `@eslint-sh/eslint-plugin` currently ships `ShellLanguage`/
169
- `ShellSourceCode` only; rules and shared configs are not implemented yet. Disable directives /
170
- inline config are implemented and live. No lint rules ship yet, so the rule id in the
171
- troubleshooting section above is illustrative: after you register a custom rule as
172
- `sh/your-rule`, `# eslint-disable-next-line sh/your-rule -- justification` suppresses it on the
173
- following line.
512
+ refer to the same object. Its named exports and its `syntax` / `syntax/analyze` subpaths are the
513
+ plugin's, re-exported unchanged (see [Writing custom rules](#writing-custom-rules)). `@eslint-sh/eslint-plugin` currently ships `ShellLanguage`/
514
+ `ShellSourceCode`, the built-in rules documented under its `docs/rules/`, and the `recommended`
515
+ and `agent-authored` [presets](#presets). Disable directives / inline config are implemented and
516
+ live. Disable comments suppress enabled built-in rules and custom rules alike; for example,
517
+ `# eslint-disable-next-line sh/no-eval -- justification` suppresses that rule on the following
518
+ line.
174
519
 
175
520
  For the plugin's public API reference (types, exports, selector cookbook, and a custom-rule
176
521
  authoring tutorial), see the `@eslint-sh/eslint-plugin` package's README.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The faithful shell analysis surface (`eslint-sh/syntax/analyze`):
3
+ * `@eslint-sh/eslint-plugin/syntax/analyze`, re-exported unchanged. It is the complete public
4
+ * surface of `sh-ast/analyze`, taken from the same `sh-ast` instance the plugin's language parses
5
+ * with. Its `resolveWord` takes a faithful `Node`, unlike the view-aware `resolveWord` exported
6
+ * from `eslint-sh` itself.
7
+ *
8
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
9
+ * of this package.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+
15
+ export * from "@eslint-sh/eslint-plugin/syntax/analyze";
16
+
17
+ export { }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The faithful shell syntax surface (`eslint-sh/syntax`): `@eslint-sh/eslint-plugin/syntax`,
3
+ * re-exported unchanged. It is the complete public surface of `sh-ast`'s root entry point, taken
4
+ * from the same `sh-ast` instance the plugin's language parses with, so a project that installs
5
+ * only `eslint-sh` can edit or analyze faithful syntax without installing `sh-ast` itself.
6
+ *
7
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
8
+ * of this package.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
13
+
14
+ export * from "@eslint-sh/eslint-plugin/syntax";
15
+
16
+ export { }
@@ -2,4 +2,7 @@ import sh from '@eslint-sh/eslint-plugin';
2
2
 
3
3
  export default sh;
4
4
 
5
+
6
+ export * from "@eslint-sh/eslint-plugin";
7
+
5
8
  export { }
package/dist/index.d.ts CHANGED
@@ -9,4 +9,4 @@ import sh from '@eslint-sh/eslint-plugin';
9
9
  * @public
10
10
  */
11
11
  export default sh;
12
- //# sourceMappingURL=index.d.ts.map
12
+ export * from '@eslint-sh/eslint-plugin';
package/dist/index.js CHANGED
@@ -9,4 +9,7 @@ import sh from '@eslint-sh/eslint-plugin';
9
9
  * @public
10
10
  */
11
11
  export default sh;
12
- //# sourceMappingURL=index.js.map
12
+ // Every named export of the plugin root (`resolveWord`, `ShellSourceCode`, the view types, …)
13
+ // is re-exported unchanged, so a project that installs only `eslint-sh` can write custom rules
14
+ // without depending on `@eslint-sh/eslint-plugin` being hoisted where it can import it.
15
+ export * from '@eslint-sh/eslint-plugin';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The faithful shell analysis surface (`eslint-sh/syntax/analyze`):
3
+ * `@eslint-sh/eslint-plugin/syntax/analyze`, re-exported unchanged. It is the complete public
4
+ * surface of `sh-ast/analyze`, taken from the same `sh-ast` instance the plugin's language parses
5
+ * with. Its `resolveWord` takes a faithful `Node`, unlike the view-aware `resolveWord` exported
6
+ * from `eslint-sh` itself.
7
+ *
8
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
9
+ * of this package.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+ export * from '@eslint-sh/eslint-plugin/syntax/analyze';
@@ -0,0 +1,15 @@
1
+ /* eslint-disable @api-extractor-tools/package-documentation -- this file is the barrel of the
2
+ `./syntax/analyze` entry point (package.json `exports`); the rule only recognizes the root barrel. */
3
+ /**
4
+ * The faithful shell analysis surface (`eslint-sh/syntax/analyze`):
5
+ * `@eslint-sh/eslint-plugin/syntax/analyze`, re-exported unchanged. It is the complete public
6
+ * surface of `sh-ast/analyze`, taken from the same `sh-ast` instance the plugin's language parses
7
+ * with. Its `resolveWord` takes a faithful `Node`, unlike the view-aware `resolveWord` exported
8
+ * from `eslint-sh` itself.
9
+ *
10
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
11
+ * of this package.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ export * from '@eslint-sh/eslint-plugin/syntax/analyze';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The faithful shell syntax surface (`eslint-sh/syntax`): `@eslint-sh/eslint-plugin/syntax`,
3
+ * re-exported unchanged. It is the complete public surface of `sh-ast`'s root entry point, taken
4
+ * from the same `sh-ast` instance the plugin's language parses with, so a project that installs
5
+ * only `eslint-sh` can edit or analyze faithful syntax without installing `sh-ast` itself.
6
+ *
7
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
8
+ * of this package.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ export * from '@eslint-sh/eslint-plugin/syntax';
@@ -0,0 +1,14 @@
1
+ /* eslint-disable @api-extractor-tools/package-documentation -- this file is the barrel of the
2
+ `./syntax` entry point (package.json `exports`); the rule only recognizes the root barrel. */
3
+ /**
4
+ * The faithful shell syntax surface (`eslint-sh/syntax`): `@eslint-sh/eslint-plugin/syntax`,
5
+ * re-exported unchanged. It is the complete public surface of `sh-ast`'s root entry point, taken
6
+ * from the same `sh-ast` instance the plugin's language parses with, so a project that installs
7
+ * only `eslint-sh` can edit or analyze faithful syntax without installing `sh-ast` itself.
8
+ *
9
+ * It tracks the plugin's pinned `sh-ast` minor, so an `sh-ast` 0.x minor bump is a minor release
10
+ * of this package.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+ export * from '@eslint-sh/eslint-plugin/syntax';
package/package.json CHANGED
@@ -1,25 +1,37 @@
1
1
  {
2
2
  "name": "eslint-sh",
3
- "description": "Shell script parsing and syntax-error reporting for ESLint, with custom-rule authoring over a typed shell AST",
4
- "version": "0.5.0",
3
+ "description": "Shell script linting for ESLint, with built-in policy rules, shared presets, and custom rules over a typed shell AST",
4
+ "version": "0.7.0",
5
5
  "author": "Mike North <michael.l.north@gmail.com>",
6
6
  "dependencies": {
7
- "@eslint-sh/eslint-plugin": "0.5.0"
7
+ "@eslint-sh/eslint-plugin": "0.7.0"
8
8
  },
9
9
  "devDependencies": {
10
10
  "eslint": "^10.7.0"
11
11
  },
12
12
  "peerDependencies": {
13
- "eslint": "^10.0.0"
13
+ "eslint": "^10.2.0"
14
+ },
15
+ "engines": {
16
+ "node": "^22.13.0 || >=24"
14
17
  },
15
18
  "exports": {
16
19
  ".": {
17
20
  "types": "./dist/eslint-sh.d.ts",
18
21
  "default": "./dist/index.js"
22
+ },
23
+ "./syntax": {
24
+ "types": "./dist/eslint-sh-syntax.d.ts",
25
+ "default": "./dist/syntax/index.js"
26
+ },
27
+ "./syntax/analyze": {
28
+ "types": "./dist/eslint-sh-syntax-analyze.d.ts",
29
+ "default": "./dist/syntax/analyze.js"
19
30
  }
20
31
  },
21
32
  "files": [
22
- "dist"
33
+ "dist",
34
+ "!dist/**/*.map"
23
35
  ],
24
36
  "license": "MIT",
25
37
  "main": "./dist/index.js",
@@ -28,8 +40,7 @@
28
40
  "check:api-report": {
29
41
  "outputs": [
30
42
  "{projectRoot}/api-report",
31
- "{projectRoot}/temp",
32
- "{projectRoot}/dist/eslint-sh.d.ts"
43
+ "{projectRoot}/temp"
33
44
  ]
34
45
  },
35
46
  "uplevel-api-reports": {
@@ -43,7 +54,11 @@
43
54
  ],
44
55
  "outputs": [
45
56
  "{workspaceRoot}/api-report-combined/eslint-sh.api.md",
46
- "{workspaceRoot}/api-docs-temp/eslint-sh.api.json"
57
+ "{workspaceRoot}/api-docs-temp/eslint-sh.api.json",
58
+ "{workspaceRoot}/api-report-combined/eslint-sh-syntax.api.md",
59
+ "{workspaceRoot}/api-docs-temp/eslint-sh-syntax.api.json",
60
+ "{workspaceRoot}/api-report-combined/eslint-sh-syntax-analyze.api.md",
61
+ "{workspaceRoot}/api-docs-temp/eslint-sh-syntax-analyze.api.json"
47
62
  ]
48
63
  }
49
64
  }
@@ -59,15 +74,15 @@
59
74
  "directory": "packages/eslint-sh"
60
75
  },
61
76
  "scripts": {
62
- "build": "tsc -p tsconfig.json",
77
+ "build": "tsc -p tsconfig.json && node ../../scripts/rollup-declarations.mjs api-extractor.json syntax/api-extractor.json syntax/analyze/api-extractor.json",
63
78
  "check": "concurrently -n lint,types,api-report \"pnpm run check:lint-ts\" \"pnpm run check:typecheck\" \"pnpm run check:api-report\"",
64
- "check:api-report": "api-extractor run",
79
+ "check:api-report": "api-extractor run && api-extractor run --config syntax/api-extractor.json && api-extractor run --config syntax/analyze/api-extractor.json",
65
80
  "check:lint-ts": "eslint .",
66
81
  "check:typecheck": "tsc --noEmit",
67
82
  "clean": "rimraf --glob dist \"temp/*\" \"*.tsbuildinfo\"",
68
- "fix:api-report": "api-extractor run --local",
83
+ "fix:api-report": "api-extractor run --local && api-extractor run --local --config syntax/api-extractor.json && api-extractor run --local --config syntax/analyze/api-extractor.json",
69
84
  "fix:lint-ts": "eslint . --fix",
70
85
  "test": "vitest run",
71
- "uplevel-api-reports": "mkdir -p ../../api-report-combined ../../api-docs-temp && cp api-report/eslint-sh.api.md ../../api-report-combined/ && cp temp/eslint-sh.api.json ../../api-docs-temp/"
86
+ "uplevel-api-reports": "mkdir -p ../../api-report-combined ../../api-docs-temp && cp api-report/eslint-sh.api.md api-report/eslint-sh-syntax.api.md api-report/eslint-sh-syntax-analyze.api.md ../../api-report-combined/ && cp temp/eslint-sh.api.json temp/eslint-sh-syntax.api.json temp/eslint-sh-syntax-analyze.api.json ../../api-docs-temp/"
72
87
  }
73
88
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAE1C;;;;;;;;GAQG;AACH,eAAe,EAAE,CAAC"}
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAE1C;;;;;;;;GAQG;AACH,eAAe,EAAE,CAAC"}