Degradation diagnostics
Parseman can't always do the fast thing. A reducer's parameter list might be unreadable, an artifact might be opaque, a regex might not lower to a charCodeAt scan. Falling back is the correct move — the parse result comes out the same either way. Falling back silently is the defect: a build quietly paying 5x looks exactly like a build that's working fine, and the only way to tell the difference is to go read the generated artifact yourself.
So every fallback path reports on one channel.
The format
[parseman] degraded [<code>] <where>: <subject> — <fell back to>; otherwise <what>For example:
[parseman] degraded [build-arity-unconfirmed] node("Operation"): build reducer
`foldOperation` — could not confirm its formal parameter list (`foldOperation` did not
resolve to a module-scope function declaration (imported binding, shadowed name, or a
computed value)), so this node captures children, fields and raw children, logs trivia,
and clones `_ctx.state` on every match; otherwise only the tiers the reducer actually
declares would be captured (arity >= 1 children, >= 2 fields, >= 4 raw, >= 5 trivia,
>= 6 state)Each line names the rule, the reducer or input responsible, what parseman did instead, and what it would have done otherwise. A diagnostic that just says "fallback" isn't actionable, and isn't worth printing.
Levels
Default-on:
PARSEMAN_DEGRADATION=off # silence
PARSEMAN_DEGRADATION=warn # default — print
PARSEMAN_DEGRADATION=error # fail the buildUnder the macro plugin, findings arrive as ordinary bundler warnings (Vite/Rollup's this.warn), anchored to the module, and error throws once at the end of the module with every finding listed. A runtime compile() drains at the end of the compile instead: at warn it prints one aggregated block, at error it throws once with every finding listed.
Both modes honour error
Before 0.45.0, error was inert for a runtime compile() — the drain that threw lived only in the macro plugin, so library users got warn behavior from a setting documented as "fail the build."
This channel is NOT like the gating diagnostic
0.45.0 moved the first-char gating advice out of the compile path entirely: compile() now produces an artifact and says nothing, and you ask for a diagnosis with diagnoseGrammar(). This channel deliberately did not follow suit.
Gating advice is just advice — the build did what you asked, and here are some notes. A degradation is parseman telling you it could not do what you asked, and the whole point of this release is that such a thing must never be silent. So it stays default-on, on both paths.
What did change is the shape on the runtime path. It used to print one full line per site as each was discovered — 31 near-identical ~500-character lines for a single code in one benchmark run, while the macro path had always aggregated. Now both drain the same way, so the eight-site cap and the counted summary below apply everywhere.
Asserting zero degradations
The message is greppable on the literal [parseman] degraded, so a build gate can assert there are none — the same shape you'd use to grep build output for falling back to runtime:
pnpm build 2>&1 | tee build.log
! grep -q '\[parseman\] degraded' build.logor, equivalently, set PARSEMAN_DEGRADATION=error and let the build fail.
A degradation recorded by an analysis rather than a compile — an opaque contributing artifact, say — also comes back structurally, on diagnoseGrammar(g).degradations, as a finding in that report. That's the machine-readable route: d.ok covers it, no grepping required.
Aggregation
A diagnostic that fires on every rule would get filtered out — and filtered-out is just silent by another name. So instead, past eight sites per code, the remainder collapses into one counted line:
[parseman] degraded [build-arity-unconfirmed] +12 more site(s) not listed (20 total).
Set PARSEMAN_DEGRADATION=error to fail the build on these.Codes
| Code | What was lost | Usual fix |
|---|---|---|
build-arity-unconfirmed | Parseman couldn't work out the build's parameter list, so all five capture tiers stay on. | See below — usually a rest parameter or a reassigned binding. Declare it with node(..., { buildArity: n }). |
mk-inline-missed | A reducer looks like an mk(...) wrapper but did not match the shape, so each match pays a call instead of an inlined object literal. | Use (children, fields, span, rawChildren, triviaLog) => mk(type, children, rawChildren, span, triviaLog) with the node's own type. |
Reducer arity
A node's build receives (children, fields, span, rawChildren, triviaLog, state). Collecting an argument the build never declares is wasted work — and the trivia log's per-token push alone dominates real parses. So parseman works out the build's declared arity and elides every tier above it:
| Arity | Enables |
|---|---|
>= 1 | children |
>= 2 | fields |
>= 4 | rawChildren |
>= 5 | triviaLog |
>= 6 | a clone of _ctx.state |
What the macro resolves
An inline arrow function describes itself. Everything else is just a name, and the macro plugin runs at enforce: 'pre' with the module AST and the filesystem available — so it resolves the name rather than giving up on it:
const foldOperation = children => ({ … })
node('Fold', body, foldOperation) // ✓ module-scope const
function foldOperation(children) { … }
node('Fold', body, foldOperation) // ✓ function declaration
let foldOperation = children => ({ … }) // ✓ `let`/`var`, if never reassigned
import { foldOperation } from './reducers.ts' // ✓ named import
import { foldOperation as fold } from './reducers.ts' // ✓ aliased import
import fold from './reducers.ts' // ✓ default import
import * as helpers from './reducers.ts'
node('Fold', body, helpers.fold) // ✓ namespace member
export { fold } from './impl.ts' // ✓ re-exports, including `export *`
const fold = foldOperation // ✓ alias chainsThis is real lexical scope analysis, so shadowing gets decided rather than feared: a foldOperation declared inside some other function doesn't affect a call site where the module-scope one is in scope, and a call site where an inner binding really does shadow it resolves to that inner binding instead.
Parameter lists are read straight from the AST, so a default or destructured parameter counts positionally like any other — (c, f = undefined, s, r) is arity 4.
What genuinely cannot be resolved
These aren't just unread — they're genuinely undecidable — so parseman fails open (full capture) and reports build-arity-unconfirmed:
- a rest parameter —
(...args) => …declares an unbounded arity - a body that references
arguments - a reassigned binding — which function it names at parse time is not decidable
- a computed or dynamically constructed reducer
- an import that cannot be resolved or parsed
Declaring the arity yourself
Fail-open is safe, but it's permanent — the node pays for every tier on every match, forever. buildArity is the escape hatch.
node('Fold', body, fold, { buildArity: 1 })You're asserting the highest positional argument the reducer actually reads. Parseman then elides everything above it, exactly as if it had read the parameter list itself, and the diagnostic goes away. A declaration is authoritative — it wins over anything the source appears to say.
Declaring too low under-captures. The reducer gets an empty rawChildren / triviaLog or an absent state, rather than a wrong value. So count the parameters carefully.
