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
v1
andv2
are local variable symbols, andv2
is lexically contained withinv1
v2
is imported from another module asv1
v1
exports 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 Resolver
resolves the import pathsScheduler
manages the parsing order for modulesParser
s extract imports, exports, symbols and determine their dependencyDepend-On Graph
aggregates all the parsed modulesUsed-By Graph
reverses the edges fromDepend-on Graph
Dependency Tracker
tracks 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 💆♀️.