diff --git a/docs/wasm-plugins.md b/docs/wasm-plugins.md index 35cc3f0..23afa4f 100644 --- a/docs/wasm-plugins.md +++ b/docs/wasm-plugins.md @@ -88,8 +88,14 @@ list restricts native parsing to those identifiers. ## The WASM ABI -There are **no imports** — codesight instantiates with an empty import object, so -the module must not require WASI, JS-binding glue, or any host functions. +The host instantiates every module with a **minimal WASI import object** (Node's +built-in `node:wasi` — clock/random/exit/stderr only; **no filesystem, no network, +no env/args**). Pure-compute modules with no imports (Rust/AssemblyScript on +`wasm32-unknown-unknown`) ignore it; modules whose runtime needs WASI (e.g. Go +`//go:wasmexport` reactors) get exactly those minimal capabilities. A module that +exports `_initialize` is initialized before any other export is called. The module +must not require JS-binding glue or host functions beyond WASI. `node:wasi` is built +in, so codesight keeps its zero-dependency guarantee. A conforming module exports a small fixed core plus one optional `parse*` function per capability it provides: @@ -277,10 +283,13 @@ from built-in `ast`/`regex` results in the scan summary. ## Plugin skeleton -Implement the module in any toolchain that compiles to a `wasm32` module with **no -imports** (no WASI, no JS-binding glue). The allocator and the per-kind marshalling -are boilerplate; the only part that changes is your extraction logic. Export only -the `parse*` functions for the kinds you support. +Implement the module in any toolchain that targets `wasm32` and imports **at most +`wasi_snapshot_preview1`** (no JS-binding glue, no other host functions). Pure-compute +languages (Rust/AssemblyScript via `wasm32-unknown-unknown`) need no imports at all; +full-runtime languages (Go via `GOOS=wasip1 -buildmode=c-shared`) import WASI, which +the host supplies minimally. The allocator and the per-kind marshalling are +boilerplate; the only part that changes is your extraction logic. Export only the +`parse*` functions for the kinds you support. Required exports and their behavior, in pseudocode: diff --git a/src/wasm/plugin-host.ts b/src/wasm/plugin-host.ts index ce6e194..6d5941d 100644 --- a/src/wasm/plugin-host.ts +++ b/src/wasm/plugin-host.ts @@ -28,8 +28,15 @@ */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { createRequire } from "node:module"; import type { NativeLang } from "../types.js"; +// `node:wasi` is loaded lazily (below) rather than via a static import: importing +// it emits a one-time ExperimentalWarning at module-load, which would fire on +// every run. Deferring the load to first plugin use lets us install a targeted +// warning filter first, and avoids loading WASI at all when native AST is off. +const nodeRequire = createRequire(import.meta.url); + /** Capability namespace, part of the discovery filename: codesight--.wasm */ const CAPABILITY = "ast"; /** ABI/contract version the host understands. A plugin reporting a different value is skipped. */ @@ -65,6 +72,24 @@ export function resetNativePluginProvider(): void { // Instance cache keyed by resolved .wasm path (or sentinel for "tried, not loadable"). const cache = new Map(); +// node:wasi prints a one-time "ExperimentalWarning: WASI is an experimental +// feature..." to stderr when constructed. Suppress *only* that specific warning +// (installed lazily, the first time we actually use WASI) so it doesn't pollute +// CLI output — every other process warning passes through unchanged. +let wasiWarningSuppressed = false; +function suppressWasiExperimentalWarning(): void { + if (wasiWarningSuppressed) return; + wasiWarningSuppressed = true; + const original = process.emitWarning.bind(process); + (process as { emitWarning: unknown }).emitWarning = (warning: unknown, ...args: unknown[]) => { + const opt = args[0]; + const type = opt && typeof opt === "object" ? (opt as { type?: string }).type : opt; + const message = typeof warning === "string" ? warning : (warning as Error | undefined)?.message ?? ""; + if (type === "ExperimentalWarning" && String(message).includes("WASI")) return; + return (original as (...a: unknown[]) => void)(warning, ...args); + }; +} + /** * Resolve + load the plugin for `lang`, searching `pluginDirs` in order. Returns * null when no plugin is available; a malformed/incompatible plugin is treated @@ -90,7 +115,26 @@ export function loadPlugin(lang: NativeLang, pluginDirs: string[]): LoadedPlugin try { const bytes = readFileSync(wasmPath); const module = new WebAssembly.Module(bytes); - const instance = new WebAssembly.Instance(module, {}); // no imports + + // Always instantiate with a WASI import object. Pure-compute (no-imports) + // plugins ignore the extras; plugins whose runtime needs WASI (e.g. Go + // reactors built with //go:wasmexport) get a minimal, capability-restricted + // environment — NO filesystem, NO network, no args/env. node:wasi is built + // in, so this keeps the zero-dependency constraint. "Plugins are pure + // compute" — if a kind ever needs project context, it flows through the ABI, + // not the filesystem. + suppressWasiExperimentalWarning(); + const { WASI } = nodeRequire("node:wasi") as typeof import("node:wasi"); + const wasi = new WASI({ version: "preview1" }); + const instance = new WebAssembly.Instance(module, wasi.getImportObject() as WebAssembly.Imports); + + // Reactor modules (Go) export `_initialize` and must be initialized before + // any other export is called. No-imports modules (Rust/AssemblyScript) have + // neither `_initialize` nor `_start` and are used as-is. + if (typeof (instance.exports as Record)._initialize === "function") { + wasi.initialize(instance); + } + loaded = bindExports(instance.exports); } catch { loaded = null; // unloadable plugin → treated as absent