Building Eventra · Part 5 of 104 min read

Why Eventra's CLI reads your code with the TypeScript compiler, and the month it took to get there

  • buildinpublic
  • typescript
  • cli
  • staticanalysis
const log = (n) => eventra.track(n)log("checkout.started")log(EVENTS.invite)log("billing.upgraded")log("export.csv")syncFEATURE CATALOGcheckout.startedinvite.sentbilling.upgradedexport.csv

The CLI is the most technically demanding part of Eventra, and the part that took the longest to get right. This post covers how it went from "create cli" to something that understands wrappers, re-exports and barrel files.

Why there's a CLI at all

I wrote earlier that the dead feature idea only works if you have a complete list of features, and that the list should come from the code and not from a person typing it in. Everything about the CLI follows from that one sentence.

The job is simple to describe. Look through a project, find every place where a feature is tracked (a call like track("checkout.started")), and send that list to Eventra. Then Eventra can compare "what exists in the code" with "what actually happened in production", and the difference is exactly what the product is for. A feature that is in the code and has never produced an event is the clearest signal in the whole system.

The first version

On April 10 there is a commit called "feat: create cli" and one called "feat: public cli", the same day. The first version handled simple code.

Before the CLI, the user had to tell Eventra about their own code: whether they used wrappers around track(), and how. If a value was passed indirectly, the terminal printed an explanation of what a given prop meant and asked the user to resolve it. Asking users to describe their own codebase to a tool that is supposed to read it defeats the purpose, so I replaced that flow with a real CLI.

Three days later I was already adding Vue, Astro and Svelte parsers, support for function wrappers (when you wrap track() in your own helper) and dynamic values. That was April 13, and the commit messages show it: "cli support dynamic variables", "support array from dynamic", "hide duplicate".

Where simple stops working

Real code isn't a list of track("name") calls. In a real codebase:

  • People wrap the tracker in a helper, and the helper gets called from many places with different names.
  • The name is a constant defined in another file and imported through several layers.
  • The name is a variable, an array, an object property or an enum member.

Every one of those is a point where a text search stops finding the right answer. You can try to handle them with patterns, and I did for a while. Around April 25 the commits change character: "cli engine", then on April 28 "cli compiler".

That is the turn. Instead of matching text I started using the TypeScript compiler itself to work out what a name actually refers to. The compiler already knows how TypeScript resolves things, so I do not have to reimplement any of it, and it does not get it subtly wrong.

What drove the change was running the CLI on my own large projects. It kept missing features that were plainly in the code. A tool that misses a feature breaks its one promise, which is a complete list of what is in your code. Patching one pattern at a time has no end, while the compiler already knows how TypeScript resolves things.

What the month looked like

Between April 10 and May 6 there are 59 commits in the CLI package. In outline:

  • April 13: framework parsers, wrappers, dynamic values.
  • April 23-24: aliases, a check command, auto-detect, watch mode.
  • April 28: the compiler-based engine.
  • April 30: more value types, error handling, watch mode.
  • May 4-5: a universal parser, a memory leak fix, and a document describing the architecture.

Watch mode alone shows up in eight commits in that window. Keeping a long-running process in sync with files that change under it is a different kind of problem from analysing a project once.

The version numbers tell the same story. The package started at 0.0.1 on April 10 and went through about seventy releases before 1.0.0 on May 26. That's more than one release a day on average, because every run on a real project showed me something else it hadn't found, and I'd fix it and ship it.

What the CLI doesn't do

I want to be clear about the limits, because a tool that claims it sees everything is one you can't trust.

It only analyses TypeScript and JavaScript. Frameworks with their own template syntax (Vue, Svelte, Astro, Angular) are handled through separate plugins, which is the subject of the post on the core and plugins. The CLI documentation lists the supported setups. If a name is built at runtime from something the compiler can't resolve, like a value fetched from a server, the CLI reports it as dynamic and doesn't pretend to know it.

That last part is deliberate. When the tool isn't sure, it says so. The alternative is silently skipping the unclear cases, and a feature scanner that silently skips things is the exact failure the product exists to prevent.