Skip to content

Commit f34bcc4

Browse files
authored
fix(build:wasm): add preflight check with clear remediation (#990)
* fix(build:wasm): add preflight check with clear remediation When tree-sitter-cli's binary download fails (e.g. behind a proxy or with --ignore-scripts), `npm run build:wasm` would attempt all 35 grammars and dump a raw Node.js ENOENT stack for each, with no hint about how to fix it. Add a one-shot preflight that runs `npx tree-sitter --version` before the loop. On a missing-binary ENOENT pattern, print a single clear banner naming the root cause and listing concrete fixes (npm rebuild tree-sitter-cli, install -g, or download from releases), then exit cleanly so the rest of `prepare` can proceed. Per-grammar builds now capture stdio instead of inheriting it, so failures show one trimmed stderr line instead of a 20-line crash dump. A second banner surfaces if individual builds fail with a missing docker/emcc toolchain, pointing users at Docker Desktop or Emscripten. Impact: 3 functions changed, 2 affected * fix(build:wasm): quote shell args and include message in toolchain detect (#990) - quoteShellArg: with shell: true, execFileSync joins cmd+args into one shell string, so whitespace in paths (e.g. Windows 'C:\Users\First Last') gets re-split. Quote args containing whitespace with platform-appropriate quoting (cmd.exe double quotes vs POSIX single quotes). - Toolchain detection: include build.message in the combined string so Node-surfaced ENOENTs (e.g. 'spawn emcc ENOENT') trigger the missing toolchain banner. Also match on ENOENT as a trigger word. Impact: 2 functions changed, 2 affected
1 parent 34dd9b0 commit f34bcc4

1 file changed

Lines changed: 127 additions & 8 deletions

File tree

scripts/build-wasm.ts

Lines changed: 127 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,100 @@ const grammarsDir = resolve(root, 'grammars');
2222

2323
if (!existsSync(grammarsDir)) mkdirSync(grammarsDir);
2424

25+
type PreflightFailure = {
26+
reason: string;
27+
remediation: string[];
28+
};
29+
30+
function printBanner(title: string, lines: string[]): void {
31+
const bar = '─'.repeat(Math.max(title.length + 4, 60));
32+
console.warn(bar);
33+
console.warn(` ${title}`);
34+
console.warn(bar);
35+
for (const l of lines) console.warn(l);
36+
console.warn(bar);
37+
}
38+
39+
// With shell: true, execFileSync concatenates cmd + args into a single string
40+
// and hands it to the shell, so any whitespace in args (e.g. Windows paths like
41+
// `C:\Users\First Last\...`) gets re-split as separate tokens. Quote args that
42+
// contain whitespace so the shell treats them as one argument.
43+
function quoteShellArg(arg: string): string {
44+
if (arg.length === 0) return '""';
45+
if (!/\s/.test(arg)) return arg;
46+
if (process.platform === 'win32') {
47+
// cmd.exe: wrap in double quotes; escape any embedded double quotes.
48+
return `"${arg.replace(/"/g, '""')}"`;
49+
}
50+
// POSIX sh: wrap in single quotes; close/escape/reopen for embedded single quotes.
51+
return `'${arg.replace(/'/g, `'\\''`)}'`;
52+
}
53+
54+
function runCaptured(
55+
cmd: string,
56+
args: string[],
57+
cwd: string,
58+
): { ok: true; stdout: string } | { ok: false; stdout: string; stderr: string; message: string } {
59+
try {
60+
const stdout = execFileSync(cmd, args.map(quoteShellArg), {
61+
cwd,
62+
stdio: ['ignore', 'pipe', 'pipe'],
63+
shell: true,
64+
encoding: 'utf8',
65+
});
66+
return { ok: true, stdout };
67+
} catch (err: any) {
68+
return {
69+
ok: false,
70+
stdout: String(err.stdout ?? ''),
71+
stderr: String(err.stderr ?? ''),
72+
message: String(err.message ?? 'unknown error'),
73+
};
74+
}
75+
}
76+
77+
function preflightTreeSitterCli(): PreflightFailure | null {
78+
const result = runCaptured('npx', ['tree-sitter', '--version'], root);
79+
if (result.ok) return null;
80+
81+
const combined = `${result.stderr}\n${result.stdout}\n${result.message}`;
82+
const missingBinary =
83+
/ENOENT.*tree-sitter(\.exe)?/i.test(combined) ||
84+
/tree-sitter(\.exe)? was not found/i.test(combined) ||
85+
/spawn .*tree-sitter(\.exe)?.*ENOENT/i.test(combined);
86+
87+
if (missingBinary) {
88+
return {
89+
reason: "tree-sitter CLI binary is missing — can't build WASM grammars",
90+
remediation: [
91+
'The tree-sitter-cli npm package downloads a platform-specific binary',
92+
'at install time (see node_modules/tree-sitter-cli/install.js). That',
93+
'download appears to have failed or been skipped on this machine,',
94+
'likely due to a network/proxy block or a previous --ignore-scripts install.',
95+
'',
96+
'Remediation — try one of:',
97+
' 1. npm rebuild tree-sitter-cli (re-run the binary download)',
98+
' 2. npm install -g tree-sitter-cli (install globally from PATH)',
99+
' 3. Download tree-sitter from',
100+
' https://github.com/tree-sitter/tree-sitter/releases',
101+
' and place it on PATH',
102+
'',
103+
'Note: the native Rust engine handles parsing on its own. WASM grammars',
104+
'are only needed when running with --engine wasm or on platforms without',
105+
'a prebuilt native addon.',
106+
],
107+
};
108+
}
109+
110+
return {
111+
reason: `tree-sitter CLI is not runnable: ${result.message.trim()}`,
112+
remediation: [
113+
'Inspect node_modules/tree-sitter-cli/ to confirm the package installed cleanly.',
114+
result.stderr.trim() ? `stderr: ${result.stderr.trim().split('\n').slice(0, 3).join(' | ')}` : '',
115+
].filter(Boolean),
116+
};
117+
}
118+
25119
// Allowed WASM imports — pure C runtime / memory primitives only (no I/O, no syscalls)
26120
const ALLOWED_WASM_IMPORTS = new Set([
27121
'env.memory',
@@ -124,8 +218,16 @@ const grammars = [
124218
{ name: 'tree-sitter-verilog', pkg: 'tree-sitter-verilog', sub: null },
125219
];
126220

221+
const preflight = preflightTreeSitterCli();
222+
if (preflight) {
223+
printBanner(`WASM build skipped: ${preflight.reason}`, preflight.remediation);
224+
console.warn(`\nSkipped building ${grammars.length} grammars. Exiting cleanly (non-fatal — native engine available).`);
225+
process.exit(0);
226+
}
227+
127228
let failed = 0;
128229
let rejected = 0;
230+
let missingToolchain = false;
129231

130232
for (const g of grammars) {
131233
let pkgDir: string;
@@ -139,15 +241,20 @@ for (const g of grammars) {
139241
const grammarDir = g.sub ? resolve(pkgDir, g.sub) : pkgDir;
140242

141243
console.log(`Building ${g.name}.wasm from ${grammarDir}...`);
142-
try {
143-
execFileSync('npx', ['tree-sitter', 'build', '--wasm', grammarDir], {
144-
cwd: grammarsDir,
145-
stdio: 'inherit',
146-
shell: true,
147-
});
148-
} catch (err: any) {
244+
const build = runCaptured('npx', ['tree-sitter', 'build', '--wasm', grammarDir], grammarsDir);
245+
if (!build.ok) {
149246
failed++;
150-
console.warn(` WARN: Failed to build ${g.name}.wasm — ${err.message ?? 'unknown error'}`);
247+
// Include build.message — Node.js surfaces ENOENT for spawned executables
248+
// (e.g. `spawn emcc ENOENT`) via the error message, not stderr.
249+
const combined = `${build.stderr}\n${build.stdout}\n${build.message}`;
250+
if (
251+
/emcc|emscripten|docker/i.test(combined) &&
252+
/not found|no such|cannot find|missing|ENOENT/i.test(combined)
253+
) {
254+
missingToolchain = true;
255+
}
256+
const detail = build.stderr.trim().split('\n').slice(-2).join(' | ') || build.message;
257+
console.warn(` WARN: Failed to build ${g.name}.wasm — ${detail}`);
151258
continue;
152259
}
153260

@@ -173,6 +280,18 @@ for (const g of grammars) {
173280
const total = failed + rejected;
174281
if (total > 0) {
175282
console.warn(`\n${failed} build failures, ${rejected} validation rejections out of ${grammars.length} grammars (non-fatal — native engine available)`);
283+
if (missingToolchain) {
284+
printBanner('WASM toolchain missing', [
285+
"tree-sitter's `build --wasm` needs either Docker or Emscripten (emcc) to",
286+
'compile grammars from C to WebAssembly. Neither appears to be available.',
287+
'',
288+
'Remediation — install one of:',
289+
' - Docker Desktop: https://www.docker.com/products/docker-desktop',
290+
' - Emscripten SDK: https://emscripten.org/docs/getting_started/downloads.html',
291+
'',
292+
'Then re-run: npm run build:wasm',
293+
]);
294+
}
176295
if (rejected > 0) {
177296
console.error('SECURITY: Some grammars were rejected — inspect the source packages before retrying.');
178297
}

0 commit comments

Comments
 (0)