shellcheck-wasm
v0.3.3
Published
ShellCheck compiled to WebAssembly with TypeScript API
Downloads
322
Maintainers
Readme
shellcheck-wasm
ShellCheck 0.11 compiled to WebAssembly, with a typed TypeScript API for Node.js and browsers.
The module is built with GHC's WebAssembly backend (wasm32-wasi) as a WASI reactor exposing the async JSFFI export lintWithOptions (and getVersion for cache invalidation). Both runtimes load the same artifact; the only difference is how the WASI imports are provided.
Requirements
- Node.js >= 20
- The prebuilt
dist/shellcheck.wasm+dist/shellcheck.js(see Building the WASM module), or build them yourself with the GHC WASM toolchain
Installation
npm install shellcheck-wasmPublishing
One-step release:
npm run release-patch # 0.1.0 -> 0.1.1
npm run release-minor # 0.1.0 -> 0.2.0
npm run release-major # 0.1.0 -> 1.0.0This runs tests, bumps the version, rebuilds dist/, commits, pushes the tag, and publishes to npm.
For manual control:
npm run build
npm publishUsage
Node.js
import { createShellCheck, lintWithOptions } from 'shellcheck-wasm';
// One-shot
const results = await lintWithOptions('echo $UNQUOTED_VAR', { severity: 'warning' });
// Reusable instance (module is instantiated once and reused)
const shellcheck = await createShellCheck();
const results2 = await shellcheck.lintWithOptions('echo $VAR', { severity: 'warning' });By default the loader resolves dist/shellcheck.wasm relative to the package. Pass an explicit path when needed:
const shellcheck = await createShellCheck({ wasmUrl: '/path/to/shellcheck.wasm' });Browser
Serve dist/shellcheck.wasm and dist/shellcheck.js from the same origin with correct MIME types (application/wasm for the .wasm file, text/javascript for .js), then:
import { createShellCheck } from 'shellcheck-wasm';
const shellcheck = await createShellCheck({ wasmUrl: '/shellcheck.wasm' });
const results = await shellcheck.lintWithOptions(editorValue, { severity: 'warning' });
console.log(results);wasmUrl may be any absolute or relative URL. The companion shellcheck.js (post-link JSFFI glue) is resolved by replacing the .wasm suffix with .js.
Important: The
.wasmfile must be served withContent-Type: application/wasm, otherwise the browser will refuse to instantiate it. The test suite validates this requirement (seebrowser-serve.test.ts).
API reference
createShellCheck(options?)
Creates (or returns the cached) ShellCheck instance, instantiating the WASM module on first call. Uses version-based cache invalidation; pass forceNew: true to bypass.
await createShellCheck(options?: {
wasmUrl?: string; // path or URL of shellcheck.wasm
runtime?: 'node' | 'browser' | 'auto'; // default 'auto'
forceNew?: boolean; // bypass version cache (default false)
});lintWithOptions(script, options)
await lintWithOptions('echo $VAR', { severity: 'error' }); // filtered
await lintWithOptions('echo $VAR', { exclude: [2086] }); // suppress SC2086lint(script, options?)
await lint('echo $VAR'); // LintResult[] (deprecated)Deprecated: Use
lintWithOptionsinstead. This convenience wrapper will be removed in a future version.
resetShellCheck()
Terminates the cached instance. Mainly useful in tests.
LintOptions
interface LintOptions {
shell?: 'bash' | 'sh' | 'dash' | 'ksh' | 'busybox';
severity?: 'error' | 'warning' | 'info' | 'style';
exclude?: number[]; // warning codes to suppress, e.g. [2086]
include?: number[]; // if set, only these codes are reported
files?: Record<string, string>; // virtual files for `source` directives
}Severity filtering matches ShellCheck semantics: setting severity: 'error' hides info-level findings such as SC2086.
LintResult
interface LintResult {
file: string;
line: number;
column: number;
endLine?: number;
endColumn?: number;
code: number; // e.g. 2086
severity: 'error' | 'warning' | 'info' | 'style';
message: string;
fix?: {
replacements: Array<{
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
text: string;
}>;
};
}Example output for echo $VAR:
[
{
"code": 2148, "severity": "error",
"message": "Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive."
},
{
"code": 2086, "severity": "info",
"message": "Double quote to prevent globbing and word splitting.",
"fix": { "replacements": [
{ "startLine": 1, "startColumn": 6, "endLine": 1, "endColumn": 6, "text": "\"" },
{ "startLine": 1, "startColumn": 10, "endLine": 1, "endColumn": 10, "text": "\"" }
] }
}
]How it works
shellcheck/ (v0.11.0 submodule, unmodified)
└─ wasm/Main.hs + wasm/ShellCheck/Wasm/*
│ wasm32-wasi-ghc 9.10 (ghc-wasm-meta, FLAVOUR=9.10)
│ -no-hs-main -optl-mexec-model=reactor
▼
dist/shellcheck.wasm (~17 MB, WASI reactor)
dist/shellcheck.js (post-link.mjs JSFFI glue)
│ src/runtime/{node,browser}.ts
▼
lint(script, options?) → Promise<LintResult[]>Key design points:
- Single entry point. The Haskell layer calls
ShellCheck.Checker.checkScriptdirectly and mapsCheckResultto JSON. No CLI parsing, no formatters, no filesystem access inside the module. - In-memory filesystem.
sourcedirectives resolve against thefilesoption map; there is no host filesystem access from WASM. Eachlintcall builds a fresh interface, so concurrent or repeated calls share no state. - Reactor lifecycle. Callers must
_initializeonce, thenhs_init(0, 0), thenawaitthe async exports. Both runtimes implement exactly this sequence; see the GHC user's guide section on JavaScript FFI in the wasm backend. - No regex shim.
regex-tdfais pure Haskell and compiles towasm32-wasiunmodified. - One WASI implementation. Both runtimes use
@bjorn3/browser_wasi_shim. Node's builtinnode:wasiwas tried and aborts reactor initialization with an opaque exit-code throw; the shim initializes cleanly in both environments.
Building the WASM module
Requires the prebuilt GHC WASM toolchain (a stock ghcup GHC is single-target and cannot emit wasm32-wasi):
# One-time toolchain install (Linux x86_64, no compilation, ~1 GB)
curl https://gitlab.haskell.org/haskell-wasm/ghc-wasm-meta/-/raw/master/bootstrap.sh | sh
source ~/.ghc-wasm/env
npm run build:wasmscripts/build-wasm.sh builds exe:shellcheck-wasm with the wasm32-wasi-cabal wrapper, then runs GHC's post-link.mjs to generate the JSFFI glue. Output lands in dist/. Use cabal <= 3.14 with the wrapper (3.16 has a known regression with this toolchain).
Development
npm install # install JS dependencies
npm run build # builds WASM + TypeScript (via unbuild)
npm test # Node suites: unit + integration + browser-path-over-HTTP
npm run test:browser # real headless Chromium via @vitest/browser + Playwright
npm run lint # Biome check
npm run lint:fix # Biome check --writeGit hooks (Husky + lint-staged) run Biome on staged JS/TS files at commit and the test suite on push.
Test matrix
| Suite | Environment | What it covers |
|---|---|---|
| api.test.ts | Node | Type shapes, response envelopes |
| node.test.ts | Node + built .wasm | SC2086/SC2164/SC3043 detection, severity/include/exclude filters, shell override, virtual source files, fix data |
| browser-serve.test.ts | Node + local HTTP | BrowserShellCheck fetching .wasm over HTTP |
| browser.test.ts | Real headless Chromium | Full browser path: fetch, instantiate, lint, options |
Integration suites skip automatically when dist/shellcheck.wasm is absent.
Project structure
shellcheck-wasm/
├── src/
│ ├── index.ts # public entry point
│ ├── api.ts # createShellCheck / lintWithOptions / resetShellCheck
│ ├── types.ts # LintOptions, LintResult
│ ├── vitest-provided.d.ts # ProvidedContext augmentation for browser tests
│ ├── runtime/
│ │ ├── node.ts # Node loader (reads dist/ from disk)
│ │ ├── browser.ts # browser loader (fetch + WASI shim)
│ │ └── utils.ts # shared parseLintResponse
│ └── __tests__/ # Vitest suites
├── wasm/
│ ├── Main.hs # stub (exports live in ShellCheck.Wasm.API)
│ └── ShellCheck/Wasm/
│ ├── API.hs # foreign export javascript lintWithOptions/getVersion
│ ├── SystemInterface.hs# in-memory SystemInterface
│ └── Types.hs # JSON types mirroring src/types.ts
├── shellcheck/ # upstream ShellCheck v0.11.0 (submodule)
├── test/
│ ├── fixtures/ # .sh fixtures
│ └── serve-dist.ts # globalSetup: serves dist/ to browser tests
├── scripts/ # build-wasm.sh
├── build.config.ts # unbuild configuration
├── shellcheck-wasm.cabal
├── cabal.project
├── vitest.config.ts # Node suites
└── vitest.browser.config.ts # Chromium suitedist/ (compiled JS, .wasm, glue, generated .d.ts) and dist-newstyle/ are gitignored build outputs.
Known limitations
- The 17 MB module takes a few seconds to fetch and instantiate on first use; reuse the instance returned by
createShellCheck. - The module has no host filesystem access; only virtual
filescan be resolved forsourcedirectives. - Exceptions inside the checker surface as rejected promises; validate options client-side.
- Browser testing covers headless Chromium. Other engines should work (the module only needs post-MVP features all modern browsers ship), but they are not in the matrix.
License
GPL-3.0-or-later, same as ShellCheck. ShellCheck is copyright Vidar Holen and contributors; this wrapper is a derivative work.
