Dependency Tracker is a Rust-based tool designed to trace symbol dependencies in JavaScript and TypeScript across module boundaries. It's especially useful for large projects where tasks like refactoring a shared UI library or updating i18n translation keys can become complex and time-consuming.
If you're only interested in tracking module-level dependencies, you might prefer using dependency-cruiser. I will also use it for projects that are well-organized, where understanding the relationships between modules (or packages) is enough. However, if you're looking for a tool with more fine-grained tracking at the symbol level, Dependency Tracker could be just what you need.
Currently, this tool is used internally in my own projects, so some assumptions may not align with your project needs. These assumptions include:
- no invalid imports
- no circular dependency
- no string literal exports
export { myFunction as "my-function" }; - no string literal imports
import { "string name" as alias } from "module-name";
See also Why I Built a Tool to Trace Symbol Dependencies
A symbol can represent:
- A module-level local variable
- A named export
- A default export
There is an edge from v1 to v2 if:
- Both
v1andv2are local variable symbols, andv2is lexically contained withinv1 v2is imported from another module asv1v1exports or re-exportsv2
// assume a module named exports 'Foo', 'Bar'
// then this wildcard re-export statement gets turned into
// export { Foo, Bar } from 'a'
export * from "a";Expanding wildcard re-export statements simplifies parallel module parsing. Without expansion, any module containing such a statement would need to be parsed first, creating a bottleneck. Additionally, it's common in JavaScript projects to have numerous index.js files dedicated to re-exports, making this approach even more important for efficient processing.
// one named export symbol + one local variable symbol named "A"
export const A = "A";
// one default export symbol + one local variable symbol named "B"
export default B = "B";Duplicating local variable symbols when they are exported or re-exported as default will increase the size of the serialized output, but it avoids introducing new edge rules. This is another trade-off to consider.
// one default export symbol + one anonymous local variable symbol
export default () => {}
export default function () {}
export default function* () {}
export default class {}This decision involves a trade-off: either introduce a new rule for edges or create a local variable symbol with a unique, impossible-to-collide name for the anonymous default export.
import * as A from "a";
// B depends on all named export symbols from A, not just `A.b.c.d.e`
function B() {
return A.b.c.d.e;
}This presents a trade-off: either create a more fine-grained dependency graph or keep it simpler for now.
Imagine an application with two routes: /home and /account.
Here's what the dependencies for the home page might look like:
And here's the account page:
This application can be represented as a Directed Acyclic Graph (DAG), where the edges represent dependencies between symbols. For example, A -> B means that Symbol A depends on Symbol B. In this context, symbols are module-scoped identifiers—for instance, given const Foo = 'foo', Foo would be a symbol.
For the design team, the key question might be: How many pages will be affected if we change this component?
For the UX writing team, they might wonder: How many pages will be affected if we update these translation keys?
In smaller applications, these questions are easy to answer. But as the project grows, answering them becomes much more time-consuming.
By generating a DAG of all the symbols in your application, you can create a "super node" and use Dependency Tracker to trace all the dependent symbols (Adj+ from the super node). Then, if any symbol in the path is linked to a specific URL, you can collect those URLs and paths to map out the impact.
flowchart TD
source(JS/TS Project) --> scheduler(Scheduler)
scheduler(Scheduler) --> parser1(Parser)
scheduler(Scheduler) --> parser2(Parser)
scheduler(Scheduler) --> parser3(Parser)
parser1(Parser) --> depend_on_graph(Depend-On Graph)
parser2(Parser) --> depend_on_graph(Depend-On Graph)
parser3(Parser) --> depend_on_graph(Depend-On Graph)
depend_on_graph(Depend-On Graph) --> used_by_graph(Used-By Graph)
used_by_graph(Used-By Graph) -- cache --> dependency_tracker(Dependency Tracker)
Path Resolverresolves the import pathsSchedulermanages the parsing order for modulesParsers extract imports, exports, symbols and determine their dependencyDepend-On Graphaggregates all the parsed modulesUsed-By Graphreverses the edges fromDepend-on GraphDependency Trackertracks the symbol by traversing theUsed-By Graph
reexport all the library crates:
- database
- graph
- i18n
- parser
- path_resolver
- portable
- route
- scheduler
- tracker
Database defines the models using in the cli and api_server crate.
DependOnGraph takes the SymbolDependency one by one to construct a DAG. You have to add the SymbolDependency by topological order so that DependOnGraph can handle the wildcard import and export for you.
let mut depend_on_graph = DependOnGraph::new("<project_root>");
depend_on_graph.add_symbol_dependency(symbol_dependency_1).unwrap();
depend_on_graph.add_symbol_dependency(symbol_dependency_2).unwrap();UsedByGraph takes a DependOnGraph instance and reverse the edges. UsedByGraph is serializable so you can construct once and distribute it to other users, it also useful if you want to have multiple UsedByGraph for different versions of your applications.
let used_by_graph = UsedByGraph::from(&depend_on_graph);
let serialized = used_by_graph.export().unwrap();
let used_by_graph = UsedByGraph::import(serialized).unwrap();collect_all_translation_usage takes the project root and output the usage of i18n keys.
let i18n_usages = collect_all_translation_usage("<project_root>").unwrap();Parser provides two ways to construct the AST.
let module_ast_from_path = Input::Path("<module_path>").get_module_ast().unwrap();
let module_ast_from_input = Input::Code("<inline_code>").get_module_ast().unwrap();PathResolver provides a very simple resolve_path() to resolve the import path based on this order:
<import_src>/index.js<import_src>/index.ts<import_src>.ts<import_src>.tsx<import_src>.js<import_src>.jsx
let path_resolver = PathResolver::new("<project_root>");
let import_module_path = path_resolver.resolve_path("<current_module_path>", "<import_src>").unwrap();Portable defines the structure of the portable files.
let portable = Portable::new(
project_root,
i18n_to_symbol,
symbol_to_route,
used_by_graph
);
let serialized = portable.export().unwrap();
let portable = Portable::import(serialized).unwrap();Route gives you the relationship between routes and symbols.
let mut symbol_to_routes = SymbolToRoutes::new();
symbol_to_routes
.collect_route_dependency(&module_ast, &symbol_dependency)
.unwrap();Scheduler gives you the module path by topological order. It will check the wildcard exports and namespace imports. If A does wildcard exports or namespace imports from B, then B will be returned before A.
let mut scheduler = ParserCandidateScheduler::new("<project_root>");
loop {
match scheduler.get_one_candidate() {
Some(module_path) => {
// parse this module and add it into the depend-on graph
scheduler.mark_candidate_as_parsed(module_path);
}
None => break,
}
}DependencyTracker traces all the symbol dependency paths for you.
let mut dt = DependencyTracker::new(&used_by_graph, false);
// trace the default export of this module
let paths = dt.trace("<module_path>", TraceTarget::DefaultExport).unwrap();
// trace the named export of this module
let paths = dt.trace("<module_path>", TraceTarget::NamedExport("exported_name")).unwrap();
// trace the local variable of this module
let paths = dt.trace("<module_path>", TraceTarget::LocalVar("variable_name")).unwrap();See the demo crate.
Track fine-grained symbol dependency graph
Usage: demo -s <SRC> -d <DST>
Options:
-s <SRC> Path of project to trace
-d <DST> Path of the output folder
-h, --help Print help
-V, --version Print version
See the cli crate.
Parse a project and serialize its output
Usage: cli <COMMAND>
Commands:
portable Parse and export the project in portable format
database Parse and export the project in database format
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
-V, --version Print version
Usage:
cli portable -i <INPUT> -t <TRANSLATION_PATH> -o <OUTPUT>cli database -i <INPUT> -t <TRANSLATION_PATH> -o <OUTPUT>
see the api_server crate. The database is the one generated by CLI with database command.
Start the server to provide search API
Usage: api_server --db <DB>
Options:
--db <DB> The path of your database
-h, --help Print help
-V, --version Print version
You have to run the api_server with one of your portable, then you can use the web for searching.
This feature is made for non-technical folks 💆♀️.