Why I Built a Tool to Trace Symbol Dependencies
Introduction
If you’re both a gamer and a collector, you’ve likely enjoyed the journey of finding all the Koroks across Hyrule while playing Tears of the Kingdom. Although collecting all 1,000 Korok Seeds is nearly impossible, the adventure is undeniably fun.
In contrast, making changes or searching through large-scale frontend codebases is anything but enjoyable. Every missed detail could lead to bugs or inconsistencies in the production environment, so identifying all the impacted pages and understanding how they’re affected is crucial. After all, people count on us, right?
Some might argue that these issues can be mitigated through visual comparison testing or more comprehensive test coverage. While that certainly helps, it doesn’t cover all scenarios. Often, the Design team needs to know the impact of changes to components in the design system, or the UX writing team needs to understand the effects of modifications to i18n translations.
Let's Break Down The Problem
To trace dependencies, we need to build a graph. This graph should be capable of representing dependencies for symbols within the local module, across different modules, and even across multiple projects.
Symbol's Definition
A symbol, the smallest unit in this problem, can be treated as a vertex in the directed acyclic graph (DAG). Symbols come in three variants:
-
Local Variable Symbol: Used only within the current module.
-
Named Export Symbol: Can be imported by other modules using a named import.
-
Default Export Symbol: Can be imported by other modules using a default import.
Symbol's Dependency
Now that we have the symbols (vertices), it’s time to define the symbol dependencies (edges). There are three cases where Symbol A is said to depend on Symbol B:
-
Lexical Containment: Symbol A and Symbol B are both Local Variable Symbols, and Symbol B is lexivally contained within Symbol A.
-
Import: Symbol A is a Local Variable Symbol imported from another module's Symbol B, Symbol B could be either a Named Export Symbol or a Default Export Symbol.
-
Export: Symbol A is either a Named Export Symbol or a Default Export Symbol and it exports Symbol B, Symbol B can be any variant.
Some Interesting Parts While Implementing
Parsing in Topological Order
Since the dependency graph is constructed only once for each version (or release), I decided to parse modules in their topological order. If you’re building a tool that needs to re-run on every save, this approach might not be suitable—parsing thousands of modules this way could be too slow.
Why can't we parse modules independently and in any order? If we could, topological ordering wouldn't matter. The reason lies in JavaScript's wildcard re-export
and import as namespace
. There's no way to know the Named Export Symbols if the imported module hasn’t been parsed yet.
The re-export chain can extend indefinitely, and parsing modules in the wrong order can break the chain. To handle this, I implemented a scheduler that feeds the parser with modules in topological order. This way, we can set up a thread pool for parsing and feed them efficiently from the scheduler. It’s just the T
in the SRTBOT framework for dynamic programming.
For even better performance, especially if you're building a parse-on-save tool, this problem could also be solved using async/await in Rust. I’m not using it yet—I’ve only just entered the "Future" of Rust 😂. If you prefer building with JavaScript, wyw-in-js
's approach using Promise
and Generator
could be helpful.
Don't Use Default Export
Avoid using default exports whenever possible, especially anonymous ones. By sticking to named exports, you ensure that all imports follow a consistent, predictable pattern. In this tool, eliminating default exports simplifies things—we don't need to create an anonymous Local Variable Symbol for an anonymous default export.
Google’s TypeScript style guide also explicitly discourages default exports.
How JavaScript Scope is Handled in SWC
In the code below, there are two variables with the symbol a
.
Other compilers typically uses type like Scope
, and store them nested, but in rust, type like Scope
requires [Arc<Mutex>]
so swc uses different approach. Instead of passing scopes, swc annotates two variables with different tag, which is named SyntaxContext
. The notation for the syntax context is #n
where n
is a number. For example, foo#1
In the example above, after applying the resolver pass, the code becomes:
This shows how the two variables a
are uniquely identified by their syntax context.
Reference: SWC Ident Struct Documentation
Making the Handlers Stateless
When constructing edges for symbol dependencies, it's essential to know the current symbol to handle "Lexical Containment" cases properly. Here’s an example of a stateless visitor in SWC:
By keeping the visitor stateless and tracking the current identifier (current_id
), we can effectively manage symbol dependencies in a clean and maintainable way.
Reference: SWC Plugin Cheatsheet
A Practical Use Case: Searching i18n Keys
This feature request came from our UX writing team, who are currently organizing the translations for our products. With nearly 10,000 i18n keys spread across multiple projects, manually searching for and managing these keys is becoming increasingly unrealistic. They asked if I could extend this tool to help ease their workload, and it’s clear this would be a great opportunity to streamline the process.
Implementing this feature is straightforward. I just need to create two mappings: one that links i18n keys to symbols, and another that connects symbols to routes. Once these mappings are in place, finding results becomes as simple as traversing the graph along its edges. The best part? It works both ways: they can find all i18n keys used on a specific page, or all the pages where a translation appears.
As long as these mappings are 100% accurate, the search results would be flawless. Unfortunately, I can’t guarantee perfect accuracy in this case, so the results won’t always be exact. I won’t dive deeper into the specifics here, as that would take us too far off track. However, this experience has shifted my perspective on what a maintainable codebase looks like. I plan to explore this topic in another post later.
Summary
Link to the project: https://github.com/wtlin1228/dt
I started this project as a personal one, driven by the constant requests from designers to identify the impact of changing shared components. It also became a great way to practice my Rust skills and dive deeper into SWC, one of the modern JavaScript compilers. I’m thrilled that others have started seeing the potential in this tool, as it’s evolved beyond just solving my own pain points.
While there's still much to explore and improve, the journey has been rewarding. I hope this tool can continue to help developers and teams tackle dependency tracking in large-scale JavaScript projects, making their lives a little easier. There’s more to come, so stay tuned for future updates and enhancements!