Small scoped parsers, not a rewrite
The diagnosis
The preprocess stage rewrites TJS-specific syntax into JavaScript before acorn ever sees
it. It does this with roughly seventeen transforms built from regexes and character scans.
That worked while the rewrites were local. It has stopped working, and the evidence is not a
hunch — it is the bug list from a single day (2026-09-02):
| bug | the mechanism that failed |
|---|---|
new E({x:1}) in a class field read as a method head |
regex plus a single-character look-back |
| a destructuring rename rewritten as a dictionary member | ^(\w+)\s*:\s*(.+)$ — cannot tell a rename from an example |
| the polymorphic merge grouping across scopes | a regex sweep over the whole file with no notion of scope |
| a generic type-param list split inside its own braces | .split(','), not depth-aware |
a generic default containing => silently dropped |
.split('='), not depth-aware |
<T = () => 0> unparseable |
the <…> scan reads =>'s > as the closing bracket |
a ternary's : consumed as an annotation |
no way to know which kind of : this is |
:! return example runs into the body |
no way to know where an expression ends |
Every one is the same shape: a scanner that knows characters but not grammar. And they reduce to two questions that no amount of masking can answer:
- Where does this expression end?
- What does this
:mean here?
The layer that is missing
The repo already has a lexical layer, and it works. src/strip-comments.ts owns literals,
comments, balanced braces (matchingBrace) and depth-aware splitting (splitTopLevel).
Consolidating on it closed the whole literal-blindness family — six live bugs across eight
files at 0.13.0-beta.1 — and src/lang/literal-blindness.test.ts keeps it closed.
What is missing is the layer above: syntactic primitives, built on the lexical ones and consumed by the transforms.
transforms (exist) — the ~17 preprocess rewrites
syntactic primitives (NEW) — expression extent, colon disambiguation
lexical primitives (exist) — strip-comments.ts
This is not a rewrite. TS → TJS already runs on the TypeScript AST; TJS → JS already ends at acorn. Only the middle is hand-rolled, and only the middle needs to change.
Two primitives cover most of it
1. Expression extent
Given a start offset, where does this expression end?
Respecting literals, nesting, and — the case that keeps biting — =>. It resolves:
<T = () => 0>: the extractor counts brackets where it should consume an expression.function Array$(value):! { value: null } {: where the return example ends and the body begins.topLevelDefaultEqinparser-transforms.ts, written by hand on 2026-09-02, which is a degenerate case of this and should be deleted when the primitive lands.
2. Colon disambiguation
Is this
:a ternary's, a type annotation's, or an object key's?
It needs little more than "have I seen an unmatched ? at this depth". It resolves the
ternary/arrow mangling in RpcServer.ts and commandExecutor.ts — and it is the same
question behind the destructuring-rename bug, which was fixed by guessing from the shape of
the value because nothing could answer it directly.
Both are a few dozen lines, independently testable, and not throwaway: an expression-extent scanner is a Pratt-parser fragment, and a CST builder needs exactly these. The incremental work is a down payment on the rewrite, not scaffolding for it.
The discipline that decides whether this works
The failure mode is proliferation — several half-parsers that disagree. This is not
hypothetical. It is how the current situation arose, recorded in
literal-blindness.test.ts: "each pass had hand-rolled its own partial tracking."
And it nearly recurred twice on the day this note was written:
topLevelDefaultEqis a private depth scanner, written instead of extending the shared one.- The
messageTextfix landed inbatteries/and left six copies untouched indemo/src/capabilities.ts, which is what broke four LLM examples.
splitTopLevel's own docstring records the subtler version: it was extracted to stop a fourth
copy of a scan, and then two callers immediately wrote byte-identical wrappers around it — "a
consolidation that goes three-to-one and grows two wrappers has gone three-to-three."
So the rule is not "write small parsers". It is:
One implementation per concept, in a shared leaf, in the shape callers actually reach for, with a guardrail test that fails when a second copy appears.
That is what made the lexical layer succeed, and it is the only thing that will make this layer succeed.
The measurable payoff
preprocess is quadratic in file size — a clean 4× per doubling, measured: 16KB 182ms, 32KB
642ms, 64KB 2.5s, 128KB 10.2s, 256KB 39.4s. Four files in the compat corpus are skipped
because of it, the largest extrapolating to ~37 minutes. It has never been localised to any
single transform, and the seventeen source = transform(source) steps sum to ~15ms — which is
what you would expect when the cost is the shape rather than one pass.
Seventeen transforms each re-scanning the source is the shape. Shared primitives that compute positions once are the natural place for that cost to collapse. That makes this the first parser work in this repo with a benchmark attached rather than only a cleaner design — and it should be measured, not assumed.
Sequencing
convertvalidates its own output. Orthogonal — reporting, not parsing — and it turns every remaining parse failure from a silent bad artifact into a named error. Todaytjs convert --emit-tjsexits 0 and writes a.tjsthattjs checkthen rejects. The converter owns both halves and can check itself for free.- Expression extent, taking
<T = () => 0>andSchema.tswith it — two of the seven remaining corpus failures, and the proof the approach pays. - Colon disambiguation, taking
RpcServer.tsandcommandExecutor.ts. - Reassess. If the remaining failures still reduce to grammar questions, the primitives have earned a CST; if they do not, they were cheap and the transforms are cleaner anyway.
Status
Proposed, not started. Trigger condition met: PLAN.md's parser reassessment says revisit
if edge cases increase, and the seven remaining compat-corpus failures reduce to roughly six
bugs, essentially all of this class.