Skip to main content

Codegraph Enrichments

Codegraph enrichment is on by default. Resolution recall varies by language; see Supported Languages. Opt out with CODEGRAPH_ENABLED=false.

While git enrichments answer "how has this code behaved over time?", codegraph enrichment answers "how is this code connected right now?". tea-rags extracts your project's call graph and import graph into a per-project DuckDB database, then attaches structural graph signals — fan-in, fan-out, instability, PageRank, transitive impact — to every indexed chunk. Your agent can rank by architectural importance and blast radius, not just relevance and history.

What It Is​

Codegraph is a trajectory enrichment family (internal key codegraph.symbols). At index time, per-language tree-sitter walkers extract symbols, imports, and call sites; per-language resolvers turn those into graph edges stored in DuckDB (one .duckdb file per indexed project under <dataDir>/codegraph/). Two graphs are built:

  • Import graph (file-to-file) — which files import which, used for file-level coupling signals.
  • Call graph (symbol-to-symbol) — which functions/methods call which, used for symbol-level signals and cycle detection.

For the theory behind these metrics (Henry & Kafura fan-in/fan-out, Martin instability, PageRank centrality and bug-proneness), see Code Quality Metrics.

Enabling and Disabling Codegraph​

Codegraph is on by default. Each index run records the value it used in the project registry, and later runs — CLI, MCP server, auto-update — replay it, so every project keeps its own choice. To index a project without codegraph, set CODEGRAPH_ENABLED=false for the run or pin it:

tea-rags projects set-env --name <alias> CODEGRAPH_ENABLED=false

While disabled, the entire family is dropped for that project — no graph extraction, no graph signals on payloads — and a process with the flag off does not register the codegraph MCP tools (get_callers, get_callees, find_cycles). Re-index after enabling so payloads carry the new signals.

Projects indexed while codegraph was opt-in keep the state they were indexed with: the first start of this release records false for every project whose index holds no codegraph data, so nothing is rebuilt and no drift asks for --force-enrichments codegraph. Turn it on for one with tea-rags projects set-env --name <alias> CODEGRAPH_ENABLED=true and reindex.

Supported Languages​

Graph extraction runs for 8 languages across 15 extensions:

LanguageExtensions
TypeScript.ts, .tsx, .mts, .cts
JavaScript.js, .jsx, .mjs, .cjs
Python.py
Ruby.rb
Go.go
Java.java
Rust.rs
Bash.sh, .bash

Files in other languages are still indexed and embedded by tea-rags — they just carry no codegraph signals.

What You Get​

Codegraph computes signals at two scopes:

File-scope signals (import graph)​

SignalWhat it tells you
codegraph.file.fanInNumber of files importing this file (afferent coupling)
codegraph.file.fanOutNumber of files this file imports (efferent coupling)
codegraph.file.instabilityMartin instability fanOut / (fanIn + fanOut), range 0–1
codegraph.file.connectionCountTotal file-graph edges fanIn + fanOut (support for instability confidence)
codegraph.file.isHubtrue when fanIn exceeds the collection p95 (heavily depended-upon)
codegraph.file.isLeaftrue when fanOut is 0 and fanIn > 0 (pure dependency, depends on nothing)
codegraph.file.transitiveImpactDistinct files that transitively import this file (reverse BFS, depth-capped at 5) — the real blast radius

Symbol-scope signals (call graph)​

SignalWhat it tells you
codegraph.chunk.fanInDistinct call sites invoking this symbol (method-level fan-in)
codegraph.chunk.fanOutOutgoing calls from this symbol (method-level fan-out)
codegraph.chunk.pageRankPageRank over the call graph (damping 0.85, normalized 0–1) — recursive importance
Why two fan-in's?

codegraph.file.fanIn and codegraph.chunk.fanIn measure different graphs — file imports vs. method call sites — so they are not interchangeable. A file with low import fan-in can still contain a method everyone calls. Standard alpha-blending between file and chunk does not apply to codegraph signals for this reason.

MCP Tools​

When codegraph is enabled, these graph-query tools become available (they read the pre-computed DuckDB graph directly — no embedding):

ToolReturns
get_callersSymbols that invoke the given symbolId (who depends on this)
get_calleesSymbols invoked by the given symbolId (what this depends on)
find_cyclesStrongly-connected components (cycles ≥ 2) in the import graph (scope: "file") or call graph (scope: "method")
get_architecture_reportArchitecture violations with per-line evidence. Stable Dependencies Principle judged on components (every directory with a facade file, adoption aside, else directories — one partition shared by every component detector) — a stable component depending on a less stable one, grouped into root causes by unstable target, with the file edges that carry it; plus leaking abstractions, silent coupling, main-sequence distance (zone of pain / uselessness, abstractness from the walker type census) and the inferred layer stack: the same DOMAIN graph (every directory with a facade file counts once, adoption aside), SCC-condensed longest-path levels (L0 = foundation), coverage, rank-correlation coherence, and findings for knots (multi-component cycles, with the lightest feedback-arc cut that levels them), back-edges, abstraction bypasses, plus informational composition cycles and islands. Scripts, spikes, benchmarks, examples and fixtures are left out. Optional pathPattern scopes the judged edges by source file; an optional layerMap object (scopePathPattern, granularity: "directory" | "file", directoryDepth) adds the layerMap view to the response — per-node levels inside a scope at directory or file granularity, boundary edges naming the outside component and its global level, knots among the map's own nodes (each marked composition when it is just a module cycling with its own sub-parts), and move candidates (inner nodes nothing inside depends on whose outward edges all point into one other domain). A domain directory root ("src/core/domains/explore") judges that directory AS ITS OWN SYSTEM instead: every detector runs over the induced sub-graph, and response.domain reports the domain's own component/level counts plus its border — boundaryOut/boundaryIn edges aggregated per component pair, each naming the external component and its whole-graph level. A norms: true flag adds the dependency-norms view: every typed file edge judged against the project's own precedent ledgers per (source role, target role, locality), one ledger per language family, roles derived from each file's primary type — a misfit is an edge running against the direction its role pair has established (an exact sign test of A→B against B→A, Benjamini–Hochberg over the pairs), with both counts and the established direction as evidence; edges touching an untyped or suffix-only file are never judged. Absent from the response unless requested. summary.splitMerge compares the component partition with git history: split candidates (one component whose files change in separate groups) and merge candidates (two components that change together). Knot findings are capped and carry evidence.drillDown; a knotOf call pages through one knot — see Knots and drill-down
get_naming_lexiconThe project's naming vocabulary: names per declaration kind for given types / anchors, a CONFORMS / MISFIT / NEW_TERM / NO_CONVENTION / COLLISION verdict per draft in names (values, and types or constants with kind: "type"), project terms for a concept (with language). Reviewing the names a diff adds moved to review_changes
get_ontology_reportProject-wide naming ontology audit over declared identifiers: synonyms (one type, many names), homonyms (one name, many types), outliers (a name off its type's dominant naming shape) and collisions (a name equal to another symbol). Ranked, with counts and one example location each. The opt-in verbs section lists, per language and noun, the method verbs the project uses with their holder counts, and the methods whose verb is a misfit, each with a suggestion
review_changesEvery diff-scoped report over one working-tree change in one call: naming (the names the change declares, judged against the project's vocabulary), incompleteChange (co-change partners of the changed files that the change does not touch), cohesion (per changed file, groups of symbols that change together and split candidates) and architecture (the change's new edges judged by the boundary detectors). See Reviewing a change
find_co_changedCo-change partners of the given files from git history — support, both directed confidences, Wilson lower-bound strength, lift, up to three sample commits, and structurallyLinked (an import / method edge / re-export barrel chain joins the pair; false = silent coupling). Ranked by strength; partners deleted from the working tree are dropped; built: false means no co-change build yet, not "no partners"

Model edges. When residual resolution with Kev has run, get_callers / get_callees entries it produced carry origin: "model", model and p (the model's confidence). Entries without origin are static. Treat a model edge as probabilistic. trace_path steps carry no origin.

Call-graph resolution scope. get_callers / get_callees walk the run-time hierarchy (Class Hierarchy Analysis), which reaches structural implementers too: a TypeScript class or object-literal factory that satisfies an interface or an object-type alias without implements, and a Python class that satisfies a typing.Protocol without subclassing it, descend from that contract in the cone. A host-class id whose member is actually defined by an included module or concern — Account.suspended when suspended lives in Account::Suspensions — resolves through the class's MRO to the definer; the response names it in resolvedSymbolId whenever the id you asked for differs from where the edges live. Either tool also takes relativePath in place of symbolId to answer at file scope straight from the import graph — the files importing it (get_callers) or the files it imports (get_callees), heaviest first by call weight. Where the graph records a symbol's declared visibility, get_callees targets, get_callers callers, trace_path steps and find_symbol outline members all carry it (private / protected / public), omitted rather than guessed when the graph has no answer.

trace_path distinguishes a genuine read failure from "no path exists": an unreadable or corrupt graph surfaces its typed error (INFRA_CODEGRAPH_DATABASE_MISSING and friends) instead of answering an empty path list, while a project the codegraph never indexed still answers empty — the same rule every other graph tool follows on an unbuilt graph. No graph read, successful or not, ever creates a database file as a side effect.

Knots and drill-down​

A layering knot is a cycle across several components. Knot findings in get_architecture_report are ranked by the instability spread of their members (instabilitySpread: the most unstable member's instability minus the most stable one's). A spread above 0 means a stable component is caught in a cycle with volatile ones — a Stable Dependencies break inside the knot, which ranks above a tangle of equally volatile members.

A large knot can hold thousands of components, so each finding carries a capped evidence list: the first 20 members by afferent coupling plus memberCount, and the first 10 edges of the feedback-arc cut by call weight plus cutEdgeCount. Composition cycles are capped the same way. To see the rest, every knot finding carries evidence.drillDown: a knotOf component and, when the members share a directory, a pathPattern.

Pass that component back as knotOf (with offset for later pages) and the response carries a knot view instead of the project report:

  • members as { component, instability, afferentCount, efferentCount }, paged by limit / offset, with the whole knot's instabilitySpread;
  • the cut edges, paged, each with keepCost { recollapsedMemberCount, levelsAfterKeep } — what happens if this one edge is kept while every other cut edge is removed. recollapsedMemberCount: 0 means the greedy cut took the edge needlessly and it can stay;
  • memberCount, cutEdgeCount, levelsAfterCut, the knot's back-edges, and nextOffset while either list has more;
  • on offset: 0 only, the findings of the other detectors that lie inside the knot. summary stays whole-project.

A component outside every knot answers inKnot: false with its level and depth; a component the graph does not hold is an input error.

Naming lexicon​

get_naming_lexicon reads the identifier declarations the codegraph records — params, locals, fields and return types, each with the type it is known to hold. A type comes from an annotation, a constructor, a resolver binding, a finder call (Doc.find(id)), the return of the one method a call resolves to, or — counted apart as name-inferred — a name that holds one type in at least 80% of its typed uses. The shapes it reports (EXACT, QUALIFIED, TAIL, VERB_TYPE, CALLEE_DERIVED, FREE) are induced from those rows, never assumed: a project that names by role gets a FREE-dominant answer and no forced suggestion. Casing per role comes from the language descriptor. An index built before the identifier table existed answers with a driftWarning naming the reindex.

Type and constant names. A draft with kind: "type" carries the file it will live in (path) and, optionally, its planned ancestor (extends). The names come from the type declarations the codegraph records, so an index built before those existed needs a codegraph recompute (--force-enrichments codegraph) before types and constants are judged. A type's expected role is its suffix (…Strategy, …Preset, …Store), taken from the strongest evidence available: the ancestor's family first, then the directory's dominant suffix, then a suffix used across the project. The project-wide suffix only confirms a name; it never makes one a MISFIT, because a suffix popular elsewhere is a guess, not an expectation. A name that lacks its family's or directory's role is MISFIT with the role appended; a short name that already names a type in another module is COLLISION. Term alignment checks each word against the project's established words for the same concept and offers alternatives — on a NEW_TERM when the words literally overlap (for example Predefined where the project writes PredefinedTemplate and PredefinedField), and also on a CONFORMS when the draft's head is a synonym of an established term recognized by the index's own embedding model (EmbeddingBackend → provider, SignalStatistics → stats) — a synonym confirmed only by a matching project suffix is exactly what a bare CONFORMS would otherwise wave through. The similarity floor is the project's own null distribution of unrelated head pairs, corrected for how many candidates the draft was scored against, so it holds the same ~10% false-positive rate regardless of the draft's directory or family size. That verdict stays soft: the agent decides whether to reuse the term.

A value draft (param, local, field) whose type has no name shared by at least two owners in the project gets NO_CONVENTION instead of a bare NEW_TERM, with prefer: { exact?, analogous }: the type's name spelled in the draft's casing (plural for a collection), and names used by analogy — the thin rows' own, then the value names of related types. A return draft keeps NEW_TERM. When a directory's role suffix applies, only a draft that would be its file's primary declaration is held to it, so a helper interface next to an …Ops class is not told to end in Ops.

Reviewing a change​

review_changes runs every diff-scoped report over one working-tree change in one call. It is registered only when codegraph is enabled.

{
"project": "myapp",
"changes": { "base": "origin/main" },
"sections": ["naming", "architecture"]
}
  • changes.base (default HEAD) is read at its merge-base with HEAD, so a branch review covers what the branch changed plus uncommitted work. Untracked files are included; at most 200 changed files are reviewed.
  • files narrows the review to the listed files; a listed file with no diff is reviewed whole.
  • path next to project points the review at a linked worktree of the same repository — project picks the index, path the tree.
  • sections is an allowlist; the default is every section. An unknown id is an error. A section you did not ask for is absent from the answer, and a section whose data is missing answers built: false with a reason — absence never means "nothing found".

The answer sits under review: workTree, base, mergeBase, changedFiles, skipped / truncated, indexLag { indexedCommit, treeCommit } when the index was built at another commit than the tree's HEAD, notices (an empty diff names the trees and bases it did not look at, instead of a bare changedFiles: 0), and sections, keyed by id:

  • naming — the names the change declares, judged against the project's vocabulary; described below.

  • incompleteChange — co-change partners of the changed files that the change does not touch: support, confidence and lastCoChangeAt per pair, up to 50.

  • cohesion — per changed file, the groups of symbols that change together in history, a cohesion score and a split verdict. A file with no data is listed under notJudged as noCohesionData, never scored zero. Up to 50 reports.

  • architecture — the edges the change adds, judged by the boundary detectors: stableDependencies, leakingAbstraction, cycles, mainSequence (delta), silentCoupling, facadeContract and splitCandidates. facadeContract flags a change to a module's facade that stops re-exporting names consumers outside the module still import, judged against the facade's indexed re-export surface. splitCandidates flags a change that spans two co-change clusters of one split candidate, or touches both sides of a merge candidate. detectors gives a status per detector, including built: false with its reason. Up to 100 findings.

    Each finding names what it judged in endpoints, a typed object whose kind says which shape it is: fileEdge (cycles, leaks), componentEdge (an SDP finding: the component pair and the file edge carrying it), filePair (a silent-coupling pair, the changed file first), fileGroup (a grouped silent-coupling finding), file (a facade), component (main-sequence shift, split) or componentPair (merge). The field names match the get_architecture_report violations, so a finding can be matched to the whole-repo report without parsing text. evidence is structured per detector (instabilities, the cycle path, the D and I deltas with their fan counts, the dropped names and their consumers, the touched clusters), with numbers at 3 decimals. subject is the same endpoints rendered as one line, and detail is the sentence a reviewer reads.

    stableDependencies and mainSequence weigh only the edges the change adds. cycles and leakingAbstraction judge every import of a changed file as it now reads, so they also report a cycle or a facade bypass the change merely touches. Such a finding carries preExisting: true: the index already holds that edge, the problem predates the change. Three more markers are triage data, never suppressions: foundationTerminal (the edge ends in the measured foundation: a bottom-level component whose types are all abstract, the same set the layering report lists as summary.layering.foundation), compositionRoot (an SDP finding on a component that assembles the application) and entryPoint (a main-sequence shift on a cli or mcp entry component, which belongs at the unstable end).

Reviewing a diff (the naming section). changes: {} reviews the working tree against HEAD, untracked files included; changes: { base } reviews a branch. The base is read at its merge-base with HEAD (git merge-base <base> HEAD), so base: "origin/main" reviews what the branch changed, plus uncommitted work, however far origin/main has moved on since the branch left it — the three-dot view a reviewer means, not a diff against the base's tip. A commit HEAD descends from is its own merge-base, so passing a sha pins the comparison exactly. When the base shares no history with HEAD (unrelated roots, or a shallow clone that cut the fork point off) the call fails and says so. The answer reports the commit it used (mergeBase) and how many files differ from it (changedFiles). files: [...] narrows the review to the listed files: a file with a diff is reviewed by its added hunks, and a file with none — committed code on a clean tree — is reviewed whole, every declaration it holds, and counted in wholeFiles. Only declarations inside added hunks are judged, so an unchanged name in a touched file is not reported. The changed files are left out of every evidence read, so a change an incremental reindex has already stored cannot vote for itself. One call covers at most 200 changed files; the rest are reported as truncated. Test and other non-production files are skipped, as are files no codegraph language walks; both count toward notJudged.

The section's answer sits under review.sections.naming. Declarations that conform are only counted (conforming), and so are novel ones: a NO_CONVENTION, or a NEW_TERM with no topTerms and no alternatives, where the project has nothing to compare the name with, so there is nothing to act on. Everything else is a finding with its file and line. A finding carries its verdict's fields: suggestion and holder (or role) on a MISFIT, existing on a COLLISION, topTerms and alternatives on a NEW_TERM. A name judged generic is listed even when it conforms, with genericName. checked = conforming + novel + findings.

checked counts only what was judged. A method whose return type is unknown — most of them in Ruby — still becomes a draft: it is judged against the project's method vocabulary instead of a type, so it counts in checked like any other name. A verb the project uses conforms and a rare one is a NEW_TERM; a method is never asked to swap its verb for another verb of the same noun, because find_user and build_user are different operations. A method a macro composes — has_one :account adds build_account, create_account and account= — is not a draft: its name is not written on its declaration line, so the review neither judges nor counts it. Only files go unjudged. notJudgedBy says what the review skipped, per reason: { "file": { "nonProduction": 3, "noCodegraphLanguage": 1 } }. Reasons are nonProduction, noCodegraphLanguage and unreadable, and they add up to notJudged. notJudgedNames lists the first 50 of them with path, line and name, so a reviewer knows what still needs reading by hand. An excerpt from a live run on this repository:

{
"review": {
"sections": {
"naming": {
"built": true,
"base": "HEAD",
"checked": 163,
"conforming": 93,
"novel": 58,
"findings": [
{
"relPath": "src/core/api/internal/ops/naming-lexicon-ops.ts",
"line": 373,
"name": "req",
"kind": "param",
"type": "NamingLexiconRequest",
"verdict": "NEW_TERM",
"topTerms": [],
"genericName": { "typeCount": 7, "n": 10 }
},
{
"relPath": "src/core/domains/trajectory/git/provider.ts",
"line": 846,
"name": "meta",
"kind": "local",
"type": "GitFileSignals",
"verdict": "MISFIT",
"suggestion": "fileSignals",
"holder": "assembleFileSignals"
}
],
"notJudged": 19
}
}
}
}

The excerpt keeps two of the run's 12 findings.

Use names for a single proposed name or a rename; a whole change is reviewed by review_changes (its naming section). get_naming_lexicon no longer accepts changes or files.

Ontology report​

get_ontology_report counts a declaration as evidence only when its type is known — annotated, constructor, resolver binding, finder or the return type of the single method it is bound to — and never from a name-inferred type, so the report cannot confirm its own convention. Names bound to many unrelated types (result, data, item) are detected from the data, dropped from every section and listed in the summary; primitive and top types per language are ignored. An index built before the identifier table existed answers a driftWarning instead of an empty, clean-looking report.

The verbs section is left out unless requested in sections. It groups methods by language and noun tail and lists the verbs the project uses for each noun with their holder counts, plus the methods whose verb the method vocabulary judges a MISFIT, each with its suggestion.

These pair naturally with find_symbol, which resolves a name to a symbolId using the same Class#method (instance) / Class.method (static) convention the codegraph tools consume.

Use Cases​

What would break if I change this function? Show me its callers

Find the architectural hubs in this codebase

Are there any circular imports between modules?

Is this codebase laid out correctly? Which modules depend on less stable ones?

Show me entry-point files nothing else imports from

Review my branch against main before I open the MR

What does this service depend on transitively?

Reranking Presets​

Codegraph signals power composite presets that blend the structural graph with git history. These presets are only available when codegraph is enabled (they declare a requires dependency and are silently dropped otherwise):

PresetRequiresUse case
blastRadiuscodegraph + gitRank by how much a change ripples out (fan-in + transitive impact + churn)
architecturalHubcodegraph + gitFind the load-bearing files everything depends on
dangerouscodegraph + gitHigh blast radius and high bug-fix rate — change with care
entryPointcodegraphLeaf/entry files — natural starting points for onboarding

Enabling codegraph also upgrades the shared presets (hotspots, techDebt, codeReview, ownership, securityAudit) to composite versions that factor structural coupling into their scoring.

Scoring Weights Reference​

Weight keys available for custom reranking (rerank: { "custom": { ... } }) when codegraph is enabled:

KeySignalScope
fanInNormalized files importing this filefile
fanOutNormalized files this file importsfile
fanOutPerLineEfferent coupling per line of codefile
instabilityMartin instability (already 0–1)file
isHub1 when file is a hub (fanIn > p95)file
isLeaf1 when file is a leaffile
transitiveImpactNormalized count of transitive importersfile
chunkFanInNormalized method-level fan-insymbol
chunkFanOutNormalized method-level fan-outsymbol
pageRankNormalized PageRank (recursive importance)symbol

Configuration​

VariableDefaultDescription
CODEGRAPH_ENABLEDtrueMaster switch for the codegraph trajectory family. false disables extraction, signals, and tools.
CODEGRAPH_DB_PATHdata dirOverride the graph-DB root directory. Per-project files at <rootDir>/codegraph/<collection>.duckdb.
CODEGRAPH_DB_MEMORY_LIMIT"2GB"Per-project DuckDB RAM ceiling before spilling to a temp dir (prevents OOM on large repos).
CODEGRAPH_DB_THREADS2DuckDB worker threads per project. The writer lock — not parallel scan — is the bottleneck, so more threads inflate memory without speeding up.
CODEGRAPH_CUSTOM_EXCLUDE(empty)Comma-separated .gitignore-shaped patterns added to the exclusion filter, e.g. vendor/**,generated/**,*.pb.go.
CODEGRAPH_AMBIGUOUS_RESOLVE_MODE"strict"How to resolve short-name calls matching multiple candidates. strict drops the edge unless exactly one match; first picks the first candidate (higher recall, more noise).

Test files and generated files are always kept out of the graph, and that is not configurable. A test calls production code and nothing calls it, so its edges inflate the fan-in and PageRank of whatever it touches without describing any real dependency. Both kinds are still indexed by Qdrant and stay searchable — only graph extraction skips them.

Next Steps​