dep2 streams a project's filesystem and parsed syntax trees into relations
and runs incremental Datalog over them, so query results update live as you
edit code.
project files ──▶ fs plugin ─────▶ files(path, ext)
┐
source code ──▶ treesitter plugin ─▶ ast_node(...) ├─▶ FlowLog (incremental Datalog) ─▶ live results
(wasm grammars) ┘ ▲
your rules.dl ───────────────────────────────────────────────────────┘
It is built on FlowLog, an incremental Datalog engine over
differential-dataflow.
When a file changes, only the affected facts are re-derived — insertions and
deletions flow through your rules as +/- updates.
This project was forked from an HCL→Datalog tool (DbFlow); the HCL front-end was removed in favour of feeding FlowLog its native Datalog directly, plus two new streaming plugins (
fs,treesitter).
FlowLog runs on i64 columns internally for speed, but string and float
are first-class column types inside the engine: the engine interns strings to
ids and stores floats as their bit pattern on input, and decodes both back to
text on output (see crates/reading/src/interner.rs). So a .dl program +
.facts files using string/float columns work with FlowLog standalone — the
codec is an engine feature, not something the dep2 layer bolts on. Relations are
fed by streaming plugins that emit insert/delete diffs:
fs— walks a project root, seedsfiles(path, ext), then watches the tree and emits diffs as files are created/deleted.treesitter— parses each source file with a tree-sitter grammar loaded at runtime from a.wasm, flattens the tree intoast_node(structural-path ids) plus anast_spanside table of byte offsets, and incrementally re-parses on change — streaming only the minimal diff.
Both use relative paths with / separators, so files and ast_node join.
files(path: string, ext: string)
ast_node(file: string, node: string, parent: string, kind: string,
named: number, text: string)
ast_span(file: string, node: string, start: number, end: number)
ast_child(file: string, node: string, idx: number)
ast_line(file: string, node: string, start_line: number, end_line: number)
line(file: string, lang: string, lineno: number, blank: number, gid: number)
node— structural-path id:0is the file root,0.2its third child,0.2.1that node's second child, …parentis the parent's path (empty at the root). Positional rather than a global counter, so an edit only changes ids under the edited subtree — unchanged subtrees keep identicalast_noderows and fall out of the diff.kind— grammar node type (function_item,identifier,"{", …).named—1for named grammar nodes,0for anonymous tokens/punctuation.text— source slice (leaf nodes only).- byte offsets live in
ast_span, keyed by(file, node)— kept out of the structural graph because offsets shift on every insert, which would otherwise churn the whole file. Joinast_spanonly when you need positions. ast_childgives each node's index among its siblings (root = 0), so rules can ask positional questions ("the first child / qualifier"). Join when you need order.ast_linegives each node's 0-based line span;lineis one row per physical line (blank= 1 if whitespace-only,gida unique line id for counting). These are raw, language-agnostic facts that a token AST can't otherwise express (blank lines, line numbers) — they let line-oriented analyses (e.g.cloc.dl) be written purely as rules. Join only when you need line-level counts.
A fresh clone is ready to develop with three commands:
mise trust && mise install && mise run setupmise run setup activates the git hooks, builds the tree-sitter grammars the
default mise run graph uses into ./grammars, installs the web deps, and
builds the engine (debug). It needs npm plus a local emscripten or Docker (to
compile grammars to wasm) and pnpm (for the web UI). Already-built grammars are
skipped, so re-running is cheap.
For a performance build of the engine, do a release build (the treesitter
plugin pulls in wasmtime, so the first build takes a few minutes):
cargo build --releasemise run setup builds the grammars mise run graph uses (Rust, JS/TS, JSON,
TOML, HTML, CSS, Markdown, MDX). To add another, use the build-grammar task —
grammar .wasm files must be built with a tree-sitter CLI matching the
tree-sitter crate version, or loading fails (failed to parse dylink section):
mise run build-grammar tree-sitter-python
# -> ./grammars/tree-sitter-python.wasmSome grammars need extra arguments — a subdirectory (the package ships several
grammars, pass <out-dir> <subdir> <wasm-name>) or a github: source (no npm
release):
mise run build-grammar tree-sitter-typescript grammars typescript tree-sitter-typescript
mise run build-grammar github:srazzak/tree-sitter-mdxdep2 run <program.dl> --source '[RELATION=]PROVIDER:k=v;k=v...' [--source ...] \
[--addr 127.0.0.1:7878] [--no-serve] [--print] [-w N]
RELATION is omitted for multi-output providers (e.g. treesitter, which feeds
ast_node + ast_span); single-output providers (fs, csv) use it to name
their relation. Config pairs are ;-separated (so values may contain commas).
The program runs continuously until Ctrl-C, serving a query API (see below) on
--addr and keeping the current state of every terminal IDB (those not
consumed by another rule) up to date. Pass --print to also stream
+ rel(...) / - rel(...) updates to stdout, or --no-serve to skip the API
and just print.
List Rust source files (fs plugin):
dep2 run examples/files.dl --source 'files=fs:root=/path/to/project'Extract Rust function definitions (treesitter plugin):
dep2 run examples/rust_functions.dl \
--source 'treesitter:root=/path/to/project;grammars=rs=./grammars/tree-sitter-rust.wasm'Other programs in examples/:
ast_dump.dl— every named AST node as(file, node, kind, text).rust_calls.dl— call graph: each call attributed to its nearest enclosing function via a linearenclosing(node, fn)closure (scales to large files). Matches plainfoo(..), pathPath::foo(..)and methodrecv.foo(..)calls.rust_recursive_fns.dl— recursive functions (self or mutual) via the transitive closure of the call graph. Methods are qualified by theirimpltype and calls resolved per type (Self::f,Type::f,self.f()), so same-named methods of different types are not conflated; free functions match by name. Receiver-typed calls (self.field.f()) are skipped (no type inference). Oncrates/strata/srcit precisely flagsprocessing_order_dfsandassigning_scc_dfs(stratified negation + recursion).rust_function_spans.dl— function defs with byte spans (joinsast_span).rust_unused_functions.dl— unused functions via stratified negation.rust_dead_code.dl— workspace-wide dead functions: defined but never called anywhere (cross-file negation). Counts plain/path/method/turbofish calls plus macro-body calls, and excludespub/main/#[test]. Read the settled result viadep2 query dead_fn(the live stream over-approximates then retracts). Residual false positives are trait-dispatched methods and functions passed by name — on this workspace those are the only hits, i.e. no true dead code. Point the source at a directory spanning all crates.rust_large_functions.dl— functions over a byte-size threshold (head arithmetic +ast_spanjoin). Run on this repo'scrates/executing/src, it flagsstreaming_program_execution(~32 KB) andprogram_execution(~28 KB).rust_panic_audit.dl—.unwrap()/.expect()call sites with byte offset.rust_panic_propagation.dl— functions that can panic transitively: direct.unwrap()/.expect()closed over the call graph (can_panic). Name-based and file-local, so it over-approximates; recursion is monotone reachability.rust_xpanic.dl— the cross-file, precise version: transitive panic closed over the type-qualified cross-file call graph (functions keyed by AST node id so edges compose across files). Oncrates/strata/src,mainis flagged because it callsStrata::from_parser(resolved cross-file) which can panic. Method callsrecv.f()are skipped, as inrust_xcalls_typed.rust_xcalls.dl— cross-file call graph: resolves each call to the file(s) defining a function of that name and surfaces edges that cross a file boundary (cross_file_call). Oncrates/strata/srcit recovers e.g.stratification.rs::from_parser→rewrite.rs::desugar_recursive_aggregation. Name-based, so a name defined in several files resolves to all of them.rust_xcalls_typed.dl— type-qualified cross-file call graph: resolvesType::methodto that type's method (and free functions by name), so same-named methods aren't conflated. Oncrates/strata/srcit trims the name-based graph from ~15 edges (8 of them false test→from_parserresolutions) to 3 correct ones. Trade-off: method callsrecv.f()are skipped (receiver type needs inference), which the name-based version catches.rust_hot_fns.dl— "hot" functions by fan-in: how many distinct functions call each workspace-defined name, via a cross-filecountaggregation over the call graph (fanin; sort descending for the top). Oncrates/executing/srcthe internal leaders arenew,jn_compare,aggregate_values, the*_deconstructors.rust_imports.dl— cross-file import / module graph:moddeclarations and each file's external crate/module dependencies (root segment of everyusepath, via a child-0 descent closure overast_child). Oncrates/executing/srcit reconstructs the crate's dependencies (planning, reading, catalog, …) and module tree.rust_crate_deps.dl— crate-level dependency graph: groupsrust_imports' per-file deps by importing crate viasplit_nth(File, "/", 0)(run withroot=crates).intra_depis the workspace-internal graph and matches the Cargo dependency graph (e.g.executing→ catalog/parsing/planning/reading/ strata/macros/debugging). Demonstrates the string builtins enabling crate-aware queries.rust_crate_coupling.dl— crate coupling metrics over the internal dependency graph:afferent(c, n)(how many crates depend onc) andefferent(c, n)(how manycdepends on), viacountaggregation grouped by crate. On this repo the foundational crates areparsing(afferent 6),catalog(4); the high-level onesexecuting/dep2_core(efferent 7). Crate names are normalised withreplace(.., "-", "_")so hyphenated dirs (dep2-core,dep2-plugin-*) resolve against theirusenames.rust_crate_reach.dl— transitive crate reachability: the closure of the internal dependency graph (recursion at crate granularity).tdep_count(c, n)is each crate's transitive fan-out (dep211,dep2_core10,executing8);indirect_only(from, to)is reachable-but-not-direct couplings (e.g.executing → optimizing, only viaplanning).rust_crate_depth.dl— crate dependency depth (architectural layer): the longest dependency chain per crate, via recursivemaxaggregation over the crate DAG. The layering comes out 0 (parsing) → 1 (catalog,reading,strata) → 2 (macros,optimizing) → 3planning→ 4executing→ 5dep2_core→ 6dep2.rust_pubcrate.dl— crate-aware refactoring hint: fully-pubfunctions called only from within their own crate (pubcrate_candidate) — candidates to demote topub(crate). Joins pub-fn defs (keyed by crate) against the per-crate call set and excludes names called from any other crate (stratified negation +split_nth+!=on crate names). Conservative by name (under-suggests). On this repo it flags 55, e.g.executing::program_executionandeval_builtin, while correctly keepingstreaming_program_execution(used bydep2-core).
The analyses are language-generic — the engine and rule style don't change, only the tree-sitter node-kind vocabulary does. JavaScript / TypeScript examples:
js_functions.dl— JS/TS function definitions across the three forms:function f(){}(function_declaration),class C { m(){} }(method_definition), andconst f = () => {}(arrow_function bound to a name).js_calls.dl— JS/TS call graph (nearest-enclosing-function attribution + plain/method calls), the same shape asrust_calls.dl. The same files run on.ts(with type annotations /interface/implements) using the TypeScript grammar — e.g. on a.tsfile they recoverfact → fact(recursion),dbl → fact,area → side(athis.side()method call).cloc.dl— a cloc-style line counter (code/comment/blank) grouped by crate and — unlike cloc — splitting Rust's in-file#[cfg(test)] modtests out from production code, which a line-oriented tool can't do because the test code lives in the same file. The test region is found by a rule over the AST; all classification is rules over the rawline/ast_linefacts. Its four buckets exactly partition every physical line (code+test+comment+blank=wc -l), and it reveals what cloc can't — e.g.executingis roughly half test code — that cloc lumps into one per-language number.poly_recursive_fns.dl— cross-language recursive-function detection over a mixed Rust + JS + TS tree parsed in one engine (grammars=rs=...,js=...,ts=..., all files sharing oneast_noderelation). Small per-language frontends normalise each AST into sharedis_fn/fn_name/callsrelations, then one language-agnostic core computes the closure — flaggingrfact(Rust),jfib(JS), andtsum/loop(TS) in a single run.
The grammars= value maps ext=path.wasm (comma-separated for multiple
languages, e.g. grammars=rs=...rust.wasm,js=...javascript.wasm,ts=...typescript.wasm).
The language name is derived from the wasm filename (tree-sitter-rust.wasm →
rust).
While dep2 run is live it serves the current materialized state of the output
relations over HTTP/JSON, and re-derives it incrementally as files change.
CLI:
dep2 query # list output relations and their row counts
dep2 query func # dump the rows of relation `func`
dep2 query func --json # raw JSON
dep2 query --addr HOST:PORT ...HTTP/JSON (curl-friendly):
GET /relations -> { "relations": [ { "name", "count" }, ... ] }
GET /relations/<name> -> { "name", "count", "rows": [ [col, ...], ... ] }
curl -s http://127.0.0.1:7878/relations/funcQuerying a relation that the program computes but doesn't serve (an intermediate
consumed by another rule, not declared .out) returns a 404 explaining it —
"relation 'X' is computed but not served (consumed by Y); declare it under .out
to expose it" — rather than a bare "unknown relation".
A running engine accepts new Datalog queries without restarting: a query is a
full .dl program whose .in decls name relations the base program publishes
(its inputs and served derived relations). The engine compiles and validates it
up front, then every worker instantiates it as its own dataflow that imports
the published relations' shared arrangements — it replays their entire history,
then follows live updates incrementally, exactly as if its rules had been part
of the program from the start. Queries can join, aggregate, negate, and recurse
like any program.
GET /query -> { "queries": [id, ...] }
POST /query {"id","program"} add a query
DELETE /query/<id> drop a query (its dataflow retires)
GET /query/<id>/relations list the query's relations
GET /query/<id>/relations/<name> the query's live rows
curl -s -X POST 127.0.0.1:7878/query -d '{
"id": "fanout",
"program": ".in\n.decl file_link(src: string, dst: string)\n.printsize\n.decl fanout(src: string, c: number)\n.rule\nfanout(S, count(D)) :- file_link(S, D).\n"
}'
curl -s 127.0.0.1:7878/query/fanout/relations/fanoutInvalid programs (parse/typing errors, unknown base relations, schema
mismatches) are rejected at POST time with the front-end's rendered report.
Publishing costs one extra whole-row arrangement per base relation, compacted
as the engine runs so trace memory stays bounded.
Programs are native FlowLog Datalog. Declare streamed relations under .in,
derived relations under .printsize, and write rules under .rule:
.in
.decl ast_node(file: string, node: string, parent: string, kind: string, named: number, text: string)
.printsize
.decl func(file: string, name: string)
.rule
func(File, Name) :-
ast_node(File, F, _, "function_item", _, _),
ast_node(File, _, F, "identifier", _, Name).
By default the query API serves only terminal relations — those not consumed
by another rule's body — so intermediate/scratch relations stay quiet (and aren't
materialized live). To expose a relation that is consumed by another rule,
declare it under .out instead of .printsize; .out force-serves it over the
query API:
.out
.decl reach(from: string, to: string) // served even though other rules use it
Columns are declared number (i64), string, or float. String literals
("function_item") are interned by the engine and matched against streamed/loaded
string values.
Expressions. Arithmetic chains evaluate left-to-right (no operator
precedence: A - B * 2 is (A - B) * 2); parentheses group explicitly
(A - (B * 2)). Float literals are written with a decimal point (1.5), and a
typing pass resolves every expression's mode from the declared column types:
comparisons, arithmetic and sum/min/max over float columns evaluate as
floats. Mixing float with number in one expression is a load-time error —
there is no implicit conversion; to_float(n) and round(f)/floor(f) are
the explicit bridges:
light(N, W) :- sample(N, W), W < 1.5. // float compare
mid(N, (A + B) / 2.0) :- sample(N, A), sample(N, B). // parenthesised float arithmetic
usd(P, to_float(C) / 100.0) :- cost(P, C). // number -> float
whole(P, round(to_float(C) / 100.0)) :- cost(P, C). // ... and back
Load-time validation. Programs are checked when loaded, with the offending
rule in the message: atom arity against declarations, head/negation/comparison
variables bound by a positive body atom, no variable joined across columns of
different types, conversion builtins applied to the right kind. The engine also
verifies every streaming source's schema (arity + column types) against the
.in declaration it feeds — a .decl typo fails startup instead of feeding
silently-garbled rows.
Errors are rendered as labelled source snippets (the syntax crate — a
chumsky parser with ariadne reports, exercised by the whole example corpus
and by property tests over generated programs):
Error: unknown column type `nmber` — the types are number, string and float
╭─[ rules.dl:2:23 ]
│
2 │ .decl e(x: number, y: nmber)
│ ┬
│ ╰── unknown column type `nmber` — …
───╯
String operators are exposed as builtin functions usable anywhere a factor
is (head expressions and comparison operands). They decode the interned ids back
to text, so they work on string columns and string literals:
split_nth(s, sep, n)— the n-thsep-separated segment ofs(a string). E.g.crate_of(File, split_nth(File, "/", 0))extracts the leading path segment.replace(s, from, to)—swith everyfromreplaced byto(a string).starts_with(s, prefix),contains(s, needle),str_before(a, b)(lexicographica < b) — these return1/0, so use them as a filter with= 1, e.g.r(F) :- files(F, _), starts_with(F, "src/") = 1.to_lower(s)/to_upper(s)— case folding, e.g.contains(to_lower(F), "readme") = 1for case-insensitive matching.extract_number(s)— the first integer insas a number (digit runs may contain,separators:"a backlog of 47,500 rows"→ 47500; NULL if no digits). The bridge from text to arithmetic.date_epoch(s)— Unix epoch seconds of an ISO-8601 timestamp ("2026-07-20T00:00:00Z"; date-only accepted, numeric offsets rejected; NULL if unparseable). The bridge from date columns to time arithmetic — pair it with theclockplugin'snow(iso, epoch)heartbeat relation:soon(N) :- deadline(N, D), now(_, E), date_epoch(D) < E + 604800.to_float(n),round(f),floor(f)— explicit numeric conversions (see Expressions above); strictly typed, soto_floatof a float is an error.ln(f),exp(f),sqrt(f),pow(f, f)— float math. Would-be-NaN results (ln/sqrtof a negative) are NULL; infinities keep their IEEE-754 value.ln(P / (1.0 - P))is the log-odds transform.abs(x)— polymorphic: number → number, float → float (the typing pass resolves the mode). Replaces the two-symmetric-rules idiom for magnitudes.similarity(a, b)— how alike two strings are, 0..100 (Sørensen–Dice over character bigrams). Case-sensitive; compose withto_lowerfor fuzzy joins:pair(A, B) :- x(A), y(B), similarity(to_lower(A), to_lower(B)) > 85.
Builtins compose (split_nth(split_nth(P, "/", 0), "_", 0)), take full
expressions as arguments (round(to_float(C) / 100.0)), and propagate NULL.
Boolean builtins are written f(..) = 1 because a bare f(..) in body position
parses as a relation atom.
Time is available as a relation: the clock plugin (no network, no files)
feeds a heartbeat now(iso, epoch) row, retracted and re-inserted every
tick seconds (default 60), so time-based rules re-evaluate incrementally —
things enter and leave derived relations as deadlines cross the window.
fixed=<ISO> freezes the clock for tests and reproducible runs.
dep2 run rules.dl --source 'files=fs:root=.' --source 'clock:tick=60'ast_span(byte offsets) churns on most edits — offsets after the edit shift, so it is not minimal-diff. That churn is deliberately isolated to the side table; the structuralast_nodegraph stays stable. Avoid joiningast_spanin hot analyses unless you need positions.- Change detection still rescans the directory tree on each event (the
fsplugin) / re-reads changed files (treesitter); the re-parse itself is incremental. Fine for typical projects. - Recursive aggregation over a growing value domain may not terminate. A
recursive aggregated head (e.g. connected components,
cc(N, min(C)) :- edge(O,N), cc(O,C)) is desugared by a planner-level stratum split (crates/strata/src/rewrite.rs) into an un-aggregated recursive helper plus a downstream non-recursive aggregation — sound under the incremental (isize) semiring, and correct under streaming insert/delete (see thebatch_cc_/streaming_cc_/batch_mutual_min_property tests). Both self-recursion and mutual recursion between aggregated heads are handled (the whole aggregated cycle is lifted out of the recursive stratum). The helper accumulates candidate values, so the aggregate must range over a finite value domain to converge: min/max label propagation (connected components, reachability) terminates; shortest paths through a positive cycle would diverge, as in any pure-Datalog encoding.
All crates live under crates/ (a flat workspace, members = ["crates/*"]):
crates/dep2/ the CLI binary
crates/dep2-core/ HCL-free engine: string interning + streaming wiring
crates/dep2-plugin/ plugin traits (Plugin, StreamingDataProvider, ...)
crates/dep2-plugin-fs/ filesystem seed + watch
crates/dep2-plugin-treesitter/ wasm-grammar parsing + flatten
crates/dep2-plugin-csv/ CSV streaming (kept as a reference data source)
crates/syntax/ the .dl parser (chumsky + ariadne reports)
crates/{parsing,strata,catalog,optimizing,planning,reading,executing,macros}/
the FlowLog incremental Datalog engine
examples/ example .dl analysis programs
web/ React SPA: live graph + data + rules views
packages/force-graph/ reusable R3F force-graph component (+ Storybook)
.mise/tasks/ project tasks (graph, storybook, build-grammar, ...)