DEV Community

Cover image for Strata: I Got Tired of Retrieval-Based Code Intelligence, So I Built Something Else
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Strata: I Got Tired of Retrieval-Based Code Intelligence, So I Built Something Else

A technical deep-dive into exhaustive repository analysis with LLMs.


Every code intelligence tool I've used works the same way: you ask a question, it finds three relevant chunks using embeddings or keyword search, it answers from those three chunks.

That design made sense in 2021. Inference was expensive, context windows were small, and reading an entire repository in one pass was a non-starter.

None of that is true anymore.

A mid-size C project like Redis contains roughly 10,000 functions. At one LLM call per function, analyzing the whole thing costs an afternoon of inference and somewhere between $2 and $20 depending on your model. That's not a recurring cost — you do it once, you cache the results, and every question afterward is answered from a complete index rather than a lucky search.

The inversion that makes this interesting: instead of choosing which parts of the codebase to read, you just read everything. The hard part is no longer selection. The hard part becomes structuring the output so it's useful.

I built Strata to explore what happens when you take that inversion seriously.


The core idea: layers

The name comes from the architecture. Analysis happens in seven passes, each building on the one beneath, from raw syntax at the bottom to project philosophy at the top.

L6  History & Philosophy     ← "What does this project value?"
L5  Security Attention Map   ← "Where should a reviewer look first?"
L4  Dead Code                ← "What can safely be deleted?"
L3  Documented vs Actual     ← "How badly has the architecture drifted?"
L2  Module/Subsystem Rollup  ← "What does each module do?"
L1  Function Summaries       ← "What does each function do?"
L0  Structure                ← "What functions exist? Who calls whom?"
Enter fullscreen mode Exit fullscreen mode

Every pass writes to a content-addressed SQLite cache. Re-runs are free — only changed files re-analyze. Bumping a pass version (e.g., "L1_v2") invalidates exactly that pass without touching the others.


L0: Structure without LLM

The foundation. L0 runs tree-sitter on every source file, extracts the symbol table, builds the call graph, and records git blame attribution. No LLM touches this layer.

Getting this right matters disproportionately because everything above it hangs off it. If L0's call graph is wrong, L4's dead code detection produces garbage, and L5's security paths become fictional.

The tree-sitter query for C function extraction looks like this:

_FUNC_DEF_QUERY_SRC = """
(function_definition
  declarator: (function_declarator
    declarator: (identifier) @name)
) @func
"""

# In tree-sitter 0.25, QueryCursor is the execution API
def caps(query, node):
    return QueryCursor(query).captures(node)
Enter fullscreen mode Exit fullscreen mode

One subtlety: tree-sitter's Python bindings changed their API between 0.22 and 0.25. In 0.22, query.captures(node) was a method on the Query object. In 0.25, Query is just a compiled pattern — you need QueryCursor(query).captures(node) to execute it. This is the kind of thing that silently produces empty results rather than erroring, so watch for it.

The blame optimization matters too. A naive implementation calls pygit2.blame() once per symbol:

# SLOW: one I/O-heavy blame call per symbol
for sym in file.symbols:
    author = primary_blame(repo_root, rel_path, sym.line_start, sym.line_end)
Enter fullscreen mode Exit fullscreen mode

Redis has ~30 symbols per file × 324 files = ~10,000 blame calls. Each one opens the git repository, walks the history, and deserializes hunk data. At 1-2 seconds per call, that's hours.

The fix is obvious once you see it — load blame once per file, reuse for all symbols:

# FAST: one blame call per file
blame_cache: dict[str, dict] = {}

for file_path in files:
    if rel not in blame_cache:
        blame_cache[rel] = blame_file(repo_root, rel)   # once

    for sym in info.symbols:
        author = _blame_range(blame_cache[rel], sym.line_start, sym.line_end)
Enter fullscreen mode Exit fullscreen mode

~10x speedup on L0.


L1: Function summaries at scale

This is where the inference budget goes. One LLM call per symbol, all running concurrently up to a configurable semaphore ceiling.

The key design decision is structured output via instructor + pydantic. The model returns a typed FunctionSummary object, not free text:

class Citation(BaseModel):
    file: str
    line_start: int
    line_end: int

class FunctionSummary(BaseModel):
    one_line: str = Field(max_length=180)
    preconditions: list[str]
    invariants: list[str]
    side_effects: list[str]
    error_paths: list[str]
    surprising: str | None
    citations: list[Citation]      # EVERY claim must cite a real line
    confidence: float = Field(ge=0.0, le=1.0)
    static_vs_llm_disagreements: list[str]
Enter fullscreen mode Exit fullscreen mode

Every list item in preconditions, invariants, side_effects, and error_paths requires a citation in the prompt instructions. The validator then checks that cited line ranges actually fall within the symbol's range:

def _validate_citations(summary, file_path, line_start, line_end):
    errors = []
    for c in summary.citations:
        if c.file != file_path:
            errors.append(f"citation file mismatch: {c.file!r}")
        if c.line_start < line_start - 5 or c.line_end > line_end + 5:
            errors.append(f"citation {c.line_start}{c.line_end} outside {line_start}{line_end}")
    return errors
Enter fullscreen mode Exit fullscreen mode

Rather than rejecting out-of-range citations outright (which would waste the inference), we penalize confidence instead:

if val_errors:
    summary = summary.model_copy(
        update={"confidence": max(0.0, summary.confidence - 0.2 * len(val_errors))}
    )
Enter fullscreen mode Exit fullscreen mode

The static_vs_llm_disagreements field is one of the most interesting outputs. When the model mentions a callee that the static call graph doesn't show, it records it. These disagreements usually mean one of three things: dynamic dispatch, function pointers, or macro expansion. All three are exactly the places a human reviewer should pay attention.

The few-shot examples in the prompt are doing a lot of work. Here's the opening of one:

EXAMPLE 1 — simple allocator wrapper

Function: dictCreate  File: src/dict.c  Lines: 97–120

Source:
int *dictCreate(dictType *type, void *privDataPtr) {
    dict *d = zmalloc(sizeof(*d));
    if (d == NULL) return NULL;
    _dictInit(d,type,privDataPtr);
    return d;
}

Response:
{
  "one_line": "Allocates and zero-initializes a new hash table with the given type vtable.",
  "preconditions": ["type is a valid non-NULL dictType pointer [src/dict.c:97]"],
  "side_effects": ["allocates heap memory via zmalloc [src/dict.c:99]"],
  "error_paths": ["returns NULL if zmalloc fails [src/dict.c:100]"],
  ...
  "confidence": 0.97
}
Enter fullscreen mode Exit fullscreen mode

Notice the inline citation format inside list items ([src/dict.c:99]). The model learns to inline citations rather than collecting them only in the top-level citations array.


L2: Map-reduce rollups

L2 summarizes the repository bottom-up. Function summaries → module summaries → subsystem summaries → project summary. The model never sees more than one level at a time.

def _module_prompt(module_path, summaries):
    joined = "\n".join(f"- {s}" for s in summaries[:120])
    return f"""
Summarize the module '{module_path}' in 2–4 paragraphs based on these function summaries.
Cover: what the module does, its key abstractions, notable design decisions, anything surprising.

Function summaries:
{joined}
"""
Enter fullscreen mode Exit fullscreen mode

Capping at 120 function summaries per module prevents token overflow on large files. The cap is generous enough for real-world C files but worth adjusting for codebases with very large files.

The map-reduce structure means Strata scales to any repository size — you never send the entire codebase to a model in a single context.


L3: Architecture drift

This is the most interesting output in the product.

Every long-lived project has drifted from its own documentation. Strata measures it. L3 reads every Markdown file in the repo, extracts the claimed architecture, and diffs it against what L0 and L2 actually found.

DOC_GLOBS = ["README*", "ARCHITECTURE*", "docs/**/*.md", "doc/**/*.md"]

def _divergence_prompt(claimed, actual_modules, actual_imports, rollup_names):
    return f"""
Claimed architecture (from docs):
{json.dumps(claimed, indent=2)}

Actual modules (from code):
{json.dumps(actual_modules[:200], indent=2)}

Identify divergences. Classify each as:
- undocumented_module: code module not mentioned in docs
- layering_violation: import that violates a stated layering rule  
- phantom_abstraction: described as pluggable but only one implementation exists
- ghost_component: documented component that no longer exists in code

Return JSON array: [{{"kind": "...", "description": "...", "evidence": "..."}}]
"""
Enter fullscreen mode Exit fullscreen mode

Four divergence kinds. In practice, undocumented_module is the most common — projects grow new subsystems that never make it into the docs. ghost_component is the most interesting — abstractions that were removed but whose documentation lives on.


L4: Dead code with confidence tiers

Reachability over the call graph from real entry points. The key design decision: three tiers instead of a binary dead/alive classification.

Tier 1: no callers, no exports, no reflection indicators → almost certainly dead
Tier 2: unreachable statically but language has escape hatches → uncertain
Tier 3: reachable only from test files → test-only
Enter fullscreen mode Exit fullscreen mode

Overclaiming tier 1 destroys trust in the whole tool. A dead code report where 30% of "dead" functions turn out to be called at runtime via dlopen or function pointers is worse than no report at all. So we surface the tier prominently and let the user decide.

has_escape = (
    "dlopen" in (sym.get("source", "") or "")
    or name.startswith("_")  # convention: private but possibly used by macros
)

tier = 1 if not has_escape else 2
Enter fullscreen mode Exit fullscreen mode

This is conservative on purpose.


L5: Security attention map (not a vulnerability scanner)

L5 traces paths from untrusted-input sources to sensitive sinks through the call graph.

SOURCE_NAMES = frozenset({
    "read", "recv", "recvfrom", "recvmsg",  # network
    "fread", "fgets", "getline",             # file
    "getenv", "getopt",                      # process
    "readQueryFromClient", "processInputBuffer",  # Redis-specific
})

SINK_NAMES = frozenset({
    "memcpy", "memmove", "strcpy", "strcat",  # memory
    "execve", "execl", "system", "popen",      # exec
    "open", "fopen", "unlink",                 # path
})
Enter fullscreen mode Exit fullscreen mode

The framing matters. This is explicitly not a vulnerability scanner. The UI and documentation say so clearly. It answers "where should a security reviewer spend their first day?" — which is genuinely valuable and honest. A false-positive-riddled bug finder is a liability. A well-ranked attention map is a tool people will actually use.


L6: History and philosophy

Walk every commit. Chunk by quarter. Summarize each chunk. Reduce to eras.

def _quarter(timestamp: int) -> str:
    d = datetime.datetime.utcfromtimestamp(timestamp)
    q = (d.month - 1) // 3 + 1
    return f"{d.year}-Q{q}"
Enter fullscreen mode Exit fullscreen mode

The interesting outputs:

Churn coupling — files that change together frequently, revealing coupling the architecture doesn't admit to:

def _compute_churn_coupling(commits, top_n=20):
    pair_counts: Counter = Counter()
    for c in commits:
        for i, a in enumerate(c.files_changed):
            for b in c.files_changed[i+1:]:
                pair = tuple(sorted([a, b]))
                pair_counts[pair] += 1
    # normalize by minimum individual change count
    ...
Enter fullscreen mode Exit fullscreen mode

Knowledge silos — subsystems where one author wrote >80% of the code and no one else has significantly touched it. These are bus factor risks.

Implicit values essay — a final LLM call that reads the entire commit history and argues for what the project consistently chose when forced to trade off between speed, simplicity, compatibility, and safety. The prompt asks for evidence from the commit record, not assertions.


The visualization

The static site uses d3-hierarchy's treemap layout. The key design constraint: it must be fully static, deployable to a CDN, with no server. All data is precomputed JSON.

const treemap = d3.treemap<HierarchyDatum>()
  .size([width, height])
  .paddingOuter(4)
  .paddingInner(1)
  .paddingTop(18)
  .round(true)
Enter fullscreen mode Exit fullscreen mode

Five lenses, each a different color function over the same cell data:

export function cellColor(sym: SymbolCell, lens: Lens): string {
  switch (lens) {
    case 'churn': {
      const age = clamp((NOW - parseDate(sym.last_changed)) / ONE_YEAR, 0, 5) / 5
      return lerp('#e63946', '#264653', age)  // red=recent, blue=old
    }
    case 'security':
      if (sym.security_paths >= 3) return '#e63946'
      if (sym.security_paths >= 1) return '#f4a261'
      return '#264653'
    case 'dead':
      if (sym.dead_tier === 1) return '#6b6b6b'
      // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Switching lenses is instant — all data is already in the precomputed JSON, no fetches required.


The cache architecture in full

┌─────────────────────────────────────────────────┐
│  cache table (SQLite)                           │
│                                                 │
│  key         TEXT PRIMARY KEY                   │
│              sha256(file_hash + pass_id         │
│                     + prompt_hash)              │
│                                                 │
│  pass_id     TEXT    "L1_v1", "L2_v1", ...     │
│  file_path   TEXT    relative to repo root      │
│  symbol      TEXT    qualified name (nullable)  │
│  created_at  INTEGER unix epoch                 │
│  result_json TEXT    the LLM output             │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • Same file, same prompt, same pass version → cache hit, zero cost
  • File changes → file_hash changes → cache miss for that file only
  • Prompt edit → prompt_hash changes → cache miss for all files in that pass
  • Pass version bump → pass_id changes → same effect as prompt edit, but explicit

Old cache rows are never deleted — they become historical records. Useful for seeing how your summaries improved as you refined the prompts.


What I learned

Retrieval is a crutch, not a constraint. The reason every code intelligence tool uses retrieval is cost, not correctness. Once you remove the cost constraint, the design space opens up significantly.

Citations are load-bearing. The requirement that every claim cite a real source line is not just a correctness measure — it changes the model's behavior in ways that improve overall quality. The model stops making confident assertions about things it can't point to.

The call graph disagreement field is underrated. I added static_vs_llm_disagreements mostly as a correctness measure, but it turns out to be a useful output on its own. When the model says a function calls something the static analysis can't see, that's usually a signal of interesting dynamic behavior.

Confidence surfacing matters. The model's self-reported confidence correlates well with actual accuracy on spot-checks. Surfaces it in the UI, not buried in a tooltip — functions with confidence below 0.6 deserve human review.

The history pass is the one that surprises people. Most engineers have never read a narrative history of the projects they work on. When you give them one argued from the actual commit record, they find things they didn't know: decisions that were made and reversed, coupling that the architecture doesn't admit to, subsystems with a single author who left three years ago.


Running it yourself

git clone https://github.com/harishkotra/strata
cd strata && pip install -e .
cp .env.example .env  # fill in your LLM endpoint

git clone https://github.com/redis/redis /tmp/redis
strata analyze /tmp/redis --passes 0        # L0 first, no LLM
strata analyze /tmp/redis --passes 1        # L1, watch progress bar
strata analyze /tmp/redis --passes 2,3,4,5,6
strata export /tmp/redis/.strata.db web/public/data

cd web && npm install && npm run dev
Enter fullscreen mode Exit fullscreen mode

The map loads at localhost:5173. Click any cell. Switch lenses. Read the tour. Ask questions:

strata ask "Which functions are most likely to contain memory bugs?"
strata ask "What is the Redis cluster replication protocol?"
strata ask "Which files changed together most often in the last five years?"
Enter fullscreen mode Exit fullscreen mode

App Preview 1

App Preview 2

Code & more: https://www.dailybuild.xyz/project/238-strata

Top comments (0)