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:
| Language | Extensions |
|---|---|
| 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)
| Signal | What it tells you |
|---|---|
codegraph.file.fanIn | Number of files importing this file (afferent coupling) |
codegraph.file.fanOut | Number of files this file imports (efferent coupling) |
codegraph.file.instability | Martin instability fanOut / (fanIn + fanOut), range 0–1 |
codegraph.file.connectionCount | Total file-graph edges fanIn + fanOut (support for instability confidence) |
codegraph.file.isHub | true when fanIn exceeds the collection p95 (heavily depended-upon) |
codegraph.file.isLeaf | true when fanOut is 0 and fanIn > 0 (pure dependency, depends on nothing) |
codegraph.file.transitiveImpact | Distinct files that transitively import this file (reverse BFS, depth-capped at 5) — the real blast radius |
Symbol-scope signals (call graph)
| Signal | What it tells you |
|---|---|
codegraph.chunk.fanIn | Distinct call sites invoking this symbol (method-level fan-in) |
codegraph.chunk.fanOut | Outgoing calls from this symbol (method-level fan-out) |
codegraph.chunk.pageRank | PageRank over the call graph (damping 0.85, normalized 0–1) — recursive importance |
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):
| Tool | Returns |
|---|---|
get_callers | Symbols that invoke the given symbolId (who depends on this) |
get_callees | Symbols invoked by the given symbolId (what this depends on) |
find_cycles | Strongly-connected components (cycles ≥ 2) in the import graph (scope: "file") or call graph (scope: "method") |
get_architecture_report | Architecture 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_lexicon | The 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_report | Project-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_changes | Every 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_changed | Co-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 bylimit/offset, with the whole knot'sinstabilitySpread; - 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: 0means the greedy cut took the edge needlessly and it can stay; memberCount,cutEdgeCount,levelsAfterCut, the knot's back-edges, andnextOffsetwhile either list has more;- on
offset: 0only, the findings of the other detectors that lie inside the knot.summarystays 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(defaultHEAD) is read at its merge-base withHEAD, so a branch review covers what the branch changed plus uncommitted work. Untracked files are included; at most 200 changed files are reviewed.filesnarrows the review to the listed files; a listed file with no diff is reviewed whole.pathnext toprojectpoints the review at a linked worktree of the same repository —projectpicks the index,paththe tree.sectionsis 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 answersbuilt: falsewith areason— 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,confidenceandlastCoChangeAtper 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 undernotJudgedasnoCohesionData, never scored zero. Up to 50 reports. -
architecture— the edges the change adds, judged by the boundary detectors:stableDependencies,leakingAbstraction,cycles,mainSequence(delta),silentCoupling,facadeContractandsplitCandidates.facadeContractflags 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.splitCandidatesflags a change that spans two co-change clusters of one split candidate, or touches both sides of a merge candidate.detectorsgives a status per detector, includingbuilt: falsewith its reason. Up to 100 findings.Each finding names what it judged in
endpoints, a typed object whosekindsays 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) orcomponentPair(merge). The field names match theget_architecture_reportviolations, so a finding can be matched to the whole-repo report without parsing text.evidenceis 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.subjectis the same endpoints rendered as one line, anddetailis the sentence a reviewer reads.stableDependenciesandmainSequenceweigh only the edges the change adds.cyclesandleakingAbstractionjudge 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 carriespreExisting: 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 assummary.layering.foundation),compositionRoot(an SDP finding on a component that assembles the application) andentryPoint(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):
| Preset | Requires | Use case |
|---|---|---|
blastRadius | codegraph + git | Rank by how much a change ripples out (fan-in + transitive impact + churn) |
architecturalHub | codegraph + git | Find the load-bearing files everything depends on |
dangerous | codegraph + git | High blast radius and high bug-fix rate — change with care |
entryPoint | codegraph | Leaf/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:
| Key | Signal | Scope |
|---|---|---|
fanIn | Normalized files importing this file | file |
fanOut | Normalized files this file imports | file |
fanOutPerLine | Efferent coupling per line of code | file |
instability | Martin instability (already 0–1) | file |
isHub | 1 when file is a hub (fanIn > p95) | file |
isLeaf | 1 when file is a leaf | file |
transitiveImpact | Normalized count of transitive importers | file |
chunkFanIn | Normalized method-level fan-in | symbol |
chunkFanOut | Normalized method-level fan-out | symbol |
pageRank | Normalized PageRank (recursive importance) | symbol |
Configuration
| Variable | Default | Description |
|---|---|---|
CODEGRAPH_ENABLED | true | Master switch for the codegraph trajectory family. false disables extraction, signals, and tools. |
CODEGRAPH_DB_PATH | data dir | Override 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_THREADS | 2 | DuckDB 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
- Git Enrichments — the history-based signal family codegraph composes with
- Code Quality Metrics — fan-in/fan-out, instability, and centrality theory with research references
- MCP Tools Atlas — full tool reference including
get_callers,get_callees,find_cycles,find_symbol,review_changes - Configuration Variables — full list of all configuration options