Semantic debugging
Engine-level debugging tools in Cruft: column-accurate, tail-call-aware stack traces you get for free, the CRUFT_SEMBP semantic breakpoints that report silent coercions, missing arguments, and prototype misses at the instant they happen, and the CRUFT_EXECDB raw op-event stream beneath them.
Every runtime gives you a stack trace: the chain of calls that were open when something threw. It tells you where execution was. It does not tell you why a value was wrong before it got there, or when a silent coercion turned your number into NaN, or that you called a function with one argument too few. In Node you reconstruct that by hand, sprinkling console.log until the shape of the bug appears.
Cruft ships a set of engine-level diagnostics that answer those questions directly, because Exegesis already knows the answers as it runs. This page is the exhaustive tour: the column-accurate stack trace you get for free, the semantic breakpoints you arm with an environment variable, and the raw op-event stream underneath them. It also covers one high-accuracy surface that is built but not yet exposed.
The stack trace is column-accurate and tail-call-aware
Start with what every uncaught error already gives you, because it is more precise than the Node baseline in two ways.
Exact columns. Each frame carries a real line:column, mapped from the bytecode span back to source, not just a line number:
$ cruft app.mjs
cruft: evaluation error: Error: crash here
at inner (file:///…/app.mjs:1:36)
at outer (file:///…/app.mjs:2:20)
at file:///…/app.mjs:3:1
The 36 and 20 are the columns of the actual call and throw expressions. The same string is on error.stack if you catch the error yourself, and inline -e code and REPL input render as <eval:…> and [repl:N] frames respectively.
Tail calls are reconstructed. Cruft does proper tail-call optimization: a function that returns f(x) as its last act reuses the frame instead of growing the stack, which is O(1) space but normally erases the caller from any trace. Cruft keeps a record of the elided predecessors and stitches them back into the rendered trace, so a deep tail-recursive chain still shows the frames a Node developer expects to see rather than a single collapsed frame.
The V8 error API works. Error.captureStackTrace(target, ctorOpt) captures a trace onto any object and drops the constructor frame just like V8, and Error.prepareStackTrace receives structured CallSite objects (getFileName, getLineNumber, getColumnNumber, and the rest) so trace-formatting libraries that feature-detect it get real data:
function boom() {
const e = new Error("x");
Error.captureStackTrace(e, boom); // trace starts at boom's *caller*
return e;
}
console.log(boom().stack);
// Error: x
// at file:///…/app.mjs:<call site of boom>
None of this needs a flag. It is always on.
Semantic breakpoints: CRUFT_SEMBP
A stack trace fires when something throws. Most real bugs never throw: a string silently coerces to a number, a function is called with a missing argument that reads as undefined, a typo'd property returns undefined instead of erroring. The program keeps running and the wrong value propagates until, much later and much further away, it finally trips something.
A semantic breakpoint stops guessing where "much later" was. You name the class of silent event you care about, and Exegesis reports every occurrence at the instant it happens, with the exact position and a full stack trace:
$ CRUFT_SEMBP="coerce,arity,proto-miss" cruft app.mjs
[sembp arity] add called with 1 arg(s), expects 2 param(s)
at add (file:///…/app.mjs:1:29)
at file:///…/app.mjs:2:5
[sembp coerce] "3" (string) → number in `*` @3:17
at file:///…/app.mjs:2:5
[sembp proto-miss] property 'missing' on `o` not found on the prototype chain @5:13
at file:///…/app.mjs:2:5
Read one line: the string "3" was coerced to a number inside a * operation at line 3, column 17, and here is the call stack that got there. That is the console.log hunt collapsed into one report, positioned exactly.
The three event classes
Pass any comma-separated subset. Each event carries its own @line:column and a stack trace, so you get both the point of the event and the path to it.
Halt mode: stop at the first occurrence
Append ! to any name to turn that breakpoint into an assertion: the run stops the first time the event fires, rather than reporting and continuing.
$ CRUFT_SEMBP="arity!" cruft app.mjs
[sembp arity] add called with 1 arg(s), expects 2 param(s)
at add (file:///…/app.mjs:1:29)
at file:///…/app.mjs:2:5
[sembp halt] stopping at first `arity` event (semantic breakpoint)
This is how you catch the first bad coercion in a run instead of scrolling through a thousand downstream ones. Mix modes freely: CRUFT_SEMBP="coerce,arity!" reports every coercion but halts on the first arity mismatch.
JSON output for tooling
CRUFT_SEMBP_FORMAT=json emits one JSON object per event instead of the human-readable block, so a wrapper script or editor integration can consume the stream:
$ CRUFT_SEMBP="coerce" CRUFT_SEMBP_FORMAT=json cruft app.mjs
{"sembp":"coerce","value":"\"3\"","from":"string","op":"*","line":3,"col":17,"trace":"at file:///…/app.mjs:2:5"}
The default is text.
The op-event stream: CRUFT_EXECDB
One level below semantic breakpoints is the raw execution database: a JSONL record of individual bytecode operations as they execute, each with the op name and its source position.
$ CRUFT_EXECDB=all cruft app.mjs
{"op_index":1,"op":"MakeClosure","line":16,"col":2}
{"op_index":2,"op":"Call","line":16,"col":2}
…
all is a firehose. In practice you filter by bytecode op name (case insensitive), which is how you answer "every property read this program performs, in order":
$ CRUFT_EXECDB=GetProp cruft app.mjs
{"op_index":36,"op":"GetProp","line":102,"col":18,"name":"prototype"}
{"op_index":66,"op":"GetProp","line":68,"col":28,"name":"text"}
…
Comma-separate to widen the filter (CRUFT_EXECDB=GetProp,Call). This is the tool for the rare bug where you need to see the actual op sequence — a control-flow surprise, an operation firing more often than you thought — rather than a semantic summary. It is verbose and engine-oriented by design; reach for CRUFT_SEMBP first and drop to CRUFT_EXECDB only when you need the raw trace.
Two things to know before you rely on these
They observe the runtime's own startup, not only your code. Both CRUFT_SEMBP and CRUFT_EXECDB instrument the whole realm, and Cruft's built-in modules are themselves JavaScript that runs during startup. So the first events you see may come from the bootstrap prelude — frames rendered as <eval:…> with unfamiliar line numbers — before your program's own events appear. Filter for your file's file:/// path (a grep on the output is the practical move) to isolate your code.
Every CRUFT_* variable has a CRUFTLESS_* alias. CRUFTLESS_SEMBP, CRUFTLESS_EXECDB, and CRUFTLESS_SEMBP_FORMAT behave identically to their CRUFT_* forms. Use whichever your environment already standardizes on; do not set both.
Value provenance: built but not surfaced
The most powerful version of this idea is causal lineage: not just "this property read returned undefined," but the backward chain of why. The engine computes exactly that. When a read resolves to undefined or an arithmetic result becomes NaN, Exegesis builds a positioned origin chain — the kind of explanation that would read:
config.rateis undefined becauseconfigis the return value ofparse(), which has no return statement (at line 4, column 3).
That chain exists internally, depth-bounded, with a birth position on every link. Today it is not appended to the thrown error: a Cannot read properties of undefined (reading 'rate') prints with the plain, Node-identical message and no provenance clause, because the runtime keeps the default error string byte-compatible with Node. The structured lineage is retained for higher-level tooling to consume rather than rendered to stderr.
Quick reference
See also Debugging and diagnostics for the wider toolkit (--audit, memory, hangs, exit codes) and the error reference for message prefixes and capability-denial diagnostics.