How the engine processes a tree
A short orientation for making sense of an analyze/analyzeTrees report — what each stage does, what languages are understood natively, and how the engine handles what it can't fully parse. For the field-by-field output shape, see the SDK reference.
From a tree to a report
| Stage | What happens |
|---|---|
| Walk | A single-threaded, gitignore-aware walk collects every file under root, honoring committed .gitignore files, and sorts the list — the input order to everything downstream is deterministic. |
| Parse → Common IR → DSL rules (fused, per file) | Each file is parsed, projected into the language-neutral CommonIr, and every applicable DSL rule pack is evaluated against that file's slice — in one fused pass, in parallel across files. A file's raw parser AST never leaves this step; only IR data and findings cross back out. |
| Whole-tree assembly | Per-file artifacts are merged into one tree-wide dependency graph and IR. Native whole-graph analyses run here: circular dependencies, dead code, structural scores, health index, and (when multiple trees are analyzed together) the cross-layer join. |
| Envelope | Findings from both stages are merged and serialized into one deterministic JSON envelope — same input, byte-identical output. |
Common IR
Every file — regardless of language — is projected into CommonIr before anything else touches it. This IR, never a raw language AST, is what the output's ir field actually contains:
| Field | Holds |
|---|---|
dep | The import graph — each path to the paths it imports. |
symbols | Function, class, const, type, and interface declarations. |
loc | Physical line count, per file. |
io | HTTP/DB/tRPC provide and consume facts (IoFacts) — the input to the cross-layer join. |
A custom or external parser can feed the exact same shape into the pipeline through the Normalized AST adapter protocol, described in the language support section below — it joins the same fused pass, the same whole-tree assembly, and the same envelope, unchanged.
Participation doesn't require a native parser
| Extension(s) | Structural support |
|---|---|
.ts .tsx .js .jsx .mjs .cjs .mts .cts | Full: symbols, imports, calls, HTTP routes and egress (via swc). |
.prisma | Schema models and fields — structural, plus usage-aware schema rules. |
.java | Method and class body spans only (lexical, not a full grammar) — enough for method-scan rules. |
| Everything else | Lexical fallback: line count and line-scan rules only — no symbols, imports, or IO. |
Unsupported doesn't mean silently ignored. A lexically-handled file still gets a line count and still runs every line-scan DSL rule against its raw text — it's just missing the symbol/import/IO extraction that a native parser provides, and that gap is a known, self-reported shape (see "Degraded vs. minified" below), not a silent hole.
For anything that needs full structural support without an in-tree parser — JSP, Python, or any other language — a hand-written external parser adapter can produce the same Common IR shape directly, via the Normalized AST protocol: a JSON envelope of per-file symbols, imports, and IO facts, validated against the engine's own Rust types and fed through analyzeEnvelope. The engine runs its language-neutral analyses over the projection: the dependency graph, the circular/unreachable/dead-code passes, and the symbol-scan/io-scan DSL rules. Analyses an envelope cannot support are reported honestly rather than stubbed — scores/health need git history and stay null in envelope mode, and the cross-layer join runs only in multi-tree analyzeTrees. A parser is first-class as soon as its projection is accurate, regardless of how crude it is.
The kernel owns no rule vocabulary
The workspace splits the engine kernel from every source of rule vocabulary. core/engine know about IR types and a registry — never about what a rule actually checks for. Rules register themselves from their own crates; the kernel only aggregates.
packages/core, packages/engine
The kernel. IR types and registry only — no rule-vocabulary knowledge. engine orchestrates the pipeline and aggregates registrations from every rule crate.
parser/*
parser-typescript (native, via swc), parser-prisma (schema language), parser-java (lexical) — each projects its language into the Common IR.
rules/native/*
rules-graph (circular dependencies, dead code, cross-layer analyses) and rules-schema (Prisma structure and usage) — whole-graph analyses, statically linked into the engine, registering their own ids.
rules/dsl/*
One folder per pack, each pairing its JSON rule definitions with co-located tests. Adding a rule means touching a pack folder — never the kernel.
packages/napi
The Node.js binding. Exposes exactly four functions: analyze, analyzeTrees, analyzeEnvelope, version — documented in the SDK reference.
Joining a frontend's fetch to a backend's route
When multiple trees are analyzed together (analyzeTrees), each parser's declared IoFacts.provides/consumes entries are joined across trees on an exact (kind, key) match — for example, a frontend's fetch("/users/:id") joins a backend's registered GET /users/:id route. The join is a plain string match on a normalized key, never AST matching, which is why even a crude external parser adapter can participate as long as its key normalization is correct.
Rather than overclaiming a match it can't be sure of, the join result carries six buckets plus a per-edge confidence flag:
edges
A consume matched to a provide across sources.
unconsumedProvides
A provide no analyzed source consumes.
unprovidedConsumes
A consume no analyzed source provides.
unresolvedConsumes
A consume whose URL/key could not be statically determined.
externalConsumes
A consume targeting an absolute external host URL — e.g. GET https://vendor.com/api/users. Classified as third-party egress, not joined, and not treated as drift.
ambiguousConsumes
A consume matching provides in two or more distinct source trees. Not auto-linked — no edge is emitted; every candidate provider is listed so the ambiguity can be resolved by hand.
lowConfidenceReason
Set on an edge whose key matched a generic path pattern (health checks, /login, and the like) that many unrelated services could share. The edge is still emitted — just flagged as lower confidence than a distinctively-named route.
20 cross-layer/* native analyses run on top of this join — matched-edge diagnostics (unconsumed endpoints, method mismatches, version skew, path near-misses, shared database tables, duplicate routes), external-egress checks (shadowed internal routes, secrets in URLs, IP literals, host fan-out, base-URL drift, and more), and a tRPC procedure-coverage check (an unconsumed procedure joined from cross-file router fragments) — cataloged in the rule reference.
Two different kinds of partial analysis
A file the engine can't fully process falls into one of two distinct, self-reported categories — never a silent skip.
degraded
A file that's too large (over the default 1,500,000-byte / ~1.5MB size cap) or fails to parse. It's still analyzed on a best-effort basis: line count and line-scan DSL rules still run against the raw text, but symbol/import/IO extraction is skipped, so method-scan/symbol-scan/io-scan rules silently find nothing for that file rather than erroring. Listed in the output's degraded array.
minified / generated
A file is classified this way when either holds: any single line is 5,000+ bytes long, or it has a 500+ byte line and lines that long make up at least half the file's bytes — the signature of bundler output and other generated code. Every DSL matcher type is skipped entirely for this file, since a giant physical line offers no reliable scoped context for any of them. Native structural extraction — symbols, imports, IO, the dependency graph, circular/dead-code analyses — is unaffected and proceeds exactly as normal.
A hand-written file that happens to contain one long string or comment line among otherwise ordinary lines is not classified as minified — that shape is common enough in real source that it has to keep its rule coverage. When one or more files are classified this way, the output's warnings array gets a single aggregate entry naming the count and a sample of affected paths, never one entry per file.
Every run also emits a structural coverage census per tree (coverage in the output) — vocab-free counts of how much of each channel it actually filled (files, symbols, import edges, and IO), plus the degraded count above — so a consumer can tell "analyzed and found nothing" apart from "this channel was dark". When a tree analyzes files but extracts zero IO, its coverage.joinContributionZero flag asserts the blindness outright: the tree is invisible to the cross-layer join, so any join finding that references it is structurally weak (a client the extractor can't see — a hand-rolled HTTP wrapper, a generated SDK — is a common cause). And a run-global disclosure registry names the classes of silent failure zzop does, and does not yet, actively detect — so the tool never quietly claims to be complete.