DEV Community

Your feature-usage scanner doesn't know Vue, Svelte, or Astro exist. Here's how we fixed that without touching its core.

If a static analyzer only walks .ts /.tsx /.js /.jsx , every other file type isn't scanned badly - it's not scanned at all. A .vue component, a .svelte widget, an .astro page: none of them exist to the tool. Not "low confidence." Not "partial support." Invisible, the same way an empty search result looks identical whether there's genuinely nothing to find or the search just never looked in the right place. That's exactly the gap Eventra's CLI had. It scans a codebase and tells you which tracked features are actually used - the whole pitch is "stop guessing which code is dead." Except if your team ships a Vue admin panel, a Svelte checkout widget, and an Astro marketing site around the same core app (which, if you've worked on more than one team, you've probably seen - nobody plans a multi-framework stack, it just accretes), the CLI would silently skip all three, report a clean scan, and never mention that it hadn't actually looked. The exact failure mode the product exists to prevent, happening inside the product itself. We'd already closed this gap once, for Vue. This month we closed it for Svelte and Astro too, and the interesting part isn't the frameworks - it's that adding two more meant touching exactly zero lines of the CLI's core analysis engine. The trick: don't teach the core anything The CLI's core is a TypeScript-compiler-API engine: it builds a real program, walks real ASTs, resolves real symbols across files, and figures out which .track("event_name") calls are statically reachable. It is, deliberately, framework-agnostic - it doesn't know what Vue is, and it shouldn't have to. So instead of teaching the core about .vue /.svelte /.astro , each framework gets a small, separate plugin whose only job is a translation: take the framework file, hand back one virtual TypeScript module. A Vue Checkout.vue becomes Checkout.vue.ts . A Svelte Cart.svelte becomes Cart.svelte.ts . The core never sees the original file - it sees TypeScript, because by the time it looks, that's what's actually there. export interface CliPluginTransformResult { readonly modules: readonly { readonly path: string; readonly content: string }[]; } That's most of the contract. A plugin declares which globs it wants (**/.svelte ), a match() to claim files, and a transform() that returns virtual modules. The core registers the glob, calls transform() on matching files, and feeds the result into the exact same incremental TypeScript program every .ts file goes through. No if (isVueFile) branch anywhere in the resolver, the propagation engine, or the wrapper detector. They don't know these files were ever anything but TypeScript. The part that has to be a real compiler, not a regex The first version of the Vue plugin - before this month - parsed .vue files with two regexes: one to grab content, one to grab event="..." attributes out of . It worked on every fixture, because every fixture was written to be exactly what that regex expected. It fell over on real code: dynamic bindings vanished silently, HTML comments got matched as if they were live markup, and + got concatenated with zero understanding of how Vue actually merges them. The fix - and the standard we held Svelte and Astro to from the start - is to parse with the framework's own real compiler. Not a library that looks similar. The actual thing: - Vue: @vue/compiler-sfc , the same package Vite and Nuxt build on. - Svelte: svelte/compiler 'sparse() , in its documented legacy-AST mode (modern: false ) - stable, tooling-oriented, and it gives youIfBlock /EachBlock /AwaitBlock as real distinct node types instead of text to pattern-match. - Astro: @astrojs/compiler , the WASM parser the Astro toolchain itself uses, which hands back a typed AST withelement /component /expression nodes. Three different compilers means three different AST shapes, and the differences turned out to matter more than expected. Svelte's legacy node has no attributes field at all - recovering lang="ts" meant regex-matching the raw opening-tag text, not asking the AST for it. Astro's attribute nodes carry a kind (quoted / expression / shorthand / template-literal / โ€ฆ) and hand you the already-extracted expression source directly; Svelte makes you slice the original source string yourself using a MustacheTag 's expression.start /.end offsets. And Astro's JSX shorthand - , sugar for event={event} - leaves the attribute's .value empty for that specific kind , so the plugin has to notice the shorthand case and substitute the attribute name back in as the expression. None of that is guessable from documentation; it's the kind of thing you only find by parsing real snippets and printing the actual AST. One convention, three parsers Eventra's templates use a plain attribute, event="checkout.cta" , to mark a tracked interaction directly in markup - no wrapper function needed for the simple case. It needed to mean the same thing in all three frameworks, expressed in whatever each one's own binding syntax actually is: Literal or dynamic, each plugin turns every one of these into a synthetic function call inside the virtual module - eventra_svelte_template_event("checkout.cta") for a literal, eventra_svelte_template_event(computedName) for a dynamic one, unquoted. Because it's a real call sitting in the same module scope as the actual /frontmatter, a dynamic binding that happens to reference a real constant defined a few lines up gets resolved through precisely the same symbol-resolution path a plain tracker.track(someVariable) would use in any .ts file. If it can't be resolved - say, an {#each} loop variable - it's reported as a dynamic occurrence instead of silently vanishing, which was the entire bug that made the first Vue regex implementation dangerous. The plugin declares this callee as a staticSink - { id, callee, eventNameArgumentIndex } - and the core converts that into its own internal sink detector. The plugin never imports anything from the CLI. The CLI never imports anything framework-specific. They meet in the middle through a plain data contract. Trusting a plugin without trusting arbitrary code eventra.json lists which plugins to load, and it's a file that gets committed to git - which means it's PR-editable by anyone who can open a pull request. If the loader did import(anyStringFromThatFile) , a one-line JSON edit in a PR would be arbitrary code execution on every machine that ever runs eventra sync . The fix already existed for the Vue plugin and needed zero changes to cover the two new ones: only packages matching @eventra_dev/cli-plugin- - an npm scope only the maintainers can publish to - are ever dynamically imported. Anything else gets skipped with a warning. Adding Svelte and Astro meant the allowlist regex, written generically the first time, just worked; the only thing that proved it was a test asserting cli-plugin-svelte matched it, written before the Svelte plugin itself existed. Proving it actually works, not just that the unit tests pass Unit tests for a plugin are cheap to fool - you write the fixture, you write the assertion, and if you got the mental model wrong in the same way twice, they both pass anyway. So after the plugins were built and green, we did the thing a real npm consumer would do: packed all four packages (SDK, CLI, and both new plugins) into actual .tgz tarballs with npm pack , installed them into a throwaway project exactly the way npm install would from the registry, and ran the real compiled eventra binary against a .svelte and an .astro fixture, each with one direct SDK call and one template event="..." binding. { "events": [ "astro.checkout.cta", "astro.checkout.started", "svelte.checkout.cta", "svelte.checkout.started" ] } Four events in, four events out, through the actual dynamic-import loading path, not a mocked one. That's the difference between "the code that implements the feature is correct" and "the feature works when a stranger installs it," and it's cheap enough to do that there's no excuse not to. What "done" actually required Each plugin ended up with roughly twenty tests and a hard 100% statement/branch/function/line coverage gate, and the coverage gate is what surfaced most of the interesting edge cases, not the other way around. A branch that never gets hit is either a real gap or a genuinely impossible case - and it's worth knowing which before shipping either way. A few turned out to be the second kind: svelte/compiler 's attribute grammar guarantees a single-value attribute is either plain text or one expression, never a third thing, so the "neither" branch in that check is unreachable by construction, not untested by neglect. Those got a one-line comment explaining why, instead of a fabricated test pretending to exercise something the parser's own grammar rules out. The rest - a boolean-shorthand event attribute with no value, an interpolated event="a-{b}" that's neither a clean literal nor a clean expression, a context="module" script block that's present but empty - were real gaps, and each one is a case someone's actual codebase would eventually hit. Try it pnpm add -D @eventra_dev/cli-plugin-svelte @eventra_dev/eventra-cli # or pnpm add -D @eventra_dev/cli-plugin-astro @eventra_dev/eventra-cli { "plugins": ["@eventra_dev/cli-plugin-svelte", "@eventra_dev/cli-plugin-astro"] } Same config shape as the Vue plugin, and you can list all three at once if your codebase actually is that mixed. Svelte plugin on npm ยท Astro plugin on npm ยท Vue plugin on npm ยท docs. Angular's next. If your own tool has a hard-coded list of "supported" things, it's worth asking whether the boundary is actually load-bearing, or whether it's just the first three cases someone happened to write down. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.