The depth case

Hand-written extractors, not query files.

Nearly every tree-sitter-based code-intelligence tool drives extraction the same way: a pack of .scm query files per language, matched against the syntax tree by the tree-sitter query engine. julie-extractors has no query files at all and never invokes the query engine. Each of its 38 languages gets an imperative Rust extractor that walks the concrete syntax tree directly, with a large hand-written extraction surface instead of query packs.

That is an unfashionable amount of code. This page explains why it was written anyway, and what the trade costs.

What query files do well, and where they stop

A tree-sitter query is a declarative S-expression pattern: match this node shape, capture these child nodes, tag them @function.name or @reference.call. For syntax highlighting and basic symbol listings the design works well, and it is easy to ship for dozens of grammars.

The trouble starts when a tool needs meaning rather than shape. A query can capture a node. It cannot easily carry context across the tree, apply language rules, or decide anything. A C# method's real signature (modifiers, generic constraints, parameter defaults, nullability) is scattered across sibling and ancestor nodes; a capture returns one node, and assembling a signature takes an algorithm. Whether an identifier declares a variable, shadows one, or references an outer symbol depends on scope rules the grammar does not encode. services.AddScoped<IFoo, Foo>() is just a generic method call to a query, and recognizing it as a dependency-injection registration that should emit edges to both IFoo and Foo means knowing the convention, handling chained receiver forms, and parsing nested generic arguments. And a query cannot decline to answer: deciding which declaration an identifier points at requires the ability to say "ambiguous" and stop, which is control flow, not pattern matching.

Tools built on query packs inherit this ceiling quietly. They report symbols and name-based references and look complete, right up until an agent asks who calls this specific overload, and the answer is not in the data.

What the extra code buys

With real code walking the tree, the extractor for each language emits thirteen data domains into one versioned SQLite artifact: symbols, identifiers, relationships, pending cross-file relationships, type facts, type-argument usages, literals, source regions, doc comments, complexity metrics, structural facts, annotations, and parse diagnostics.

The clearest example is structural facts. Nearly 200 versioned pattern IDs across 60 fact families capture framework and document shape as plain data: express.route.v1, rails.resource_route.v1, django.url_pattern.v1, spring.request_mapping.v1, fastapi.include_router.v1, aspnet.minimal_api.route.v1, nextjs.file_route.v1, phoenix.route.v1, htmx.attribute.v1, alpine.directive.v1, SQL DDL/DML shapes, and so on. Each is a stable versioned contract a downstream tool can query without ever touching an AST.

Relationships understand conventions too. C# DI registrations become instantiation edges, including chained builder.Services.AddScoped<T>() forms and nested generics, without a brittle method-name allowlist for identifier capture. Partial classes are linked across files. ES-module imports carry export provenance. Tests are classified by role per language rather than by a single "is a test" bit, with golden fixtures proving at least one emitted role for all 28 code languages. Symbols carry assembled signatures, doc comments (36 of 38 languages), body spans, and per-function complexity, which is usually enough for an agent to reason about an API without opening the file.

Reference resolution that fails closed

The biggest payoff is reference identity. Extraction feeds a four-tier resolver. Tier 1 works within a file with scope awareness, so a local variable reference beats a same-named symbol in another file. Tier 2 crosses files through import statements, including aliases; it is gated per language, a default import without export provenance does not resolve, and cross-language import candidates are excluded. Tier 3 resolves method calls through the receiver's proven static type from extracted type facts, and an inferred type lowers confidence instead of faking certainty. Tier 4 is the last resort and fires only when exactly one compatible candidate exists in the workspace: overloads stay ambiguous, partial-class collisions stay ambiguous, and a cross-language name collision is refused rather than guessed.

Every tier prefers no answer to an unproven one, because for a coding agent a wrong edge is worse than a missing edge. An agent told "ambiguous" falls back to grep and reads the file. An agent handed a confident wrong answer renames the wrong method. The outcome vocabulary (resolved, ambiguous, missing, unattempted) is committed as a per-language coverage artifact that CI checks strictly.

Owning grammars where the ecosystem had gaps

Depth sometimes requires owning the layer below. julie-extractors maintains tree-sitter grammar forks for C#, SQL (T-SQL), and Razor, cases where the upstream grammar was missing, stale, or could not represent constructs the extractors needed. Each fork is pinned to an exact commit under a written grammar-dependency policy, so a grammar change is a reviewed event rather than a floating dependency.

Keeping 38 languages honest

The obvious failure mode of hand-written breadth is uneven depth: a few well-tended languages and thirty that quietly lag. The repository's answer is a capability matrix with no silent cells. Every language and domain claim is either backed by golden fixture evidence or carries a recorded reason the domain does not apply to that language ("applicable closure"). Missing work cannot hide behind not_applicable, and strict gates fail CI when fixtures drift from claims:

node scripts/language-data-quality-report.mjs --strict
node scripts/reference-resolution-coverage-report.mjs --strict

A product rule backs this up: a new extraction capability is not done until it works for every supported language, verified on a real extract. A feature that covers one language while looking authoritative is treated as a bug.

The cost

Averaged out, each language carries roughly 3,500 lines of extraction code that has to track grammar updates by hand, work a query pack mostly avoids. Adding a language takes days rather than an afternoon of pattern writing. Imperative extractors can also hide bugs that declarative patterns cannot, which is why the fixture and gate machinery above exists. That infrastructure is part of the price.

The bet is that for agent-facing tooling the price is right, because agents act on this data: they rename symbols, plan refactors, and answer questions with it. In a paired benchmark, the same agent using Miller (whose index is exactly this artifact) got 2.2× more tasks right than with grep and file reads, with a 0% wrong-action rate against 27%. The five tasks that required exact symbol identity, the thing the resolution tiers exist for, went 0 for 20 without the artifact.

Try it

# produce an artifact for any repo
julie-extract scan --root . --db artifact.sqlite --json

# see exactly what your languages support
julie-extract languages --json

Binaries for macOS, Linux, and Windows are on the releases page, and schema contracts live in docs/contracts/. If you want the artifact with an agent interface on top (search, refs, impact, editing), that is Miller.