@devmedic/ast-cache-engine
v0.1.0
Published
Parses each file exactly once and shares the result — AST, tokens, comments, imports, exports, and top-level symbols — across every rule and consumer, content-hash-invalidated and memory-bounded (LRU).
Readme
@devmedic/ast-cache-engine
Fixes "every rule reparses the same file": parses each file exactly once and shares the result — AST, tokens, comments, imports, exports, and top-level symbols — across every rule and every consumer that asks for it, invalidated automatically when content changes, bounded to a fixed number of files (LRU eviction).
import { AstCache } from '@devmedic/ast-cache-engine';
const cache = new AstCache();
// the hot path — caller already has `source` in hand
const entry = cache.getOrParse(filePath, source);
entry.ast; // NormalizedAst — same shape @devmedic/parser-typescript#parseFile returns
entry.tokens; // Babel's token list
entry.comments; // Babel's comment list
entry.imports; // ImportBinding[] — every top-level import, source + local name + kind
entry.exports; // ExportBinding[] — every top-level export, local + exported name + kind
entry.symbols; // SymbolBinding[] — every top-level declared name
// calling getOrParse again with the SAME source is a hit — the parser never runs
cache.getOrParse(filePath, source) === entry; // true
// a convenience for a caller that hasn't read the file itself yet
const fromDisk = await cache.getOrParseFile(filePath);Where this plugs in
@devmedic/rule-engine#RuleEngine uses an AstCache internally by
default — every rule in an analyze() call already shared one parse per
file before this package existed; the real, previously-unfixed waste was
across separate consumers analyzing the same files:
RuleEngine.analyze()'s own per-file loop.apps/cli'sfix-bridge.ts, which re-parses every fixable issue's file to call the originating rule's realfix()— it used to keep its own separate, unshared memoization Map.- Anything else that constructs its own
RuleAnalysisContext(test helpers,@devmedic/rule-benchmark, a future LSP server).
apps/cli's analysis-pipeline.ts constructs one AstCache, passes it
to RuleEngine via new RuleEngine({ astCache }), and exposes it on the
pipeline's result so commands/fix.ts can pass the exact same instance
into attachFixEdits — the fix pass is now a cache hit for every file the
main analysis pass already parsed, not a second real parse.
const astCache = new AstCache();
const engine = new RuleEngine({ astCache });
// ...
await attachFixEdits(fixableIssues, rules, { astCache }); // same instance — no double-parseCache invalidation
Keyed by filePath, with the entry's own stored SHA-256 content hash
compared against the incoming content on every lookup — a hash mismatch
is a miss, not a hit, so content that changed is always re-parsed
automatically, the same convention @devmedic/rule-engine's
RuleResultCache already uses (duplicated here as a 3-line hashContent,
not imported, since rule-engine depends on this package and importing
back would cycle). A caller that already knows a file changed (or was
deleted) can also call invalidate(filePath) explicitly, ahead of the
next getOrParse call, and clear() empties everything.
Memory efficiency
Bounded to maxEntries distinct files (default 500, DEFAULT_MAX_ENTRIES)
via LRU eviction — a Map naturally iterates in insertion order, so a hit
deletes-and-re-sets its key to bump it to the most-recently-used end, and
inserting past the limit evicts from the other end. stats.evictions
tracks how many entries this has actually discarded, so a caller can tell
whether their working set is bigger than the configured budget.
Thread safety
"Thread safe" here means safe under Node's single-threaded, event-loop
concurrency — many concurrent analyze() calls, rules, and files at
once — not literal OS threads/worker_threads (nothing in this
codebase uses those, and true shared-memory thread safety across
worker_threads is a materially different, much larger problem this
package doesn't attempt to solve).
getOrParseis fully synchronous — parsing itself neverawaits — so it can never be interrupted mid-operation by another concurrent call. Two "concurrent" async callers calling it are, from the cache's perspective, just two ordinary sequential synchronous calls.getOrParseFile's file read is the only genuinely async step, and it's single-flighted: a second concurrent call for a file already being read/parsed awaits the exact same in-flight promise rather than starting its own read. Verified directly incache.test.ts— three concurrentgetOrParseFilecalls for the same file produce exactly one realreadFilecall and one cache miss.
Before/after
src/ast-cache.bench.ts (pnpm bench, Vitest's bench()) quantifies the
literal problem statement: the same unchanged file's AST asked for 50
times in a row — simulating 50 independent consumers (rules, a fix pass,
a benchmark tool) each parsing it themselves versus each sharing one cache.
| Benchmark | Result |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| parseFile() × 50 (no cache) vs. AstCache.getOrParse() × 50 | cached is ~6.2x faster (0.96ms vs. 0.15ms mean per 50-call batch) — 49 of the 50 calls never touch the parser at all |
Numbers are from one real run (pnpm --filter @devmedic/ast-cache-engine run bench) and will vary by machine — re-run locally for your own baseline. The gap widens with file size and with how many separate consumers actually exist in a real pipeline (this benchmark's REPEAT = 50 is a stand-in for "however many rules + fix pass + tooling ask for this file").
API
AstCache—getOrParse(filePath, source, options?),getOrParseFile(filePath, options?),invalidate(filePath),clear(),stats(hits/misses/evictions/size).extractImports(ast)/extractExports(ast)/extractSymbols(ast)— the pure derivation functionsAstCacheuses internally; also usable standalone.hashContent(source)— the same sha256/hex convention as@devmedic/rule-engine'sRuleResultCache.DEFAULT_MAX_ENTRIES(500).
What "symbols" means here
Every name declared at a file's top level — functions, classes, variables
(including one level of { a, b: c }/[a, b] destructuring), and
imported bindings. This is not a resolved, type-checked symbol table —
that's @devmedic/parser-typescript#createTypeScriptProgram's job, and
meaningfully more expensive (a real ts.Program + type checker). A
TypeScript-only declaration (interface, type alias, enum) is
deliberately not tracked as an export or symbol — those are compile-time
constructs, not runtime bindings.
Depends on
@devmedic/parser-typescript—parseFile(with its additivetokens: trueoption, so tokens are retained rather than discarded) andNormalizedAst.@babel/types— node-type guards for the shallow (not deep-traversal) import/export/symbol extraction.
