Files
codegraph/scripts/add-lang/check-grammar.mjs
T
Colby MchenryandClaude Opus 5.5 9ed79c6cf1 fix(add-lang): check-grammar fails a grammar that crashes or hangs its process (#2808)
Root cause: scripts/add-lang/check-grammar.mjs ran the parse loop in its own
process and printed "RESULT: PASS" as soon as the loop finished. A grammar
can finish every parse and still take the process down after that. With the
issue's vim grammar (tree-sitter-wasm 2.0.4) on Node 24, V8's optimizing tier
aborts the process from a background compile job ("Fatal process out of
memory: Zone") after PASS is printed, so the verdict and the exit code
disagree. The check also ran without the V8 flag indexing always uses
(--liftoff-only), so it judged grammars in a setup codegraph never runs them
in, and an iteration count of 0 or a word passed without a single parse.

Fix: the script runs the grammar in a child process, the way indexing does,
and prints the verdict only after that child has exited.
- The child gets indexing's V8 flags (WASM_RUNTIME_FLAGS) and loads every
  vendored grammar beside the one under test, like a polyglot index.
- It parses the sample with one reused parser, walks every node of each tree
  and frees it, as extraction does. Every parse of the same sample must give
  the same tree.
- It then parses the sample repeated to 1 MiB, the most indexing reads from
  one file, within the 10 s indexing gives one file. Only a crash or a hang
  counts there.
- The parent fails the grammar when the child dies (any exit code or
  signal), runs past a timeout (CODEGRAPH_CHECK_GRAMMAR_TIMEOUT_MS, 120 s by
  default), or reports ERROR trees or differing trees. An iteration count that
  is not a whole number above 0 is refused (exit 2).
The add-lang skill now says what counts as a FAIL.

Validation:
- New __tests__/add-lang-check-grammar.test.ts. A preload marks the process
  that runs WebAssembly, records its flags, and on request kills it or makes
  it hang at exit, after the parses. With main's script 4 of 5 tests fail:
  PASS printed for a process that then died, a hang until the test's 60 s
  limit, no --liftoff-only in the parsing process, and "0" passing. With the
  fix all 5 pass. The fifth (the tree-sitter-wasms Lua ABI-13 grammar fails
  with ERROR trees) passes on both.
- The issue's vim grammar (sha256 df3906a0, the issue's file) on Node 24.21.0:
  main's script printed PASS and then died with the Zone OOM, exit 3, in 3 of
  3 runs. The new check runs it with --liftoff-only, as indexing does, where
  it parses cleanly, 1 MiB included, and passes in 3 of 3. A copy of the new
  script with the flag withheld fails it in 3 of 3: "the parses finished, but
  the process running them then died (exit code 2147483651, 0x80000003)".
- Old vs new on the 19 vendored grammars with real samples, plus the
  tree-sitter-wasms Lua and the vim grammar, on Node 22.22.0 and 24.21.0
  (84 runs): the same verdict every time, except vim on Node 24, where main's
  PASS came with exit 3. No grammar's parses diverged.

Fixes #2464

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 17:04:12 +00:00

228 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
// Verify a tree-sitter grammar wasm is HEALTHY under the project's web-tree-sitter
// runtime BEFORE writing an extractor. Prints the ABI version and parses a valid
// sample many times in a multi-grammar context, to catch heap-corruption bugs
// that silently drop nodes on every parse after the first.
//
// Why this exists: the tree-sitter-wasms Lua grammar is ABI 13 and corrupts the
// shared WASM heap under web-tree-sitter 0.25 — Lua extraction degraded on every
// file after the first (nested calls/imports vanished). The fix was to vendor the
// upstream ABI-15 wasm. Run this on any new grammar first; if it FAILs, vendor a
// newer build instead of using the tree-sitter-wasms one.
//
// The grammar runs the way indexing runs it, in a child process:
// - with indexing's V8 flags (WASM_RUNTIME_FLAGS in
// src/extraction/wasm-runtime-flags.ts), so V8 compiles it as it would there;
// - next to every grammar codegraph vendors, like a polyglot index;
// - parsing the sample with one reused parser, walking every node of each tree
// and freeing it, as extraction does; every parse must give the same tree;
// - then once more at the largest file indexing reads (the sample repeated to
// 1 MiB, MAX_SOURCE_FILE_SIZE_BYTES in src/file-limits.ts), with the time
// indexing gives one file.
// This process prints the verdict only after that child has exited. A grammar
// can finish every parse and still take the process down, as when V8 aborts it
// from a background compile job ("Fatal process out of memory: Zone"), or hang;
// either one is a FAIL (#2464).
//
// Usage: node scripts/add-lang/check-grammar.mjs <lang|wasm-path> <valid-sample> [iterations]
// Exit: 0 healthy; 1 parse errors, corruption, or the check process crashed or
// hung; 2 could not run.
// CODEGRAPH_CHECK_GRAMMAR_TIMEOUT_MS: how long the child may run (default 120000).
// NOTE: the sample must be SYNTACTICALLY VALID — a broken sample fails for the
// wrong reason.
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const require = createRequire(import.meta.url);
const SCRIPT = fileURLToPath(import.meta.url);
const VENDORED_DIR = path.resolve(path.dirname(SCRIPT), '../../src/extraction/wasm');
// Indexing's V8 flags. Keep in step with WASM_RUNTIME_FLAGS:
// __tests__/add-lang-check-grammar.test.ts fails when the child lacks one.
const INDEXING_WASM_FLAGS = ['--liftoff-only'];
// The most indexing reads from one file (MAX_SOURCE_FILE_SIZE_BYTES), and how
// long it gives one file to parse (DEFAULT_PARSE_TIMEOUT_MS in parse-pool.ts).
const MAX_FILE_BYTES = 1024 * 1024;
const FILE_BUDGET_MS = 10_000;
const CHILD_ENV = 'CODEGRAPH_CHECK_GRAMMAR_CHILD';
const RESULT_TAG = 'CHECK_GRAMMAR_RESULT ';
const DEFAULT_TIMEOUT_MS = 120_000;
const SPECIAL = { csharp: 'c_sharp', 'c#': 'c_sharp' };
const fail = (code, msg) => { console.error(`[check-grammar] ${msg}`); process.exit(code); };
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
function resolveWasm(t) {
if (t.endsWith('.wasm')) return existsSync(t) ? path.resolve(t) : fail(2, `wasm not found: ${t}`);
const base = SPECIAL[t.toLowerCase()] ?? t.toLowerCase();
try { return require.resolve(`tree-sitter-wasms/out/tree-sitter-${base}.wasm`); } catch { /* try vendored */ }
const vendored = path.join(VENDORED_DIR, `tree-sitter-${base}.wasm`);
if (existsSync(vendored)) return vendored;
return fail(2, `no grammar for "${t}" — not in tree-sitter-wasms and not vendored`);
}
/** The parent: run the grammar in a child process, then judge how it ended. */
function judge([token, sample, iterArg]) {
if (!token || !sample) fail(2, 'usage: check-grammar.mjs <lang|wasm-path> <valid-sample> [iterations]');
if (!existsSync(sample)) fail(2, `sample not found: ${sample}`);
const iters = iterArg === undefined ? 20 : Number(iterArg);
if (!Number.isInteger(iters) || iters < 1) fail(2, `iterations must be a whole number above 0, not "${iterArg}"`);
const wasmPath = resolveWasm(token);
const timeoutMs = Number(process.env.CODEGRAPH_CHECK_GRAMMAR_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS;
const run = spawnSync(process.execPath, [...INDEXING_WASM_FLAGS, SCRIPT, wasmPath, path.resolve(sample), String(iters)], {
env: { ...process.env, [CHILD_ENV]: '1' },
encoding: 'utf8',
timeout: timeoutMs,
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
});
const timedOut = run.error?.code === 'ETIMEDOUT';
if (run.error && !timedOut) fail(2, `could not run the check: ${run.error.message}`);
const lines = (run.stdout ?? '').split(/\r?\n/);
const tagged = lines.find((line) => line.startsWith(RESULT_TAG));
const result = tagged ? JSON.parse(tagged.slice(RESULT_TAG.length)) : null;
const passthrough = lines.filter((line) => !line.startsWith(RESULT_TAG)).join('\n').trim();
if (passthrough) console.log(passthrough);
if (run.stderr) process.stderr.write(run.stderr.endsWith('\n') ? run.stderr : `${run.stderr}\n`);
if (run.status === 2 && !result) { process.exitCode = 2; return; } // the child said why it could not run
console.log(`grammar: ${path.basename(wasmPath)}`);
if (result) {
console.log(` ABI version: ${result.abi}`);
console.log(` runtime: node ${[result.node, ...result.execArgv].join(' ')}, with ${result.companions} other grammars loaded`);
console.log(` parses: ${result.clean} clean / ${result.errors} with errors (of ${result.iters})`);
if (result.large) {
const { copies, bytes, nodes, ms } = result.large;
console.log(nodes === null
? ` largest file: the sample ${copies}x (${bytes} bytes) was still parsing after ${FILE_BUDGET_MS / 1000} s, ` +
'as long as indexing gives one file, and was stopped (not judged)'
: ` largest file: the sample ${copies}x (${bytes} bytes, ${nodes} nodes) in ${ms} ms`);
}
}
const [code, verdict] = verdictFor(run, result, timedOut, timeoutMs);
console.log(verdict);
process.exitCode = code;
}
function verdictFor(run, result, timedOut, timeoutMs) {
const seconds = Math.round(timeoutMs / 1000);
const finishedAnyway = 'Finishing the parses is not the same as being safe to use: indexing would ' +
'go down the same way. Do not vendor this wasm.';
if (timedOut) {
return [1, result
? `RESULT: FAIL — the parses finished, but the process running them had not exited after ${seconds} s ` +
`and was stopped. ${finishedAnyway}`
: `RESULT: FAIL — the parses had not finished after ${seconds} s, so the process running them was stopped: ` +
'the grammar hangs, or is far too slow, on this sample. Do not vendor this wasm.'];
}
if (run.status !== 0 || !result) {
return [1, result
? `RESULT: FAIL — the parses finished, but the process running them then died (${describeEnd(run)}). ${finishedAnyway}`
: `RESULT: FAIL — the process running the parses died (${describeEnd(run)}) before they finished. ` +
'Indexing would go down the same way. Do not vendor this wasm.'];
}
if (result.errors > 0) {
return [1, `RESULT: FAIL — ${result.errors}/${result.iters} parses produced ERROR trees on a valid sample. ` +
'This grammar corrupts under web-tree-sitter; vendor a newer (ABI 14/15) wasm ' +
'(see SKILL.md "Find a grammar"). Confirm your sample is syntactically valid first.'];
}
if (result.diverged > 0) {
return [1, `RESULT: FAIL — ${result.diverged}/${result.iters - 1} later parses of the same sample gave a ` +
'different tree than the first. This grammar corrupts under web-tree-sitter; vendor a newer ' +
'(ABI 14/15) wasm (see SKILL.md "Find a grammar").'];
}
return [0, 'RESULT: PASS — grammar parses cleanly and reuses safely, and the process running it exited cleanly.'];
}
function describeEnd(run) {
if (run.signal) return `signal ${run.signal}`;
const code = run.status;
return code > 255 ? `exit code ${code}, 0x${(code >>> 0).toString(16).toUpperCase()}` : `exit code ${code}`;
}
/** The child: load and exercise the grammar the way indexing does, then report. */
async function runGrammar(wasmPath, samplePath, itersArg) {
const iters = Number(itersArg);
const { Parser, Language } = await import('web-tree-sitter');
try { await Parser.init(); }
catch { await Parser.init({ locateFile: () => require.resolve('web-tree-sitter/tree-sitter.wasm') }); }
// The other grammars an index loads beside this one: the corruption surfaces
// under the multi-grammar runtime that real indexing uses, not a single
// grammar in isolation.
let companions = 0;
for (const file of readdirSync(VENDORED_DIR).filter((f) => f.endsWith('.wasm')).sort()) {
const companion = path.join(VENDORED_DIR, file);
if (samePath(companion, wasmPath)) continue;
try { await Language.load(companion); companions++; } catch { /* not the grammar under test */ }
}
let language;
try { language = await Language.load(wasmPath); }
catch (e) {
// No process.exit: on Windows a pipe write can still be pending.
console.error(`[check-grammar] failed to load ${wasmPath}: ${e.message}`);
process.exitCode = 2;
return;
}
const parser = new Parser();
parser.setLanguage(language);
const source = readFileSync(samplePath, 'utf8');
let clean = 0, errors = 0, diverged = 0, first = null;
for (let i = 0; i < iters; i++) {
const tree = parser.parse(source);
if (!tree) throw new Error('the parser returned no tree');
const shape = walk(tree);
if (tree.rootNode.hasError) errors++; else clean++;
if (!first) first = shape;
else if (shape.nodes !== first.nodes || shape.hash !== first.hash) diverged++;
tree.delete();
}
// The largest file indexing reads, with the time indexing gives one file,
// unless the grammar has already failed. Repeating a sample can make code the
// language rejects, and recovering from that can be slow, so here only a
// crash or a hang counts.
let large = null;
if (errors === 0 && diverged === 0) {
const copies = Math.max(1, Math.floor(MAX_FILE_BYTES / (Buffer.byteLength(source) + 1)));
const big = Array(copies).fill(source).join('\n');
const started = performance.now();
const bigTree = parser.parse(big, null, { progressCallback: () => performance.now() - started > FILE_BUDGET_MS });
large = { copies, bytes: Buffer.byteLength(big), nodes: bigTree ? walk(bigTree).nodes : null, ms: Math.round(performance.now() - started) };
bigTree?.delete();
}
parser.delete();
console.log(RESULT_TAG + JSON.stringify({
abi: language.abiVersion, iters, clean, errors, diverged, companions, large,
node: process.version, execArgv: process.execArgv,
}));
}
/** Visit every node, as extraction does; returns the tree's size and a hash of its shape. */
function walk(tree) {
const cursor = tree.walk();
let nodes = 0, hash = 2166136261;
const mix = (value) => { hash = Math.imul(hash ^ value, 16777619) >>> 0; };
for (;;) {
nodes++;
mix(cursor.nodeTypeId); mix(cursor.currentFieldId); mix(cursor.startIndex); mix(cursor.endIndex);
if (cursor.gotoFirstChild()) continue;
while (!cursor.gotoNextSibling()) {
if (!cursor.gotoParent()) { cursor.delete(); return { nodes, hash }; }
}
}
}
if (process.env[CHILD_ENV] === '1') await runGrammar(...process.argv.slice(2));
else judge(process.argv.slice(2));