Skip to content

Commit 94b9165

Browse files
theagenticguyT
andauthored
fix(ingestion,cli): make a broken parser fail loud, not silently produce a symbol-free graph (#204)
## Why The WASM-resolver bug fixed in #201 shipped **invisibly for ~5 days**. Root cause of the *invisibility* (separate from the resolver itself): a globally-broken parser degrades silently — every file returns empty captures, `analyze` builds a File/Directory-only skeleton graph, prints a node count, and **exits 0**. Nothing distinguished "the parser is dead" from "this repo has no symbols." This PR closes that failure class and the adjacent gaps the investigation surfaced. ## The five fixes **#1 — Zero-symbol guard (run-level backstop).** `ParseOutput` now carries `treeSitterFileCount` + `treeSitterSymbolCount`; the orchestrator trips a `zeroSymbolGuardTripped` flag via the exported pure predicate `shouldTripZeroSymbolGuard` (≥5 tree-sitter files **and** 0 symbols), pushes a loud warning, and `analyze` maps it to a distinct **advisory exit code 3**. Configs-only / cobol-only / unsupported-language repos report 0 tree-sitter files and never trip; external `CodeElement` import stubs are excluded so an import-only repo can't mask a break. **#2 — Distinguish global parser death from per-file failure.** New `WasmRuntimeUnavailableError` (with `name` set so it survives Piscina's structured-clone across the worker boundary). `ensureWasmRuntime` now **throws** it when the vendored grammar dir is missing / `web-tree-sitter.wasm` won't init — a deployment breakage — instead of the old ambiguous soft `undefined` that collapsed into N identical per-file warnings. `openWasmParser` + `parseOne` rethrow; the parse phase aborts the run with an actionable message. Per-file errors (syntax, timeout, one missing grammar) still warn-and-skip. The "web-tree-sitter package genuinely absent" path stays a no-throw hand-off to #1. **#3 — Resolver drift-guard test.** Extended the `asset-resolver` drift guard to cover **all 5 shipped asset trees** (added `vendor/wasms` + `java` to plugin-assets / ci-templates / config). A future fixed-offset regression in any resolver now fails a test instead of shipping silently — codifies the audit that this session ran by hand. **#4 — tsup rm-before-copy.** `copyTree` now `rm`s each leaf dest before `cp`, so an incremental/watch rebuild can't accumulate renamed/deleted asset dirs in `dist/` (the stale `opencodehub-*` skill dirs from the rename PR). Safe against the nested-dest hazard — each `onSuccess` dest is a distinct leaf. **#5 — Verifier asserts a real symbol.** `verify-global-install.sh` now requires `codehub query 'Greet'` to return a `Function/Class/Method` row from `greeter.go` (a uniquely-cased Go func), replacing the weak "any hit on `export default`" gate — which **passed on the 0-symbol skeleton** because the stderr header alone satisfied "non-empty". ## Verification Against the built bundle: - **Happy path** → exit 0, 16 nodes / 24 edges, symbols extracted. - **`vendor/wasms` hidden** → run **ABORTS (exit 1)** with `Phase 'parse' failed: web-tree-sitter runtime failed to initialize; vendored grammar directory not found at <dir> (reinstall … or re-vendor …)`. **No skeleton graph persisted.** (Before this PR: silent exit 0 with a 5-node skeleton — the exact bug that hid #201.) The two parser fixes compose: #2 hard-aborts the runtime-death case before anything is written; #1's exit-3 guard is the backstop for the residual soft case (package genuinely absent → no throw but 0 symbols). Tests: ingestion **594/594**, cli **316 pass / 0 fail / 11 platform-skip**; typecheck + repo lint + banned-strings clean. New tests: `wasm-runtime.test.ts` (global-probe), `parse-worker.test.ts` (sentinel rethrow vs per-file warning), `orchestrator.test.ts` (predicate table + healthy-run no-trip), extended `asset-resolver.test.ts` drift guard. ## Notes - Adds advisory **exit code 3** to `analyze` (graph built, but zero code symbols). Documented in an `index.ts` comment; the happy path stays exit 0. - Pushing a warning does not affect `graphHash` (hash is over graph nodes/edges only) — the byte-identical-hash determinism test stays green. - Stacks cleanly on the merged resolver fix (#201) and the prefix rename (#203); no overlap. --------- Co-authored-by: T <t@example.com>
1 parent 2547b13 commit 94b9165

18 files changed

Lines changed: 589 additions & 28 deletions

.erpaval/INDEX.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ development sessions. Solutions are reusable; specs are per-feature.
3333
- [Replace raw-SQL escape hatches with typed finders on the storage interface](solutions/architecture-patterns/typed-finders-replace-raw-sql-in-consumers.md) — 108 raw-SQL sites collapse into 15 named finders. Adapters internalize dialect; consumers stay backend-agnostic. Liskov-clean parity harness via public-method rebuilder.
3434
- [Parallel Act subagents on a shared git tree — interleaving + cherry-pick discipline](solutions/best-practices/parallel-act-subagents-with-shared-git-tree.md) — verify branch state, spawn on non-overlapping packages, watch for stale dist + phantom test counts, watch the test-fixup tail.
3535
- [Fixed-offset asset resolvers break on bundle collapse](solutions/best-practices/fixed-offset-asset-resolvers-break-on-bundle-collapse.md) — after a tsup collapse flattens `dist/commands/x.js``dist/x-<hash>.js`, EVERY `import.meta.url` resolver with a fixed `..` offset shifts by a level. #189 fixed only doctor.ts; 6 others shipped broken ~5 days (init/ci-init/setup/betterleaks + 2 ingestion WASM resolvers), two SILENTLY (`analyze` → 0 symbols, exit 0). Convert all to walk-up probes; the CLI bundles deps from `dist/` so rebuild in dep order; verify from a packed-tarball global install, not the hot node_modules.
36+
- [Silent degradation needs a run-level guard](solutions/best-practices/silent-degradation-needs-a-run-level-guard.md) — a `catch→warn→continue` loop hides a GLOBAL stage failure: every item fails identically, the run exits 0 with empty output. Fix is a PAIR — (a) a thrown sentinel distinguishing global-init failure from per-item failure (set `.name`, Piscina drops the prototype across threads), (b) a run-level "processed N but produced 0 expected outputs" guard with a distinct exit code. Pick the denominator carefully (exclude regex-provider/placeholder output) and make smoke tests assert a KNOWN symbol, not "any non-empty result".
3637
- [Squash-merge masks pre-existing repo-wide debt](solutions/best-practices/squash-merge-masks-pre-existing-debt.md) — first action on a fresh branch from main is `mise run check` BEFORE starting work; lint rules / transitive deps / cross-package test assertions drift across squash boundaries even when per-commit gating was green inside the prior PR.
3738
- [No spec-coordinate leakage into source](solutions/best-practices/no-spec-coordinate-leakage-into-source.md) — ERPAVal `AC-*`, `M-*`, `W-*`, `CL-*` prefixes belong in commits, PR bodies, ADR refs sections — NOT in JSDoc, inline comments, CLI flag help, MCP tool descriptions, or test names. Sweep `rg -n "AC-[A-Z]-[0-9]" packages/` before every PR-open; LLM clients pick up the leakage and start citing it back.
3839
- [release: published events need PAT or inline](solutions/conventions/release-published-event-needs-pat-or-inline.md) — release-please-action with default `GITHUB_TOKEN` does NOT fire downstream `release: [published]` workflows; inline asset-attach in `release-please.yml` gated on `steps.release.outputs.release_created`. Fixed AC-D-4; sbom.yml has same latent bug for follow-on.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
name: silent-degradation-needs-a-run-level-guard
3+
description: A pipeline stage that catches per-item errors and continues (returns empty, logs a per-item warning) will hide a GLOBAL failure of that stage — every item fails identically, the run completes, prints a count, and exits 0. Per-item warnings collapse and never become a verdict. The fix is a pair — (a) at the failure site, distinguish a global/init failure from a per-item failure and THROW on global; (b) a run-level aggregate guard ("processed N items but produced 0 of the expected output") that maps to a distinct exit code. Smoke tests must assert real output, not "any non-empty result".
4+
metadata:
5+
type: bug
6+
category: best-practices
7+
tags: [analyze, parse, wasm, silent-failure, exit-code, error-handling, piscina, smoke-test, guard]
8+
discovered: 2026-06-08
9+
session: session-analyze-hardening
10+
related:
11+
- fixed-offset-asset-resolvers-break-on-bundle-collapse
12+
- doctor-probe-drift-after-rip-and-replace
13+
- kill-escalation-races-the-exit-handler
14+
---
15+
16+
# Silent degradation needs a run-level guard, not just per-item warnings
17+
18+
## What bit us
19+
20+
The fixed-offset WASM-resolver bug ([[fixed-offset-asset-resolvers-break-on-bundle-collapse]])
21+
shipped invisibly for ~5 days. The resolver bug was the *cause*; the reason it
22+
was *invisible* is a separate, more general defect worth its own lesson:
23+
24+
`codehub analyze` parses every source file in a Piscina worker. The worker's
25+
per-file `try/catch` (parse-worker.ts) mapped **any** thrown error to
26+
`{captures: [], warnings: [msg]}` and continued. So when the grammar runtime was
27+
globally dead (vendored `vendor/wasms/` unresolvable in the flat bundle), EVERY
28+
file took the catch, produced zero captures, and the pipeline built a
29+
File/Directory-only **skeleton graph** — then printed `"5 nodes"` and **exited
30+
0**. The per-file warnings existed but `logWarnings` collapsed them into
31+
`"parse: N warnings (use --verbose)"`, and nothing aggregated them into a
32+
run-level verdict or an exit code. A broken parser looked exactly like a healthy
33+
parse of a symbol-free repo.
34+
35+
## The shape of the bug class
36+
37+
Any stage with this shape is vulnerable:
38+
39+
```
40+
for (const item of items) {
41+
try { results.push(await process(item)); }
42+
catch (e) { warnings.push(e.message); results.push(EMPTY); } // continue
43+
}
44+
return results; // a GLOBAL failure = N identical EMPTY results = silent success
45+
```
46+
47+
The per-item resilience (good — one malformed file shouldn't fail the run) is
48+
exactly what masks a global failure (bad — a dead runtime should fail the run).
49+
50+
## The fix is a PAIR, not one change
51+
52+
You need both halves; either alone is insufficient.
53+
54+
**(a) At the failure site, distinguish global from per-item and THROW on global.**
55+
Introduce a sentinel error (`WasmRuntimeUnavailableError`) for the
56+
init/deployment-level failure (vendored dir missing, runtime won't init). Throw
57+
it from the init path; rethrow it through the per-item catch *before* the
58+
generic warning-mapping. A global failure then aborts the run loudly with an
59+
actionable message, instead of becoming N warnings. Per-item errors (one bad
60+
file) still warn-and-skip.
61+
62+
**(b) A run-level aggregate guard as the backstop.** Even with (a), a softer
63+
degradation can slip through (e.g. the runtime package is genuinely absent and
64+
the init path returns a soft `undefined` by design). So add a run-level
65+
predicate: "the run processed >= K items that SHOULD have produced output, but
66+
produced 0" → push a loud warning AND set a **distinct advisory exit code**
67+
(here: 3, separate from generic-failure 1) so CI can detect a silent-skeleton
68+
run without scraping logs. Make the predicate a pure exported function and
69+
table-test the threshold.
70+
71+
Keep the two orthogonal: (a) keys off the thrown error, (b) keys off the output
72+
count. They must not double-fire on the same condition — (a) handles hard death,
73+
(b) handles the residual soft case.
74+
75+
## Gotchas that matter
76+
77+
- **Piscina structured-clones the rejection across threads** — a custom
78+
`class XError extends Error` arrives on the main thread as a plain `Error`
79+
with the prototype lost. Set `this.name` in the constructor and match on
80+
`err.name === "XError"` on the main thread (NOT `instanceof`). `instanceof`
81+
is fine *inside* the worker, before serialization.
82+
- **Pick the denominator carefully.** "Files scanned" is the wrong count if some
83+
legitimately produce no output (here: cobol routes through a regex provider,
84+
not tree-sitter; config-only/empty repos have zero parseable files). The guard
85+
must key off "items that SHOULD have produced output" (tree-sitter files), or
86+
it false-positives on legitimately-empty repos. And don't count placeholder
87+
output (external import stubs are `CodeElement` nodes — counting them as
88+
"symbols" would let an import-only repo mask a broken parser).
89+
- **Smoke tests must assert REAL output, not "any non-empty result".** The
90+
global-install verifier queried `'export default'` and passed on "≥1 hit" —
91+
but the query command's stderr header (`query: "..." (0 results)`) is itself
92+
non-empty, so the gate passed on a 0-symbol graph. Tighten to a KNOWN symbol
93+
from a KNOWN fixture with the expected KIND (`Function`) and FILE — something
94+
that can only appear if extraction actually ran.
95+
- **A separate `doctor`-style health command does NOT protect the hot path.**
96+
`doctor` had a vendored-wasms check, but it's a manual command never run by
97+
`analyze`. Don't assume an existing health probe covers the pipeline.
98+
99+
## The one-line takeaway
100+
101+
Per-item error resilience hides global stage failure. Whenever you write
102+
`catch → warn → continue` in a loop, ask "what does a GLOBAL failure of this
103+
stage look like?" — and add (a) a thrown sentinel for the global case + (b) a
104+
run-level "produced 0 of the expected output" guard with its own exit code.
105+
Then make the smoke test assert real output, not a non-empty string.

packages/cli/src/asset-resolver.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const CI_TEMPLATE_CANDIDATES = [
3838
["ci-templates"],
3939
["src", "commands", "ci-templates"],
4040
] as const;
41+
// vendor/wasms is resolved by doctor.ts's inline walk-up (resolveVendorWasmsDir,
42+
// accepts on manifest.json) and by @opencodehub/ingestion's vendor-wasms.ts.
43+
const VENDOR_WASMS_CANDIDATES = [["vendor", "wasms"]] as const;
44+
// java/cobol_to_scip.java is resolved by cobol-proleap-setup.ts's inline walk-up.
45+
const JAVA_WRAPPER_CANDIDATE = [["java", "cobol_to_scip.java"]] as const;
4146

4247
test("resolveAsset: flat post-collapse bundle — finds dist/plugin-assets as a sibling", async () => {
4348
const root = await mkTree();
@@ -209,4 +214,28 @@ test("drift guard: production resolver candidates hit real files in the built di
209214
join(dist, "config", "betterleaks.default.toml"),
210215
"betterleaks default config",
211216
);
217+
218+
// vendored grammar WASMs — doctor.ts's resolveVendorWasmsDir walks up probing
219+
// vendor/wasms and accepts on manifest.json. Also the runtime parse path
220+
// (ingestion vendor-wasms.ts) loads from here. A fixed-offset regression would
221+
// resolve outside dist/ and this assert.equal would fail.
222+
const wasms = resolveAsset(VENDOR_WASMS_CANDIDATES, { startDir: dist });
223+
assert.equal(wasms, join(dist, "vendor", "wasms"), "ingestion vendored grammars");
224+
assert.ok(
225+
statSync(join(wasms as string, "manifest.json")).isFile(),
226+
"vendor/wasms/manifest.json must ship (doctor + parse pipeline accept on it)",
227+
);
228+
assert.ok(
229+
statSync(join(wasms as string, "web-tree-sitter.wasm")).isFile(),
230+
"vendor/wasms/web-tree-sitter.wasm runtime must ship",
231+
);
232+
233+
// COBOL ProLeap JVM bridge source — cobol-proleap-setup.ts walks up probing
234+
// java/cobol_to_scip.java.
235+
const javaSrc = resolveAsset(JAVA_WRAPPER_CANDIDATE, { startDir: dist, kind: "file" });
236+
assert.equal(
237+
javaSrc,
238+
join(dist, "java", "cobol_to_scip.java"),
239+
"cobol proleap wrapper java source",
240+
);
212241
});

packages/cli/src/commands/analyze.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ export interface AnalyzeSummary {
175175
readonly durationMs: number;
176176
readonly upToDate: boolean;
177177
readonly warnings: readonly string[];
178+
/**
179+
* Set when the parse phase produced zero code symbols from a non-trivial
180+
* number of tree-sitter files (likely a globally-broken parser). The CLI maps
181+
* this to a distinct advisory exit code so CI catches a silent-skeleton run.
182+
*/
183+
readonly zeroSymbolGuard?: boolean;
178184
}
179185

180186
export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promise<AnalyzeSummary> {
@@ -316,6 +322,17 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi
316322

317323
logWarnings(result.warnings, opts.verbose === true);
318324

325+
// Surface the zero-symbol guard prominently — the detailed message is already
326+
// in result.warnings, but logWarnings collapses grouped warnings, so emit an
327+
// explicit run-level banner that can't be missed (and that the exit-code
328+
// mapping in index.ts keys off via the returned summary flag).
329+
if (result.zeroSymbolGuardTripped === true) {
330+
log(
331+
"codehub analyze: WARNING — extracted 0 code symbols from a non-trivial source tree; " +
332+
"the parser is likely broken (see the parse warning above). Run 'codehub doctor'.",
333+
);
334+
}
335+
319336
// Persist to the composed graph + temporal store. Storage is always
320337
// graph.lbug (graph-tier) + temporal.duckdb sidecar (cochanges, summary
321338
// cache); the temporal-tier writes (`bulkLoadCochanges`,
@@ -556,6 +573,7 @@ export async function runAnalyze(path: string, opts: AnalyzeOptions = {}): Promi
556573
durationMs,
557574
upToDate: false,
558575
warnings: result.warnings,
576+
...(result.zeroSymbolGuardTripped === true ? { zeroSymbolGuard: true } : {}),
559577
};
560578
}
561579

packages/cli/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ program
149149
const embeddingsWorkers = parseWorkerCount(workersRaw);
150150
const embeddingsBatchSize = parsePositiveInt(opts["embeddingsBatchSize"]);
151151

152-
await mod.runAnalyze(path ?? process.cwd(), {
152+
const analyzeSummary = await mod.runAnalyze(path ?? process.cwd(), {
153153
force: opts["force"] === true,
154154
embeddings: opts["embeddings"] === true,
155155
embeddingsVariant: opts["embeddingsInt8"] === true ? "int8" : "fp32",
@@ -177,6 +177,10 @@ program
177177
strictDetectors: opts["strictDetectors"] === true,
178178
...(allowBuildScripts !== undefined ? { allowBuildScripts } : {}),
179179
});
180+
// Advisory exit code 3: analyze built a graph but extracted zero code
181+
// symbols (likely a broken parser). Distinct from the generic failure
182+
// exit 1 so CI can detect a silent-skeleton run without parsing logs.
183+
if (analyzeSummary.zeroSymbolGuard === true) process.exitCode = 3;
180184
});
181185

182186
program

packages/cli/tsup.config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cp, mkdir } from "node:fs/promises";
1+
import { cp, mkdir, rm } from "node:fs/promises";
22
import { dirname, join } from "node:path";
33
import { fileURLToPath } from "node:url";
44
import { defineConfig } from "tsup";
@@ -59,6 +59,15 @@ const distDir = join(here, "dist");
5959
const EXTERNAL = [/^[^.]/];
6060

6161
async function copyTree(from: string, to: string): Promise<void> {
62+
// rm the leaf dest FIRST so a rename/delete in the source tree doesn't leave
63+
// a stale entry behind in `dist/`. `cp(force:true)` overwrites files present
64+
// in both trees but never removes dest-only entries, so without this an
65+
// incremental/watch rebuild accumulates renamed dirs (e.g. a renamed skill
66+
// left both old and new names under dist/plugin-assets/skills/). `clean:true`
67+
// only wipes dist on a full build, not in --watch. rm the exact leaf `to`
68+
// (never `dirname(to)`) — every onSuccess dest is a distinct non-nested leaf,
69+
// and plugin-assets is copied as one tree, so this can't clobber a sibling.
70+
await rm(to, { recursive: true, force: true });
6271
await mkdir(dirname(to), { recursive: true });
6372
await cp(from, to, { recursive: true, force: true, errorOnExist: false });
6473
}

packages/ingestion/src/parse/parse-worker.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it } from "node:test";
44
import type { LanguageId } from "@opencodehub/core-types";
55
import { MAX_FILE_BYTES, parseOne } from "./parse-worker.js";
66
import type { ParseCapture, ParseTask } from "./types.js";
7+
import { WasmRuntimeUnavailableError } from "./wasm-runtime.js";
78

89
function task(content: Buffer, language: LanguageId = "typescript"): ParseTask {
910
return { filePath: "src/sample.ts", content, language };
@@ -75,6 +76,25 @@ describe("parseOne — error to warning mapping", () => {
7576

7677
assert.equal(result.warnings?.[0], "plain string failure");
7778
});
79+
80+
it("RETHROWS a global WasmRuntimeUnavailableError instead of mapping it to a warning", async () => {
81+
// A global runtime death is broken-for-every-file; it must abort the run,
82+
// not become a per-file warning that hides behind a 0-symbol skeleton graph.
83+
await assert.rejects(
84+
parseOne(task(Buffer.from("x")), async () => {
85+
throw new WasmRuntimeUnavailableError("vendor/wasms missing");
86+
}),
87+
/vendor\/wasms missing/,
88+
);
89+
});
90+
91+
it("keeps an ordinary per-file Error as a warning (not the global sentinel)", async () => {
92+
const result = await parseOne(task(Buffer.from("x")), () => {
93+
throw new Error("one bad file");
94+
});
95+
assert.deepEqual(result.captures, []);
96+
assert.equal(result.warnings?.[0], "one bad file");
97+
});
7898
});
7999

80100
describe("parseOne — happy path", () => {

packages/ingestion/src/parse/parse-worker.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ import { Buffer } from "node:buffer";
2929
import { performance } from "node:perf_hooks";
3030
import type { LanguageId, ParseBatch, ParseCapture, ParseResult, ParseTask } from "./types.js";
3131
import { getUnifiedQuery } from "./unified-queries.js";
32-
import { openWasmParser, type WasmParserHandle } from "./wasm-runtime.js";
32+
import {
33+
openWasmParser,
34+
type WasmParserHandle,
35+
WasmRuntimeUnavailableError,
36+
} from "./wasm-runtime.js";
3337

3438
const PER_FILE_TIMEOUT_MS = 30_000;
3539
/** Pre-parse byte cap. Inputs above this are skipped before any WASM call. */
@@ -101,6 +105,11 @@ export async function parseOne(
101105
...(warnings.length > 0 ? { warnings } : {}),
102106
};
103107
} catch (err) {
108+
// A global runtime failure means the parser is dead for every file — let it
109+
// propagate so the batch (and the run) abort loudly, instead of recording N
110+
// identical per-file warnings and a silently symbol-free graph. Per-file
111+
// errors (syntax, timeout, one bad grammar) stay warnings and skip the file.
112+
if (err instanceof WasmRuntimeUnavailableError) throw err;
104113
const message = err instanceof Error ? err.message : String(err);
105114
warnings.push(message);
106115
return {

packages/ingestion/src/parse/vendor-wasms.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { statSync } from "node:fs";
3131
import { dirname, resolve } from "node:path";
3232
import { fileURLToPath } from "node:url";
3333

34-
function isDirSync(p: string): boolean {
34+
export function isDirSync(p: string): boolean {
3535
try {
3636
return statSync(p).isDirectory();
3737
} catch {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Unit tests for the WASM-runtime global-failure detection.
3+
*
4+
* The load-bearing behavior: a globally-broken parse runtime (vendored
5+
* `vendor/wasms/` missing, or web-tree-sitter.wasm un-loadable) must surface as
6+
* a thrown {@link WasmRuntimeUnavailableError} — NOT a soft per-file skip that
7+
* lets `analyze` complete with a symbol-free skeleton graph and exit 0.
8+
*
9+
* `VENDOR_WASMS_DIR` is computed once at module load, so the global-detection
10+
* logic is split into the pure `assertRuntimeAvailable(dirExists, dir)` seam,
11+
* which we test directly without standing up Emscripten or mutating the const.
12+
*/
13+
14+
import { strict as assert } from "node:assert";
15+
import { describe, it } from "node:test";
16+
import {
17+
assertRuntimeAvailable,
18+
vendorWasmsDirExists,
19+
WasmRuntimeUnavailableError,
20+
} from "./wasm-runtime.js";
21+
22+
describe("assertRuntimeAvailable — global runtime probe", () => {
23+
it("throws WasmRuntimeUnavailableError naming the dir when vendored grammars are missing", () => {
24+
assert.throws(
25+
() => assertRuntimeAvailable(false, "/nope/vendor/wasms"),
26+
(err: unknown) => {
27+
assert.ok(err instanceof WasmRuntimeUnavailableError);
28+
assert.equal((err as Error).name, "WasmRuntimeUnavailableError");
29+
assert.match((err as Error).message, /\/nope\/vendor\/wasms/);
30+
return true;
31+
},
32+
);
33+
});
34+
35+
it("does not throw when the vendored directory exists", () => {
36+
assert.doesNotThrow(() => assertRuntimeAvailable(true, "/anywhere"));
37+
});
38+
39+
it("sets a name that survives structured-clone across the worker boundary", () => {
40+
// Piscina loses the subclass prototype across threads; the main thread must
41+
// be able to match on `name`. Constructing fresh confirms the name is set.
42+
const err = new WasmRuntimeUnavailableError("boom");
43+
assert.equal(err.name, "WasmRuntimeUnavailableError");
44+
});
45+
46+
it("reports the real vendored dir as present in this (built) package", () => {
47+
// Sanity: the standalone ingestion build ships vendor/wasms, so the probe
48+
// is true here. This also guards the resolver — a regression that pointed
49+
// VENDOR_WASMS_DIR outside the package would flip this to false.
50+
assert.equal(vendorWasmsDirExists(), true);
51+
});
52+
});

0 commit comments

Comments
 (0)