Why I Built a Tool to Trace Symbol Dependencies

Introduction

cover image

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.

reactivity graph

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:

  1. Local Variable Symbol: Used only within the current module.
  2. Named Export Symbol: Can be imported by other modules using a named import.

  3. Default Export Symbol: Can be imported by other modules using a default import.

Local Variable Symbols
// Create a Local Variable Symbol for oatchi
import { oatchi } from "pikmin";
 
// Create a Local Variable Symbol for kirby
const kirby = "pink";
 
// Create a Local Variable Symbol for hawk
function hawk() {
  // Won't create a Local Variable Symbol for color
  const color = "pink";
}
Named Export Symbols
// Create a Named Export Symbol for oatchi
export { oatchi } from "pikmin";
 
// Create a Local Variable Symbol for kirby
// Create a Named Export Symbol for kirby
export const kirby = "pink";
 
// Create a Local Variable Symbol for hawk
function hawk() {}
// Create a Named Export Symbol for hawk
export { hawk };
Default Export Symbols
// Create a Local Variable Symbol for waddle_dee
// Create a Default Export Symbol
export default function waddle_dee() {}

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:

  1. Lexical Containment: Symbol A and Symbol B are both Local Variable Symbols, and Symbol B is lexivally contained within Symbol A.

  2. 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.

  3. 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.

Lexical Containment
import { red_pikmin, yellow_pikmin, blue_pikmin } from "pikmin";
 
// "pikmins" depends on "red_pikmin", "yellow_pikmin", "blue_pikmin"
const pikmins = [red_pikmin, yellow_pikmin, blue_pikmin];
 
// "throw_pikmin" depends on nothing
function throw_pikmin(pikmin) {}
 
// "throw_pikmins" depends on "throw_pikmin" and "pikmins"
function throw_pikmins(count) {
  while (count > 0) {
    throw_pikmin(pikmins[count % pikmins.length]);
    count--;
  }
}
Import
import {
  // "red_min" is a Local Variable Symbol that depends on the
  // Named Export Symbol "red_pikmin" from the pikmin module
  red_pikmin as red_min,
 
  // "yellow_min" is a Local Variable Symbol that depends on the
  // Named Export Symbol "yellow_pikmin" from the pikmin module
  yellow_pikmin as yellow_min,
 
  // "blue_min" is a Local Variable Symbol that depends on the
  // Named Export Symbol "blue_pikmin" from the pikmin module
  blue_pikmin as blue_min,
} from "pikmin";
Export
// "oatchi" is a Named Export Symbol that depends on the
// Named Export Symbol "oatchi" from the pikmin module
export { oatchi } from "pikmin";
 
// "oatchi" is a Named Export Symbol that depends on the
// Default Export Symbol from the pikmin module
export { default as oatchi } from "pikmin";
 
// The Default Export Symbol of this module depends on the
// Default Export Symbol from the pikmin module
export { default } from "pikmin";
 
// "kirby" is a Named Export Symbol that depends on the
// Local Variable Symbol "kirby" within the same module
export const kirby = "pink";
 
// The Default Export Symbol of this module depends on the
// Local Variable Symbol "waddle_dee" within the same module
export default function waddle_dee() {}

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.

reactivity graph

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.

GOOD
// Use named exports:
export class Foo { ... }
BAD
// Do not use default exports:
export default class Foo { ... }

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.

Original
let a = 5;
{
    let a = 3;
}

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:

With SyntaxContext
let a#1 = 5;
{
    let a#2 = 3;
}

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:

struct MyVisitor {
    current_id: Option<Id>
}
 
impl Visit for MyVisitor {
    fn visit_ident(&mut self, n: &Ident) {
        if self.current_id.is_some() {
            // Create the edge for the dependency
        }
    }
 
    // Begin by visiting the module, as only module-scope symbols are tracked
    fn visit_module(&mut self, n: &Module) {
        // ...for each ident to track and its corresponding body
        self.current_id = Some(ident.to_id());
        body.visit_with(self);
        self.current_id = None;
        // ...
    }
}

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!