← Blog
AI/MLCode AnalysisProduct

Type-aware code search, all the way up the chain

Contents
  1. Full type attribution, carried in the index
  2. The type chain makes search subclass-aware
  3. The new filter surface
  4. Questions no other search can answer
  5. Why this works now: LST v3 underneath
  6. One more thing: it doesn't stop at your code
  7. Search became a type problem

You know that “Find Usages” feature that you’ve used a million times in your IDE? It works because the IDE compiled the project and holds the entire type graph in memory. That’s why it knows that something like myList.add(x) is a call to java.util.Collection.add even though the word “Collection” shows up nowhere on the line. Developers have leaned on that for years without thinking about it much, and it works great until the question outgrows the one project that’s open.

A coding agent has no such luxury as “Find Usages”. There’s no compiled project, no type graph, and nothing to right-click. Its default tools are text search and a file reader, so it’s forced to infer and re-derive from scratch every time what the compiler already knows. Working this way means it can easily miss certain call sites, but it prefers the ease and speed of searching text with grep over having to compile code and query type graphs.

That’s the gap Trigrep was built to close, and when we launched it, speed was the headline. That speed comes from the trigram index underneath, which rapidly narrows the scope using intersecting posting lists instead of scanning raw files. It holds up across thousands of repositories, or across one monolith far too large for an agent to grep, in sub-second time, so an agent can reach for it mid-task without burning latency or tokens waiting on an answer.

Tool type Technique Strengths Limitations
Grep family (grep, ripgrep, ag) Raw text scan Simple, local, immediate Re-scans files each time; more reads required to confirm matches; very slow for large repos
Indexed code search tools Trigram indexing Fast discovery across many repos Often limited integration with automation workflows
Semantic search (Moderne recipes) Search LSTs via recipes Precise understanding and transformation Slower scan due to deeper analysis
Moderne Trigrep Trigram indexing with LST data and org scoping Fast, contextual, automation-ready discovery Designed for fast discovery, not full semantic analysis

Full type attribution, carried in the index

Speed isn’t the whole story, though. Because the index comes from the LST, it can carry the types, which means the agent stops having to trade accuracy for speed. Now, Trigrep carries full type attribution in the index itself, including the library types your code inherits from and calls into.

So it knows that myList.add(x) is a call to Collection.add, and it knows it across the whole portfolio in less time than grep would take. That pairing (compiler-grade type resolution at indexed-search speed and portfolio scale) is the part neither your IDE nor your grep can do, and it’s what makes searching reliable for agents to lean on for answering complex questions about code.

This all works because for every class, method, field, and reference in a file it indexes, Trigrep stores a symbol record in the index, with the related types already resolved.

That last part is the key piece. When a parser meets a library type at a call site, it often records just enough to identify it and leaves out that type’s inheritance hierarchy, what it extends and implements. A List shows up with no interfaces, and nothing in that record says it extends Collection. A text index, or even a naive symbol index, takes that at face value and misses roughly half the references you care about. Instead, Trigrep upgrades each type to its fully attributed form through the type-table chain. That chain is the authoritative ancestry for every type the repository touches, third-party libraries included.

The type chain makes search subclass-aware

Take the myList.add(x) call from the previous example, where myList is declared as a List. That call is really List.add, so a search for Collection.add won’t match it, even though List extends Collection. A symbol index that records only what’s literally written has the same blind spot.

Trigrep handles this when it builds the index instead of when you run the query. As it indexes a call made on a List, it walks up List’s ancestry and records that same call under every ancestor that declares its own add, including Collection.add. So sym:java.util.Collection.add matches the call site directly with no type reasoning at query time.

Why a Collection.add search misses a List.add call site Searching for sym:java.util.Collection.add against the call site myList.add(x), where myList is declared as a List. A text index holds only the raw characters and does not match. A symbol index records the literal receiver type java.util.List.add and does not match. Trigrep records both java.util.List.add and java.util.Collection.add, because it walks the type chain at index time, so it matches. myList.add(x) myList declared as List searching for sym:java.util.Collection.add Text index myList.add(x) no match Symbol index java.util.List.add no match Trigrep java.util.List.add java.util.Collection.add from the type chain match

The effect is that Trigrep answers inheritance-shaped questions correctly across the whole hierarchy, including the parts that live in libraries you depend on. That ancestry lives in the type tables, so Trigrep doesn’t need the libraries themselves to know what extends what, and neither does the agent asking.

The new filter surface

Because the type chain is in the index, query filters can understand types. They take the same shape as the file:, repo:, and lang: clauses agents already know from other code search tools, so the grammar is familiar, only with new things to ask for.

A few of the filters that type attribution unlocks:

  • extends: and implements: match by ancestor, computed through the chain.

    • Example: implements:Repository finds every class that implements it, including the ones that pick it up through a parent.
  • returns: and throws: match methods by return type and declared exceptions.

    • Example: returns:List finds every method declared to return a List.
  • annotated: matches declarations carrying a given annotation.

    • Example: annotated:RestController finds every declaration carrying that annotation, across every repository at once.
  • sym: matches a symbol by fully qualified name, and call: matches call sites by declaring type and name. Both are subclass-aware.

    • Example: sym:java.util.Collection.add finds the myList.add(x) call sites too, not only the ones written literally.
  • visibility:, static:, final:, and abstract: filter by access and modifier, and select: narrows to a declaration kind.

    • Example: select:symbol.method static:yes finds static method declarations only.

Then there’s ref:. A bare ref:T finds the places where T explicitly appears as a type: a field or parameter declared as T, a new T(), a Javadoc reference. That’s close to what OpenRewrite’s FindTypes recipe returns. It skips expressions that merely happen to be of type T, which is what keeps the results usable. Search ref:org.slf4j.Logger and you get the declarations, rather than the thousands of log.info(...) calls that would bury them. When you do want those, ref.call:, ref.field:, and ref.owner: narrow to a specific kind, and ref.any: returns all of them. The default is the search you almost always meant.

The examples so far have all been Java, but none of this is Java-only. Because the filters read LST metadata rather than raw text, the same type-graph queries run against C#, JavaScript, and Python, so implements:System.IDisposable finds disposables in C# the same way implements:java.util.Comparator finds comparators in Java. Trigrep sees the whole type graph that the language’s LST carries.

You can experiment with these filters using the Trigrep search feature of the Code Genome Project. (More on that later.)

A Trigrep search for ref:java.util.List returning its first result in 3 milliseconds, with matches highlighted across two files.

Questions no other search can answer

The filters matter because of the questions they collapse into a single search. A few that used to mean writing a recipe or reading through a lot of files by hand:

  • Every implementor of an interface, including the ones three (or ten) libraries deep rather than only the direct subclasses.

  • Every caller of a method reached through a subtype, so a deprecation sweep doesn’t miss list.add(...) when the method it’s really calling is Collection.add.

  • Every endpoint carrying a given framework annotation, across thousands of repositories at once.

  • Every method that returns a particular type, which is how you scope a migration to a reactive or nullable API before you touch a line.

  • Where a type is genuinely used as a type, separated from every expression that merely happens to be of that type, which is the difference between a useful List search and one buried in noise.

Each of these is a single Trigrep query, returning in the sub-second range across thousands of repositories.

Why this works now: LST v3 underneath

Full type attribution was always in the LST. What changed is that it’s now fully available in the index, and that came from a change one layer down in how the platform stores the LST. LST v3 replaces the monolithic serialization of earlier versions with separated tables, and two of those changes are what the current Trigrep is built on.

The first is per-dependency type tables. Instead of one type blob per repository, v3 writes one table per dependency version, keyed to its coordinate. In a Java project, the JDK gets its own, spring-boot:3.4.1 gets its own, and the source set’s own types get theirs. At read time they compose into a single flat type universe, which is exactly the chain Trigrep walks to resolve a receiver’s ancestry. Because a dependency version is identical everywhere it appears, its table is written once and reused across every repo that depends on it, so the intelligence behind a Collection.add query is shared instead of something that needs to be recomputed per repo.

Inside a type table A type table has a header holding its kind, packages, identifiers and an index, and a sequence of entries. Types owned by the table carry a full definition; types it only references are stored as stubs. The index points into the entries. SCHEMA EXAMPLE HEADER Kind Parsed source, or a dependency Dependency Packages Type groupings referred to in this table org.apache.commons.lang3 org.apache.commons.lang3.builder Identifiers Names of the types referred to in this table StringUtilsBooleanUtils Index Where to find the entry for any given type StringUtils02 BooleanUtils03 POINTS INTO THE ENTRIES BELOW ENTRIES Blocks holding the type information itself. Types owned by this table carry everything: supertypes, interfaces, methods, fields. Types it merely references are stubs, since their full definition lives in another table. 01 java.lang.String stub 02 org.apache.commons.lang3.StringUtils abbreviate(…)capitalize(…)contains(…) 03 org.apache.commons.lang3.BooleanUtils and(…)negate(…)or(…) Owned by this table, full definition Referenced only, stub

That structure also keeps the cost down at query time. A symbol’s related types are stored as slots into those tables rather than as repeated names, so resolving one is a direct lookup rather than a scan. And a text-only or regex Trigrep query never needs this information, so it never opens a type table at all. Type awareness is only referenced on the searches that ask for it.

The second change is incremental builds. Each source file is a self-contained tree file, and each source set writes its own tables and its own Trigrep shard. Re-parsing one changed file rewrites that file and its shard, and re-assembly re-stitches only the chunks that changed. Keeping the index current across thousands of repositories becomes a byproduct of ordinary builds rather than a reindex-the-world job. For an agent working against a codebase that shifts through the day, the index reflects the code as it is now.

One more thing: it doesn’t stop at your code

Everything above assumes the type chain comes from your repositories and their dependencies. The same machinery works on code you never explicitly checked out. Trigrep can query LSTs built for public artifacts. LSTs can be built and indexed from third-party open source code by pulling its sources and POM, resolving its dependencies, and stitching the type tables together. So the same type-aware search runs over your open source dependencies the same way it runs over your portfolio. The engine doesn’t care whether the source started in your monorepo or in Maven Central.

That’s the idea behind the Code Genome Project: sequencing the public open source ecosystem into compiler-accurate LSTs so the code the world runs on can be searched by type, symbol, and call site rather than string match. The same Trigrep engine powers the search, and a remote MCP server opens the corpus to coding agents. It’s the type chain pointed at code that everyone uses.

Search became a type problem

Trigrep is still fast, and that still matters. But what changes how you and your agents use it is the type intelligence behind the speed. Trigrep understands the whole chain of types, including the libraries underneath your code, and it puts that understanding in the index where a query reaches it in the same instant it reaches a piece of text.

That’s what a coding agent needs from search: a way to ask a type-dependent question and get a type-accurate answer, cheap enough to ask a hundred times a session, and precise enough that the answer is the same set of files a recipe or your IDE would have found. A faster grep was only ever the starting point.

To try the type-aware filters against your own codebase, the Trigrep documentation has the full filter reference and query syntax, or try Trigrep with the Code Genome Project.

Written by Bryan Friedman