npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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's fix-bridge.ts, which re-parses every fixable issue's file to call the originating rule's real fix() — 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-parse

Cache 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).

  • getOrParse is fully synchronous — parsing itself never awaits — 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 in cache.test.ts — three concurrent getOrParseFile calls for the same file produce exactly one real readFile call 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

  • AstCachegetOrParse(filePath, source, options?), getOrParseFile(filePath, options?), invalidate(filePath), clear(), stats (hits/misses/evictions/size).
  • extractImports(ast) / extractExports(ast) / extractSymbols(ast) — the pure derivation functions AstCache uses internally; also usable standalone.
  • hashContent(source) — the same sha256/hex convention as @devmedic/rule-engine's RuleResultCache.
  • 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-typescriptparseFile (with its additive tokens: true option, so tokens are retained rather than discarded) and NormalizedAst.
  • @babel/types — node-type guards for the shallow (not deep-traversal) import/export/symbol extraction.