Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,34 @@ jobs:
- run: npm ci
- run: npm run build

# A prerelease version (x.y.z-tag.n) goes to the `next` dist-tag so `latest` keeps
# pointing at the last stable release.
- name: Classify the version
id: channel
run: |
if [[ "${GITHUB_REF_NAME#v}" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi

# Trusted Publishing: npm exchanges the job's OIDC token for publish rights, so no
# NODE_AUTH_TOKEN is set anywhere in this workflow.
- run: npm publish --provenance --access public
- if: steps.channel.outputs.prerelease == 'false'
run: npm publish --provenance --access public

- if: steps.channel.outputs.prerelease == 'true'
run: npm publish --provenance --access public --tag next

# build-info.json is not in the npm tarball (its contents are compiled into BUILD_INFO)
# but stays a release asset next to the artifact it describes.
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
PRERELEASE: ${{ steps.channel.outputs.prerelease }}
run: |
flags=(--verify-tag --generate-notes)
if [ "$PRERELEASE" = "true" ]; then flags+=(--prerelease); fi
gh release create "$GITHUB_REF_NAME" \
dist/shellcheck.wasm dist/shellcheck.wasm.sha256 dist/build-info.json \
--verify-tag --generate-notes
"${flags[@]}"
4 changes: 4 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@
"builtin": true,
"es2022": true,
},
"rules": {
// Aimed at window.postMessage; Worker and MessagePort postMessage take no targetOrigin.
"unicorn/require-post-message-target-origin": "off",
},
"ignorePatterns": ["dist/", "node_modules/", ".cache/", "src/generated/"],
}
47 changes: 31 additions & 16 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
# shellcheck-wasm

npm package `@vscode-shellcheck/shellcheck-wasm`: ShellCheck compiled to a WASI command module
plus a synchronous TypeScript runner. Built in GitHub Actions from pinned inputs; the artifact is
plus a TypeScript runner that lints in a Host-supplied Worker and reads files only through the
Host's async file system. Built in GitHub Actions from pinned inputs; the artifact is
never committed. Vocabulary: `CONTEXT.md` (use its terms). Decisions: `docs/adr/`. Plans: `docs/plans/`.

## Layout

- `buildtools/wasm/` — everything the Docker build reads: `Dockerfile`, pins (`version.txt`,
`shellcheck-src.sha256`, `ghc-wasm-meta.txt`), `cabal.project`, `build.sh` (every step after
the sources are unpacked), the tail-call gate script.
- `src/generated/` — written by `npm run build` from `buildtools/wasm/version.txt`; gitignored.
- `dist/` — compiled JS plus `shellcheck.wasm`, `shellcheck.wasm.sha256`, `build-info.json`; gitignored.
- `.cache/native/` — native ShellCheck for the parity suite; gitignored.
- `src/generated/` — written by `npm run build` from `buildtools/wasm/version.txt` and
`dist/build-info.json`; gitignored.
- `dist/` — compiled JS plus `shellcheck.wasm`, `shellcheck.wasm.sha256`, `build-info.json`;
gitignored. `build-info.json` is not packed (it is compiled into `BUILD_INFO`).
- `test/support/` — test-only Worker entries and `ShellCheckFileSystem` adapters.
- `.cache/native/` — native ShellCheck for the parity suite; `.cache/bench/` — `npm run bench`
scratch; gitignored.

## Working locally

Scripts are defined in `package.json`; there is no Docker on the dev machine, so `build:wasm`
is CI-only.

1. `npm ci`
2. Get an artifact into `dist/`: download `shellcheck.wasm` and `build-info.json` from a GitHub
Release, or use the dev stand-in, the upstream 0.11.0 command module (no tail calls, fine for
runner and parity tests, never for a release):
`mkdir -p dist && curl -fL -o dist/shellcheck.wasm https://raw.githubusercontent.com/wasilibs/go-shellcheck/24025c1590296bcce8e494624e8c3561740a32a4/internal/wasm/shellcheck.wasm`
2. Get an artifact into `dist/`: download `shellcheck.wasm`, `shellcheck.wasm.sha256` and
`build-info.json` from a GitHub Release for the ShellCheck version in `version.txt`.
`npm run build` refuses a `build-info.json` whose sha256 does not match the artifact.
3. `npm run fetch:native`
4. `npm run build && npm run lint && npm run fmt:check && npm test`

Tests print `[skip] …` and skip when `dist/shellcheck.wasm`, the native binary,
`dist/build-info.json` or `LICENSE` is missing; with `CI=1` the same conditions fail
(`test/helpers.ts`). With the stand-in, only the `build-info.json` skip remains.
Tests that start a Worker load `dist/worker.js`, so rebuild after changing `src/`; they fail
when `dist/` is older than `src/`. Tests print `[skip] …` and skip when `dist/shellcheck.wasm`,
`dist/worker.js`, the native binary or `LICENSE` is missing; with `CI=1` the same conditions
fail (`test/helpers.ts`).

`npm run bench` compares per-lint latency with the published 0.1.1 runner and native
ShellCheck (`scripts/bench.mjs`, several minutes); it exits 1 when the new runner is more than
10% slower than 0.1.1.

## Invariants

Expand All @@ -39,10 +48,15 @@ Tests print `[skip] …` and skip when `dist/shellcheck.wasm`, the native binary
- The tail-call gate runs on the pre-`wasm-opt` linker output because `wasm-opt --enable-tail-call`
rewrites `target_features`. `-mtail-call` stays in `WASM_CFLAGS`: ghc-wasm-meta dropped it from
its defaults in 2025-09, and GHC only emits `return_call` when the flag is baked in.
- Guest `PWD` must be inside a preopen or unset. The GHC RTS chdir()s to it at init; on failure
the run exits 1 with empty stdout and `hs_init_ghc: chdir(...) failed` on stderr.
- The runner stays free of threads, cancellation and filesystem policy; those are host
concerns (ADR 0005).
- Guest `PWD` must name a directory in the lint's `fs`, or be unset. The GHC RTS chdir()s to it
at init; on failure the run exits 1 with empty stdout and `hs_init_ghc: chdir(...) failed` on
stderr.
- No shipped module imports `node:*` or a bare Node built-in, and files reach the guest only
through the Host's `ShellCheckFileSystem` (ADR 0006; guarded by `test/package.test.ts`). The
caller-side entry `.` does not load the WASI shim; only `./worker` does.
- The package provides the Worker protocol, bridge, FIFO queue and `AbortSignal` cancellation;
creating Workers, what to mount, scheduling policy and watchdog durations stay in the Host
(ADR 0005).
- `wasm32-wasi-cabal` is the wrapper ghc-wasm-meta ships (cabal 3.14.x). The wrapper breaks with
cabal 3.16; keep the shipped one.

Expand All @@ -65,7 +79,8 @@ The Dockerfile is validated by review and by `ci.yml`, which builds the artifact
`npm publish --provenance` through npm Trusted Publishing (no token secret; npm-side setup is
package `@vscode-shellcheck/shellcheck-wasm`, repo `vscode-shellcheck/shellcheck-wasm`,
workflow `release.yml`), then creates the GitHub Release with `shellcheck.wasm`, `.sha256`
and `build-info.json`.
and `build-info.json`. A prerelease version (`x.y.z-next.n`) is published with
`--tag next` and its GitHub Release is marked as a prerelease; `latest` is untouched.

`bump-shellcheck.yml` runs daily and opens `chore(wasm): bump ShellCheck to <tag>` PRs. PRs opened
with `github.token` get no CI run; set the `BUMP_PR_TOKEN` secret (PAT or App token) so they do.
Expand Down
18 changes: 13 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,32 @@ _Avoid_: tool version, upstream version
The npm semver of this package. Bumps independently of the ShellCheck version so wrapper fixes can ship without a new ShellCheck release.

**Build info**:
The metadata describing how a given Artifact was built: its ShellCheck version, toolchain versions, compiler flags, target features, digest and size. Produced together with the Artifact and exposed to Hosts as a typed value.
The metadata describing how a given Artifact was built: its ShellCheck version, toolchain versions, compiler flags, target features, digest and size. Produced together with the Artifact as `build-info.json` and compiled into the package as `BUILD_INFO`.
_Avoid_: build metadata, manifest

**Command module**:
The WASI execution model the Artifact uses: exports `_start`, reads argv/stdin, writes stdout/stderr, exits. One run per `WebAssembly.Instance`. Chosen over a reactor module so output stays byte-identical to native ShellCheck.
_Avoid_: reactor, JSFFI module

**Runner**:
The thin JavaScript layer in this package that instantiates the Artifact with a WASI host and runs it once with given args/stdin, returning stdout/stderr/exit code. Contains no threading, cancellation or filesystem policy; those belong to the Host.
The JavaScript layer in this package (`createShellCheck` plus the Worker side, `startWorker`) that runs the Artifact once per lint on a fresh `WebAssembly.Instance` inside a Worker, one lint at a time in call order, and returns stdout/stderr/exit code. It aborts a lint when told to but holds no policy about when; that belongs to the Host.
_Avoid_: wrapper, FFI, binding

**Host**:
The consumer that embeds the Runner (e.g. the vscode-shellcheck extension). Owns Worker isolation, watchdogs, cancellation and which directories the Artifact may see.
The consumer that embeds the Runner (e.g. the vscode-shellcheck extension). Creates the Worker, decides what each lint may see through its File system, and owns scheduling policy and watchdog durations.
_Avoid_: client, consumer, caller

**File system**:
The async `ShellCheckFileSystem` (`stat`, `readFile`, `readDirectory`) a Host passes with a lint, run on the Host's thread. Paths are guest paths such as `/dir/.shellcheckrc`; symlink containment is its job.
_Avoid_: backend, VFS

**Bridge**:
The SharedArrayBuffer channel that lets the Artifact's synchronous WASI calls in the Worker wait on the File system's async answers on the Host's thread.
_Avoid_: RPC, proxy

**Preopen**:
A directory the Host exposes to the Artifact through WASI. ShellCheck needs one to discover `.shellcheckrc` and follow `source` directives; without one it can only lint stdin.
_Avoid_: mount, sandbox
The read-only directory tree the Artifact sees at guest `/` during one lint, served lazily from the File system through the Bridge. ShellCheck needs one to discover `.shellcheckrc` and follow `source` directives; a lint without a File system has none and can only lint stdin.
_Avoid_: sandbox

**Parity**:
The property that, for the same args, stdin, environment and filesystem, the Artifact's stdout, stderr and exit code are byte-identical to the same ShellCheck version's native binary. The acceptance bar for every Artifact.
Expand Down
131 changes: 97 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
# @vscode-shellcheck/shellcheck-wasm

[ShellCheck](https://www.shellcheck.net/) compiled to a WASI command module, plus a minimal
runner. This package exists primarily as the WebAssembly runtime of the
[vscode-shellcheck](https://github.com/vscode-shellcheck/vscode-shellcheck) extension; other Node.js
hosts can use it the same way. Given the same args, stdin, environment and visible files, output
is byte-identical to the native `shellcheck` binary of the same version. The module uses wasm tail
calls and requires Node.js 22 or later.
[ShellCheck](https://www.shellcheck.net/) compiled to a WASI command module, plus a runner that
lints in a Worker you provide and reads files only through a file system you provide. This
package exists primarily as the WebAssembly runtime of the
[vscode-shellcheck](https://github.com/vscode-shellcheck/vscode-shellcheck) extension; other
JavaScript hosts can use it the same way. Given the same args, stdin, environment and visible
files, output is byte-identical to the native `shellcheck` binary of the same version. The module
uses wasm tail calls and requires Node.js 22 or later, or a browser with tail calls and
`SharedArrayBuffer`.

No module in the package imports `node:*`: the host supplies the Worker and, per lint, the files
ShellCheck may read.

## Install

Expand All @@ -15,54 +20,112 @@ npm install @vscode-shellcheck/shellcheck-wasm

## Usage

The Worker entry is a file the host ships. In Node:

```ts
import { loadModule, createReadOnlyPreopen, run } from "@vscode-shellcheck/shellcheck-wasm/node";
// shellcheck-worker.ts
import { parentPort } from "node:worker_threads";
import { startWorker } from "@vscode-shellcheck/shellcheck-wasm/worker";

startWorker({
postMessage: (message) => parentPort!.postMessage(message),
onMessage: (listener) => parentPort!.on("message", listener),
});
```

const module = await loadModule(); // compile once, reuse for every run
On the thread that lints:

const preopen = createReadOnlyPreopen("/path/to/workspace"); // visible to ShellCheck as "/"
try {
const result = run(module, {
```ts
import { readFile } from "node:fs/promises";
import { Worker } from "node:worker_threads";
import { createShellCheck, wasmUrl } from "@vscode-shellcheck/shellcheck-wasm";

const shellcheck = createShellCheck({
module: WebAssembly.compile(await readFile(wasmUrl)), // compiled once, sent to each Worker
createWorker() {
const worker = new Worker(new URL("./shellcheck-worker.js", import.meta.url));
return {
postMessage: (message) => worker.postMessage(message),
onMessage: (listener) => worker.on("message", listener),
onError: (listener) => worker.on("error", listener),
onExit: (listener) => worker.on("exit", listener),
terminate: () => worker.terminate(),
};
},
});

const result = await shellcheck.lint(
{
args: ["-f", "json1", "-s", "bash", "-"],
stdin: script,
env: { PWD: "/sub/dir" }, // guest path of the script's directory, inside the preopen
preopens: [preopen],
});
const { comments } = JSON.parse(new TextDecoder().decode(result.stdout));
} finally {
preopen.dispose();
}
env: { PWD: "/sub/dir" }, // guest path of the script's directory
fs: workspaceFileSystem, // seen by ShellCheck as "/"; see below
},
{ signal: AbortSignal.timeout(10_000) },
);
const { comments } = JSON.parse(result.stdout);

await shellcheck.dispose(); // when done with the instance
```

`exitCode` is ShellCheck's own: `0` no findings, `1` findings, `2` or higher for invalid input.
`PWD` must be a path inside one of the preopens, or unset.
A lint rejects only when it is aborted, the instance is disposed, or the Worker or the guest
fails.

`createShellCheck` starts its Worker on the first lint and runs lints one at a time, in call
order. Aborting a queued lint removes it; aborting the running lint terminates the Worker, and
the next lint starts a new one, as it does after the Worker exits or throws. Timeouts,
priorities and dropping stale lints are up to the host, through the `signal` it passes.

In a web Worker, `onMessage` unwraps the event
(`self.addEventListener("message", (event) => listener(event.data))`), and on the creating side
`onExit` can be omitted. Browsers only provide `SharedArrayBuffer` to
[cross-origin isolated](https://developer.mozilla.org/docs/Web/API/Window/crossOriginIsolated)
pages; `WebAssembly.compileStreaming(fetch(wasmUrl))` loads the module there.

## Files

```ts
type FileType = "file" | "directory" | "other";

interface ShellCheckFileSystem {
stat(path: string): Promise<{ type: FileType; size: number; mtime: number }>;
readFile(path: string): Promise<Uint8Array>;
readDirectory(path: string): Promise<ReadonlyArray<readonly [name: string, type: FileType]>>;
}
```

The runner is synchronous: it blocks the calling thread until ShellCheck exits, with no timeout,
cancellation or output limit. Hosts that need any of those run it in a Worker and own the limits.
The `fs` of a lint is mounted read-only at guest `/`. ShellCheck needs it to find
`.shellcheckrc` above `PWD` and to follow `source` directives; without `fs` it sees no files at
all and can only lint stdin, and `PWD` must then be left unset. The methods run on the thread
that called `lint()` while the Worker waits, and every `path` is a normalized guest path such as
`/` or `/sub/dir/.shellcheckrc`. Results are cached for the rest of the lint, never across lints.

`createReadOnlyPreopen` refuses guest paths that resolve outside its directory, including through
symlinks. The check runs against the path as it is when ShellCheck asks, so a process that rewrites
symlinks in that directory during a run can race it. Expose only directories that no untrusted
process writes to while ShellCheck runs.
Signal failures by throwing an error whose `code` is `FileNotFound`, `FileNotADirectory`,
`FileIsADirectory`, `NoPermissions` or `Unavailable` (the `vscode.FileSystemError` codes);
anything else reaches ShellCheck as an I/O error. The package never writes, and a guest path that
climbs out of `/` with `..` is refused before `fs` sees it. Symlinks are resolved however `fs`
resolves them: a host that must keep ShellCheck inside a directory has to keep its `fs` from
following links out of it.

## Entry points

| Subpath | Provides |
| ------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `.` | `run`, `wasmUrl`, `SHELLCHECK_VERSION`, `RunOptions`, `RunResult`; no Node imports |
| `./node` | The above plus `loadModule`, `createReadOnlyPreopen`, `readBuildInfo`, `wasmPath`, `ReadOnlyPreopen`, `BuildInfo` |
| `./build-info` | `readBuildInfo`, `BuildInfo`: toolchain and feature metadata for the bundled module, without the runner |
| `./shellcheck.wasm` | The compiled ShellCheck module |
| `./package.json` | The package manifest |
| Subpath | Provides |
| ------------------- | -------------------------------------------------------------------------------------------- |
| `.` | `createShellCheck`, `wasmUrl`, `SHELLCHECK_VERSION`, `BUILD_INFO` and types; no WASI shim |
| `./worker` | `startWorker`, `ParentPort`: the Worker side |
| `./shellcheck.wasm` | The compiled ShellCheck module |
| `./package.json` | The package manifest |

Bundlers that relocate modules can copy the artifact from
`require.resolve("@vscode-shellcheck/shellcheck-wasm/shellcheck.wasm")` and pass their own
`WebAssembly.Module` to `run`.
`WebAssembly.Module` as `module`.

## Versioning

The package follows its own semver, independent of the bundled ShellCheck release exposed as
`SHELLCHECK_VERSION`; `readBuildInfo()` describes the build that produced the artifact.
`SHELLCHECK_VERSION`; `BUILD_INFO` describes the build that produced the artifact. Prereleases
are published under the `next` dist-tag.

## License

Expand Down
Loading
Loading