@devmedic/parser-typescript
v0.1.0
Published
Language adapter: TypeScript/JavaScript source to unified AST.
Readme
@devmedic/parser-typescript
The AST Engine: parsing, a single-pass visitor system with plugin hooks, and format-preserving printing for TypeScript, TSX, JavaScript, and JSX. No rules live here — this is parser infrastructure only.
import { parseFile, walk } from '@devmedic/parser-typescript';
const { program } = parseFile({ filename: 'app.tsx', source });
const state = { componentNames: [] };
walk(
program,
[
{
name: 'find-components',
visitor: {
FunctionDeclaration(path, state) {
if (/^[A-Z]/.test(path.node.id?.name ?? '')) {
state.componentNames.push(path.node.id.name);
}
},
},
},
],
{ state },
);The four tools, and why each one
| Tool | Role | Why it's a separate module |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Babel Parser (@babel/parser) | The default, fast parse path (parse.ts) — syntax only, no type-checking. | parseFile() is what traversal/analysis should use; it's what makes the engine fast. |
| Babel Traverse (@babel/traverse) | The visitor system (visitor.ts) — merges every registered plugin's visitor into one traversal via Babel's own visitors.merge. | This is the single-pass mechanism Babel itself uses to run many plugins together; no reason to reinvent it. |
| TypeScript Compiler API (typescript) | Opt-in semantic analysis (typescript-program.ts) — a real ts.Program/TypeChecker when a caller actually needs type information. | Full type-checking is meaningfully more expensive than a syntax-only parse; keeping it separate keeps the default path fast. |
| Recast | Format-preserving parse/print (printing.ts) — for the future Auto Fix Engine, where a fixer needs to reprint only what it changed. | Printing needs different parser settings (tokens: true) than analysis does; conflating the two would slow down the common case. |
Supported dialects
.ts/.mts/.cts, .tsx, .js/.mjs/.cjs, .jsx — inferred from the
filename, or pass language explicitly. .ts never enables JSX syntax
(it isn't legal there); everything else does.
The visitor system / plugin hooks
An AstPlugin is just { name, visitor } — a named Babel visitor. The
engine doesn't know or care what a plugin does; mergeAstPlugins/walk only
merge and run them. A future rule pack is nothing more than a plugin that
happens to report findings — that's deliberately out of scope here.
Node-visit counting (for benchmarking)
beginNodeVisitCount() / endNodeVisitCount() arm and read an opt-in,
global counter of every node any walk() call visits in between — zero
cost when not armed (no extra plugin is merged in). This exists because
walk() is the one traversal choke point every rule goes through
(directly or via a thin wrapper); it's how @devmedic/rule-benchmark
measures real "AST nodes visited" per rule without every rule needing its
own instrumentation.
import { beginNodeVisitCount, endNodeVisitCount } from '@devmedic/parser-typescript';
beginNodeVisitCount();
rule.analyze(context); // internally calls walk() one or more times
const nodesVisited = endNodeVisitCount();Being global (not per-walk()-call) state, only one measurement should be
in flight at a time — concurrent analyze() calls while armed would mix
their counts.
Performance
- Parallel-in-spirit, not parallel-in-threads: within a single file, the fast path never does more work than one syntax parse + one merged traversal.
- Single-pass traversal: N plugins still walk the tree once, via
@babel/traverse's own multi-visitor merge — not N tree-walks. - Type-checking is opt-in:
createTypeScriptProgramis a separate function nothing else calls implicitly. - Printing is separate from analysis:
parseFilenever carries thetokens: trueoverheadparseForPrintingneeds for format preservation.
A real bug this caught
The first build of walk() passed all 33 unit tests under vitest but threw
TypeError: traverse is not a function when actually run via node
dist/index.js. Vitest's esbuild-based transform auto-unwraps
@babel/traverse's CJS default export; plain Node's native ESM/CJS interop
does not — it hands you the whole module.exports object instead, with the
real function nested one level deeper at .default. visitor.ts now
resolves this defensively at runtime (works either way), and
runtime-interop.test.ts runs the actual compiled dist/ output in a real
node subprocess so this class of bug can't silently come back.
Depends on
@devmedic/core@babel/parser,@babel/traverse,@babel/typestypescriptrecast
