codecharter graph overview [path] [options]
codecharter graph neighborhood <target> [path] [options]
codecharter graph impact <target> [path] [options]
codecharter graph cycles [path] [options]
codecharter graph hubs [path] [options]
codecharter graph paths <from> --to <to> [path] [options]
codecharter graph diff --base <ref> [path] [options]
codecharter graph projects your analyzed solution into a coupling graph and
answers seven questions over it: what does the solution look like from above
(overview), what does one type connect to (neighborhood), what breaks
if I change this type (impact), where is the graph not a DAG
(cycles), which nodes are load-bearing (hubs), does every path between
two nodes pass through a given chokepoint (paths), and — comparing two git
refs — did a branch make the architecture worse (diff). It is built for two
audiences at once: a developer on the command line who wants a quick
orientation, and an AI coding agent that needs to understand a codebase
without reading every file first.
The same seven questions are also available as MCP tools; see
With an AI assistant below.
Nodes are solution types (plus, at the aggregated overview level, projects
or namespaces). Edges come from declared inheritance, interface
implementation, member/parameter/return/field/event types, resolved
method-body calls, applied attributes, and object-creation expressions — see
Edge semantics and limits for exactly what that
does and does not cover.
The agent workflow: orient, zoom, assess blast radius, find cycles, find hubs, check chokepoints, compare branches
The seven subcommands are meant to be used in sequence. An agent that has
just been handed an unfamiliar repository typically works through most of
them before it edits anything, and hubs is the one to reach for first: it
answers "where is this codebase load-bearing?" before an agent
touches anything, which overview's top-edges view does not (a type with
fan-in 60 spread across 60 different callers never shows up as a heavy
edge, yet it is exactly the type that must not be changed casually).
1. Orient: overview
Start with the shape of the solution: which projects or namespaces exist, and which ones are the most heavily coupled.
codecharter graph overview --level projects --top 10
Totals: 4 project(s), 31 namespace(s), 268 type(s)
Nodes (4):
Acme.Domain 52 type(s)
Acme.Infrastructure 74 type(s)
Acme.Web 96 type(s)
Acme.Tests 46 type(s)
Edges (6, weight desc):
Acme.Web -> Acme.Infrastructure (weight 58)
Acme.Infrastructure -> Acme.Domain (weight 47)
Acme.Web -> Acme.Domain (weight 22)
Acme.Tests -> Acme.Web (weight 19)
Acme.Tests -> Acme.Infrastructure (weight 11)
Acme.Tests -> Acme.Domain (weight 6)
The edge-semantics notice and, when the edge list was cut off, a truncation
note are written to stderr on every call; the example above shows stdout
only. --level namespaces (the default) aggregates one level deeper: each
node id becomes <assembly>|<namespace full name>, because the same
namespace name can exist in more than one assembly.
2. Zoom in: neighborhood
Once you know where the coupling is, resolve one type and look at its immediate surroundings — what it inherits from, implements, uses, is used by, calls, and is called by.
codecharter graph neighborhood OrderService --depth 1
Focus: Acme.Domain.OrderService (class) src/Acme.Domain/OrderService.cs:12
public OrderService(IOrderRepository repository, IClock clock)
public async Task<Order> PlaceAsync(OrderRequest request, CancellationToken cancellationToken)
public async Task CancelAsync(int orderId, CancellationToken cancellationToken)
Implements (1):
Acme.Domain.IOrderService (interface) src/Acme.Domain/IOrderService.cs:8 distance 1 [Implements]
Uses (2):
Acme.Domain.IOrderRepository (interface) src/Acme.Domain/IOrderRepository.cs:6 distance 1 [Uses]
Acme.Domain.IClock (interface) src/Acme.Domain/IClock.cs:4 distance 1 [Uses]
Used by (3):
Acme.Web.OrdersController (class) src/Acme.Web/OrdersController.cs:15 distance 1 [Uses]
Acme.Tests.OrderServiceTests (class) tests/Acme.Tests/OrderServiceTests.cs:9 distance 1 [Uses]
Acme.Infrastructure.OrderServiceRegistration (class) src/Acme.Infrastructure/OrderServiceRegistration.cs:5 distance 1 [Uses]
target resolves against an exact or case-insensitive full type name, a name
suffix (OrderService matches Acme.Domain.OrderService), or a source file
path — useful when an agent knows which file it is looking at but not the
type's full name. An ambiguous or unresolved target never guesses: it prints
the candidates it found and exits with a usage error.
3. Assess blast radius: impact
Before changing (or removing) a type, ask who depends on it.
codecharter graph impact OrderService
Dependents by distance (2 level(s)):
Distance 1 (3):
Acme.Web.OrdersController (class) src/Acme.Web/OrdersController.cs:15
Acme.Tests.OrderServiceTests (class) tests/Acme.Tests/OrderServiceTests.cs:9
Acme.Infrastructure.OrderServiceRegistration (class) src/Acme.Infrastructure/OrderServiceRegistration.cs:5
Distance 2 (1):
Acme.Web.Program (class) src/Acme.Web/Program.cs:1
Add --source to switch from "who depends on this" to "how does A reach B":
codecharter graph impact IClock --source OrdersController
Paths (1):
Path 1:
Acme.Web.OrdersController (class) src/Acme.Web/OrdersController.cs:15
Acme.Domain.OrderService (class) src/Acme.Domain/OrderService.cs:12 [Uses]
Acme.Domain.IClock (interface) src/Acme.Domain/IClock.cs:4 [Uses]
4. Find cycles: cycles
A dependency cycle rarely announces itself. Looking at one edge at a time
cannot reliably tell "A and B are circular" from "A, B, C, and D are", and
past a handful of namespaces the answer comes out wrong. cycles computes it
exactly: every strongly connected component in the graph, with a concrete
cycle to act on and, per edge, whether removing it alone would break the
cycle.
codecharter graph cycles --level namespaces
Level: Namespaces. Totals: 4 node(s), 6 edge(s), 1 component(s), 2 node(s) in cycles.
Component 1 (size 2):
Members (2):
Acme.Domain (Acme|Acme.Domain) 52 type(s)
Acme.Infrastructure (Acme|Acme.Infrastructure) 74 type(s)
Shortest cycle: Acme|Acme.Domain -> Acme|Acme.Infrastructure -> Acme|Acme.Domain
Closing edges (2):
Acme|Acme.Infrastructure -> Acme|Acme.Domain (weight 47, breaks)
Acme|Acme.Domain -> Acme|Acme.Infrastructure (weight 3, breaks)
A component of size 1 is never reported — a node cannot depend on itself in
this graph. --level namespaces (the default) reads best for architecture
review: small enough to read, and a namespace-level cycle is nearly always a
real problem. --level types finds more, but is noisy — companion and
nested types routinely reference each other — so prefer namespaces unless
you are chasing one specific refactor. --level projects is the strongest
signal (and, since .NET forbids circular project references, is usually
empty — a non-empty result there is worth looking at immediately).
breaksComponent: false does not mean an edge is safe to keep, only that
removing that one edge alone does not break the cycle — closing edges are
sorted with the ones that do first, then by weight ascending, so the first
row is the cheapest actionable cut when one exists. When none does (a dense,
mutually-redundant component), the shortest cycle above it is the
concrete unit to act on instead — pick one edge on that chain to break.
When you already know the type you are worried about, --target goes straight
to it instead of scanning every component:
codecharter graph cycles --target OrderService
This restricts the result to the single component containing OrderService
(--max-components is then ignored), or reports "not part of any cycle" —
resolved the same way neighborhood's target is.
5. Find hubs: hubs
"What is load-bearing here?" drives three concrete decisions: what to read first when onboarding, what to treat as a stable contract (high fan-in, low fan-out) versus a leaf you can rewrite freely, and where to look when a change ripples further than expected.
codecharter graph hubs --top 5
Level: Types. SortBy: FanIn. Totals: 268 node(s), 812 edge(s), 214 ranked.
Hubs (5):
Acme.Domain.OrderService fanIn=74 fanOut=9 weightedFanIn=318 weightedFanOut=14 instability=0.108
Acme.Domain.IOrderRepository fanIn=41 fanOut=1 weightedFanIn=52 weightedFanOut=1 instability=0.024
Acme.Domain.IClock fanIn=38 fanOut=0 weightedFanIn=61 weightedFanOut=0 instability=0
...
fanIn/fanOut count distinct neighbouring nodes — the architecturally
meaningful number, matching the classic afferent/efferent coupling
definition; weightedFanIn/weightedFanOut sum edge weights instead (a
type touched once each by 60 callers has fanIn: 60 but a much smaller
weightedFanIn than a type hit 60 times by one caller). instability = fanOut / (fanIn + fanOut) — 0 means every dependency points in (a
stable, safe-to-depend-on contract), 1 means every dependency points out
(a leaf, safe to change freely); a node with no edges at all has no defined
instability and reports n/a (never 0, since 0 means maximally stable
and an isolated node is not that).
These degrees will not match CouplingAfferent/CouplingEfferent from a
.ccr rule. They are computed over the same edge set the other graph
commands use, so that every hubs result can be cross-checked against
graph neighborhood. Every hubs result carries a notice saying so.
codecharter graph hubs --sort-by instability --min-degree 5
--sort-by accepts fan_in (default), fan_out, or instability. total,
weightedFanIn, and weightedFanOut stay available as fields on every row
regardless of --sort-by. Sorting by instability alone tends to surface
barely-touched nodes that trivially score close to 1; raise --min-degree
(default 1) to look past them. Every result carries a notice saying so
whenever --sort-by instability is used.
codecharter graph hubs --scope Acme.Web
--scope (a namespace or assembly prefix, case-insensitive) restricts which
nodes are ranked to a subtree — useful once the global top-20 turns out to
be a handful of core domain types and you want to know what is load-bearing
within the module you are actually working in. Degrees stay computed over
the whole graph, so a scoped row's fanIn/fanOut remain the honest
global numbers rather than an inward-looking count; every scoped result
carries a notice saying so. A scope that matches nothing returns an empty
ranking plus a notice naming the scope, never a silently empty list.
--level namespaces/--level projects aggregate the same way cycles
does. Intra-aggregate edges are dropped, so a namespace's fanOut counts
only dependencies on other namespaces.
6. Check a chokepoint: paths
impact --source finds a shortest path from A to B. That answers "can A
reach B", not "must every route from A to B pass through this one type" —
the question that actually matters when deciding whether it is safe to
delete a type, or whether a proposed seam genuinely isolates one module from
another. paths answers that question directly, without enumerating every
route between A and B (which is exponential in a real codebase):
codecharter graph paths OrdersController --to IClock --must-pass-through OrderService
allPathsIntercepted: true
Explored 14 node(s). Exhaustive: true.
Every path from OrdersController to IClock happens to pass through
OrderService, so removing or gating OrderService would sever every route.
When that does not hold, the result reports a concrete counterexample instead
of just "false":
codecharter graph paths OrdersController --to IClock --must-pass-through PaymentGateway
allPathsIntercepted: false
Counterexample path:
OrdersController
--Uses--> OrderService
--Uses--> IClock
Explored 9 node(s). Exhaustive: true.
The counterexample is one concrete A-to-B path that never touches
PaymentGateway, proof that the claim does not hold — an agent does not have
to trust the verdict, it can read the path.
Omit --must-pass-through to ask the plain reachability question instead
("can A reach B at all"):
codecharter graph paths OrdersController --to LegacyBillingService
reachable: false
Explored 42 node(s). Exhaustive: true.
The answer is always one of three states, never a truncated guess. A
truncated search reports allPathsIntercepted: null (rendered undecided in
text) rather than a possibly-wrong true, because "held for every path I
looked at" is not the same claim as "holds for every path" — isExhaustive
tells you which one you got, and only isExhaustive: true may ever
accompany a true/false verdict. If from equals --must-pass-through or
to equals --must-pass-through, every path trivially starts or ends at the
chokepoint, so the answer is true without a search. (from equal to to
takes the regular route: the search finds the target immediately, so with a
distinct chokepoint the answer is false, with the single-node path as the
counterexample.) If A cannot reach B at all regardless of the chokepoint,
allPathsIntercepted is also true (vacuously — there are zero paths, so
every one of them, zero of them, passes through X); a notice says so
explicitly so this is never mistaken for a genuine interception.
At --level methods (default types), paths traverses an
on-demand induced subgraph of method call edges — see
paths and method-level honesty below for
what that means for the answer's trustworthiness.
7. Did this branch make it worse: diff
A git diff shows which lines changed. It does not show that the branch
introduced a dependency from Domain to Infrastructure, that a type
gained eleven callers, or that a leaf namespace now points at four others —
exactly the findings a human architect raises in review, and exactly the
ones a line diff hides.
codecharter graph diff --base main
Building the base graph for 'main' (a cache miss costs a restore plus a full analysis; may take a while)...
Base: main @ a1b2c3d (cached). Head: (working tree) @ ?.
Nodes: 1402 -> 1420 (+22/-4). Edges: 5744 -> 5811 (+91/-24/~13).
Added nodes (22):
+ MyApp.Services.OrderReconciler (Class)
...
Removed nodes (4):
- MyApp.Services.LegacyReconciler (Class)
...
Added edges (91):
+ MyApp|MyApp.Domain -Calls-> MyApp|MyApp.Infrastructure (weight=6)
...
Removed edges (24):
...
Changed edges (13):
~ MyApp|MyApp.Api -Uses-> MyApp|MyApp.Services (weight 31 -> 47)
...
Coupling changes (18):
MyApp.Domain fanIn 6 -> 6 fanOut 0 -> 1
...
--base is required; --head defaults to the working tree, so the common
case — "did my branch make things worse against main" — costs nothing
extra beyond the base side. Node identity is the id alone, and no rename
detection is performed: a rename shows up as one removed node plus one added
node, never a single "renamed" entry. Edge identity is (sourceId, targetId, kind); a weight-only
change (a type gaining forty new call sites into another) is a real signal
and is reported as a changedEdges entry rather than folded into
"unchanged".
A cache miss on --base is not free. Comparing against a commit that is
not your current checkout means materializing it as a separate worktree,
restoring its NuGet packages, and running a full solution analysis — tens of
seconds to minutes on a large solution, which is why the CLI prints a
progress note on stderr before starting. Repeat calls against the same
--base are served from a disk cache under .codecharter/cache/graph/
(already covered by this repo's .codecharter/cache/ .gitignore entry) and
are fast. --no-cache bypasses it for diagnostics.
codecharter graph diff --base main --level namespaces --top 10
--level (default types) controls the coupling-change summary's
granularity, the same three-valued enum cycles/hubs use. The
added/removed nodes always stay at type level — that is where the evidence
lives — but at namespaces/projects the added/removed/changed edge lists
are additionally rolled up to the same granularity: a hundred type-level
additions between two namespaces is one architectural fact, not a hundred.
A changed edge's weights are the two sides' summed weights, so the reported
delta is exactly the sum of the underlying per-edge deltas. couplingChanges
is sorted by absolute total-degree change descending and truncated to
--top (default 20).
Options
[path] is shared by all seven subcommands: the path to a .sln, .slnx,
or .csproj file, or a directory to search — omit it, or point it at a
directory, to auto-discover under that directory (the current one when
omitted).
--format and --output-file are shared too. --format accepts text (the
default), json, mermaid, and dot, case-insensitively; cycles, hubs,
paths, and diff accept only text/json, since none of the four has a
diagram writer. With --format json the payload uses the same property
casing, enum casing, and null handling as the MCP tools (camelCase
properties, PascalCase enum values, null properties omitted), so a script can
consume either output the same way for every field they share. The MCP
payload is a superset that additionally carries durationMs and
isWorkspaceWarm, which the CLI JSON does not have. --format mermaid/dot
render the same result as a diagram instead — see
Rendering a diagram below.
--max-tokens is shared by all seven as well — see
Sizing a result: --max-tokens below. So is
--edge-kinds: a comma-separated, case-insensitive list of
inherits/implements/uses/calls/attributes/creates (all six by default) that narrows
which edges are included; see
Edge semantics and limits below. paths at
--level methods does not use --edge-kinds at all — see
paths and method-level honesty.
overview
| Option | Default | Description |
|---|---|---|
--level <projects\|namespaces> |
namespaces |
Aggregation granularity. |
--top <n> |
50 |
Maximum number of aggregated edges to return, sorted by weight (reference count) descending. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens, applied on top of --top. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. |
--format <text\|json\|mermaid\|dot> |
text |
Output format. mermaid/dot render a diagram instead. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
neighborhood
| Option | Default | Description |
|---|---|---|
<target> |
— | Required. The type to resolve: full name, name suffix, or source file path. |
--depth <1-3> |
1 |
Breadth-first hops to expand from the focus type. Clamped to 1-3. |
--direction <out\|in\|both> |
both |
Which edge directions to expand along. |
--signatures <focus\|all\|none> |
focus |
Which nodes to render full member signatures for: only the focus type, the focus type and every distance-1 neighbor, or none. |
--max-types <n> |
50 |
Maximum number of neighbor types to include before truncating. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens, applied on top of --max-types. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. |
--format <text\|json\|mermaid\|dot> |
text |
Output format. mermaid/dot render a diagram instead. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
impact
| Option | Default | Description |
|---|---|---|
<target> |
— | Required. The type whose inbound impact (or, with --source, dependency paths) to compute. |
--source <type> |
none | When given, switches the query from the inbound transitive closure to shortest dependency paths from this type to target. |
--depth <n> |
10 |
Maximum number of hops to search: bounds the closure's distance and the path search's radius. |
--max-nodes <n> |
200 |
Maximum number of nodes to visit before truncating. |
--max-paths <n> |
5 |
Maximum number of shortest paths to return when --source is given. |
--no-through-implementations |
off | Traverse only declared edge directions (an Implements edge from a type to its interface, never the reverse). See below. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens, applied on top of --max-nodes/--max-paths. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. |
--format <text\|json\|mermaid\|dot> |
text |
Output format. mermaid/dot render a diagram instead. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
DI-style code depends on an interface, not its implementation:
ChatService implements IChatService, so the edge runs
ChatService --Implements--> IChatService. Followed in its declared direction
only, a path from a consumer of IChatService never reaches ChatService (or
anything ChatService itself depends on), and the inbound closure of
ChatService misses every consumer that only ever references IChatService.
That is why reversed-implements traversal — walking that edge backward, from
the interface to each type that implements it, on top of every edge the query
already follows — is on by default. Every hop that took this reversed
edge is called out with an implemented by label distinct from the forward
Implements, so the output never hides that the search took a hop the edges
do not declare on their own, and the result reports how many nodes were
reached only through a reversed hop:
codecharter graph impact ChatService --source OrdersController
Paths (1):
Path 1:
Acme.Web.OrdersController (class) src/Acme.Web/OrdersController.cs:15
Acme.Domain.IChatService (interface) src/Acme.Domain/IChatService.cs:4 [Uses]
Acme.Domain.ChatService (class) src/Acme.Domain/ChatService.cs:9 [implemented by]
Pass --no-through-implementations to traverse only declared edge directions
instead. neighborhood and overview do not take this option: they already
render Implements/ImplementedBy as separate directions.
Upgrading from an earlier version: any script or CI job that relies on
graph impactstaying at the interface boundary now needs--no-through-implementations— closures can grow, a--max-nodes-bounded closure can start truncating where it previously completed, and path search can return a different shortest path. The old--through-implementationsflag is still accepted for one release as a no-op; it no longer does anything.
target and --source resolve the same way in both neighborhood and
impact. codecharter graph respects the same .codecharter/config.yml
exclude list codecharter analyze uses,
so a file you keep out of rule analysis is also kept out of the graph.
cycles
| Option | Default | Description |
|---|---|---|
--level <types\|namespaces\|projects> |
namespaces |
Aggregation granularity. types is valid here, unlike for overview. |
--target <type> |
none | Restrict the result to the single component containing this type. --max-components is ignored when given. |
--max-components <n> |
20 |
Maximum number of components to return, biggest first. Ignored when --target is given. |
--max-members-per-component <n> |
25 |
Maximum number of members (and closing edges) per component before truncating. |
--min-size <n> |
2 |
Minimum component size to report. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens. The accumulation unit is a whole component, never a half-populated one. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. |
--format <text\|json> |
text |
Output format. No mermaid/dot yet — see Options above. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
Unlike neighborhood/impact, an unresolved or ambiguous --target is
not a usage error here: it is reported the same way a target that is not
part of any cycle is, as a notice (plus candidates, when ambiguous) with exit
0. Both outcomes mean "nothing to show for this target".
hubs
| Option | Default | Description |
|---|---|---|
--level <types\|namespaces\|projects> |
types |
Aggregation granularity. Unlike cycles, types is the default — "which type is load-bearing" is the question an agent asks first. |
--sort-by <fan_in\|fan_out\|instability> |
fan_in |
Sort key. total, weightedFanIn, and weightedFanOut are always present as fields regardless of --sort-by. |
--scope <prefix> |
none | Namespace or assembly prefix (case-insensitive) restricting which nodes are ranked. Degrees stay computed over the whole graph. |
--top <n> |
20 |
Maximum number of hub entries to return, ranked by --sort-by, ties broken by total degree descending then node id. |
--min-degree <n> |
1 |
Minimum fanIn + fanOut a node needs to be listed. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens. The accumulation unit is a whole hub entry, never a half-populated one. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. Restricted to calls answers "which type is invoked from the most places"; restricted to inherits,implements answers "which abstraction has the most implementors". |
--format <text\|json> |
text |
Output format. No mermaid/dot — a ranked table is not a graph. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
paths
| Option | Default | Description |
|---|---|---|
<from> |
— | Required. The starting type or method to resolve. |
--to <target> |
— | Required. The destination type or method to resolve. |
--must-pass-through <target> |
none | When given, asks whether every path from from to --to passes through this node instead of plain reachability. |
--level <types\|methods> |
types |
Traversal granularity. methods builds an on-demand induced subgraph of method call edges — see paths and method-level honesty. |
--max-tokens <n> |
unset | Approximate payload size budget in tokens; also caps how many nodes the search may explore before the answer becomes undecided. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. Ignored at --level methods. |
--format <text\|json> |
text |
Output format. No mermaid/dot — a tri-state verdict plus one counterexample chain is not a diagram. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
from, --to, and --must-pass-through resolve the same way neighborhood's
target does at --level types (full name, name suffix, or source file
path). At --level methods they resolve against a method's canonical
signature, its declaring-type-qualified name, or a bare method-name suffix,
using the same exact-then-suffix, never-guess resolution order; an ambiguous
or unknown name prints every candidate signature (if any) to stderr and exits
0 with an undecided verdict — mirroring cycles, not the usage-error exit
neighborhood/impact use.
paths and method-level honesty
At --level methods, paths does not read a second, persisted graph level.
It builds the method-call subgraph on demand from the same analyzed model,
walking calls, implements, implemented by, and overridden by edges so
that a call through an interface (the common DI shape: a caller holds
IOrderService, not OrderService) does not dead-end at the interface
method. This keeps the persisted graph at exactly one level of granularity
(types), which is why --level methods belongs to this subcommand alone.
A method-level true verdict carries a caveat that a type-level one does
not. calls edges come from resolved method-body invocations — the same
direct-invocation analysis overview/neighborhood/impact use for their
calls edges — which is an under-approximation: a call reached only
through a delegate, reflection, or a source-generator-emitted body is
invisible to it. That means a method-level false (a counterexample path
was found) is always sound — the path shown genuinely exists — but a
method-level true (every discovered path passes through the chokepoint)
only says "every path this analysis can see does"; a route through an
invisible edge could still exist. The result carries an explicit notice
saying so whenever --level methods is used with a true verdict; the
type-level structural edges do not have this gap and their verdicts carry no
such caveat.
diff
| Option | Default | Description |
|---|---|---|
--base <ref> |
required | Revision to compare against. |
--head <ref> |
working tree | Revision to compare. |
--level <types\|namespaces\|projects> |
types |
Coupling-summary and edge roll-up granularity. Node lists always stay at type level; edge lists roll up too at namespaces/projects. |
--max-nodes <n> |
50 |
Maximum entries per added/removed node bucket. |
--max-edges <n> |
100 |
Maximum entries per added/removed/changed edge bucket. |
--top <n> |
20 |
Maximum coupling-summary entries, sorted by absolute total-degree change descending. |
--no-cache |
off | Bypass the disk snapshot cache under .codecharter/cache/graph/ (diagnostics). |
--max-tokens <n> |
unset | Approximate payload size budget in tokens. |
--edge-kinds <list> |
all six | Comma-separated inherits/implements/uses/calls/attributes/creates. |
--format <text\|json> |
text |
Output format. No mermaid/dot — a diff between two graphs is not itself a diagram. |
--output-file <path> |
stdout | Write the payload to this file instead of stdout. |
Rendering a diagram: --format mermaid/dot
--format mermaid and --format dot are a usage error on cycles, hubs,
paths, and diff: the cycles diagram writer is deferred to a follow-up,
a ranked table is not a graph, a tri-state verdict plus one counterexample
chain is not a diagram, and a diff between two graphs is not itself a
diagram. Everything below applies to overview, neighborhood, and
impact.
Both formats render the same query as a diagram instead of text or JSON, so you can paste it directly into a PR comment, a design doc, or a Graphviz pipeline instead of hand-writing one from the JSON. This is CLI-only — see With an AI assistant for why the MCP tools always return JSON.
codecharter graph overview --level namespaces --top 6 --format mermaid
%% CodeCharter graph overview — namespaces
%% Edge styles: thick = inherits, dashed = implements, solid = uses
graph TD
subgraph c0["Acme.Domain"]
n0["Orders"]
end
subgraph c1["Acme.Infrastructure"]
n1["Persistence"]
end
n0 -->|58| n1
codecharter graph neighborhood OrderService --format dot
// CodeCharter graph neighborhood — focus Acme.Domain.OrderService
digraph codecharter {
rankdir=TB;
n0 [label="OrderService", shape=box, style=bold];
n1 [label="IOrderService", shape=box];
n0 -> n1 [style=dashed, arrowhead=onormal];
}
A few things hold for every diagram:
- Node ids are synthetic (
n0,n1, ...), assigned positionally in the result's own order, never derived from the type's full name, so two names that would reduce to the same identifier (e.g. two generic instantiations) never collide into one node. The label is the type's short name, qualified with its namespace in parentheses only when another node in the same diagram shares that short name. - Edge kind maps to a line style, so the diagram reads without a legend:
Inheritsis thick (mermaid==>) or hollow-triangle-headed (DOTarrowhead=onormal),Implementsis dashed,Usesis plain,Callsis a labeled|calls|arrow (mermaid) or a thicker line (DOTpenwidth=2). A reversed-implements hop (see above) gets theImplementsdash plus animplemented bylabel, so it never reads as an ordinary forwardImplementsedge. - A weight above 1 renders as an edge label — only
overview's aggregated edges carry a weight, soneighborhoodandimpactdiagrams never show one. - The legend is always a comment (
%%///), never a graph node, so it cannot be mistaken for part of the graph by a downstream parser. - An empty result still renders a syntactically valid empty diagram
(
graph TD/digraph codecharter {}) rather than an empty string. overview --level namespacesclusters nodes into a subgraph per assembly;--level projectsdoes not — one node per assembly already is the cluster.impact's dependents-mode diagram (no--source) draws an edge from a distance-1 dependent straight to the target, since that hop is a real, known edge. Deeper distances render as nodes grouped by distance without an edge to a specific predecessor, because this result reports only "reaches the target in N hops", not which type at distance N-1 it goes through — use--sourcefor a diagram with a fully drawn chain.
--output-file and the count budgets (--top, --max-types,
--max-nodes/--max-paths) work exactly the same as with text/json — use
a lower --top/--max-types/--max-nodes than you would for JSON, since a
diagram with hundreds of nodes is not readable regardless of format.
--max-tokens is not supported with a diagram format — see the next
section.
Sizing a result: --max-tokens
Every count budget (--top, --max-types, --max-nodes/--max-paths) caps
how many items a result carries. That is the wrong unit for what actually
constrains an agent's context window: two calls with identical counts can
differ by an order of magnitude in size — neighborhood --signatures all
renders full member signatures for every neighbor, the same --max-types
with --signatures none is tiny. --max-tokens caps the payload's
approximate size instead, on top of whichever count budget already
applies — the effective limit is whichever binds first.
The estimate is computed from a compact JSON serialization at roughly 4 bytes
per token. It is an estimate, not a real tokenizer count, and the CLI's own
--format json output is indented and therefore somewhat larger than the
estimate; a script piping CLI JSON into a context window should budget for
that difference. Every result reports estimatedTokens, whether or not it
was truncated, so a caller can calibrate its next call from the last one.
When a result is truncated, it also reports boundBy — an ordered list of
the option names that actually constrained it, e.g. ["max_types", "max_tokens"] — and a coarser truncationReason
(None/CountBudget/TokenBudget/Both). This matters because a count
budget can bind during the underlying search, before --max-tokens gets a
chance to matter: if boundBy names a count option, raising --max-tokens
alone will not return more, and the accompanying notice says so explicitly.
--max-tokens truncates by whole item, never partially:
overviewdrops the lowest-weight aggregated edges.neighborhoodaccumulates neighbors in breadth-first discovery order — the order that gives up the most distant neighbors first — and only afterwards groups the survivors by relation. A truncated result therefore loses distant neighbors, never an entire relation category. When--signaturesisallorfocusand the token budget bound the result, the notice also names the cheaper knob: a narrower--signaturesscope returns substantially more neighbors within the same budget.impactdrops whole distance groups by node in closure mode, or whole shortest paths in path mode — a returned path is always the complete chain from source to target, never cut off mid-way.cyclesdrops whole components, biggest first, never a half-populated one.hubsdrops whole hub entries after--toptruncation, in ranked order — the lowest-ranked survivors go first.pathsnever returns a truncatedtrue/false: once the node budget is exhausted before the search can conclude, the answer becomesundecided/nullwithisExhaustive: falseinstead.
--max-tokens defaults to unset (no size limit); a value of 0 or below is
a usage error, not "unlimited". It is not supported together with
--format mermaid/dot: truncating a diagram mid-graph would produce
syntactically broken output, so a count budget remains the right lever to
size a diagram.
Exit codes
| Code | Meaning |
|---|---|
0 |
Success. |
2 |
Usage error: an unresolvable path, an unrecognized option value, --max-tokens ≤ 0 or combined with --format mermaid/dot, or (neighborhood/impact only) a target/--source that resolved to zero or more than one node. On an ambiguous or unresolved target, the candidates found (if any) are printed to stderr. cycles and paths never use this code for an unresolved/ambiguous target — they print the candidates to stderr and exit 0 with an undecided/no-op payload; see cycles above. hubs has no target at all. diff uses this code for a missing --base too. paths uses this code for a missing --to too. |
3 |
The resolved analysis target failed to analyze — for diff, this also covers an unresolvable --base/--head ref or a failed base-side materialization (e.g. dotnet restore offline). |
The payload always goes to stdout (or --output-file); every notice —
including the fixed edge-semantics reminder every result carries — goes to
stderr, the same split codecharter coverage uses.
See Exit codes for how this fits the other
commands.
With an AI assistant
The same seven queries are available to AI coding assistants as MCP tools,
alongside the analysis and coverage tools: graph_overview,
graph_neighborhood, graph_impact, graph_cycles, graph_hubs,
graph_paths, and graph_diff. Parameters are the snake_case equivalent of
the CLI options above (max_types, max_nodes, max_paths,
max_components, max_members_per_component, min_size, sort_by,
min_degree, max_tokens, source, base, head, max_edges,
must_pass_through, level), with the same defaults, and
target/source/scope/from/to/must_pass_through resolve the same
way. The first six share the analysis tools' warm per-workspace cache: the
first call after opening or changing a workspace is a cold start and returns
a retry notice instead of a result, and the following calls are fast.
graph_diff behaves differently: it has no no_cache parameter at all (a
diagnostic escape hatch that would let a client trigger an unbounded
re-analysis on every call, so it stays CLI-only), and a base-side cache miss
does not block the tool call — materializing and analyzing a revision runs in
the background, and the first call for a given base/head pair returns
isPending: true immediately rather than a retry notice. Call again with the
same base/head/path once you expect it to be ready; a repeat call for an
already-materialized base is served from a per-commit disk cache and returns
fast, same as the other five tools' warm-cache calls.
The MCP tools always return the typed JSON result. An agent holding the JSON can already build whatever
diagram a human wants from it, or shell out to
codecharter graph … --format mermaid for the canonical rendering.
An agent typically uses these tools before touching unfamiliar code:
graph_hubs first, to see what is load-bearing before touching anything;
graph_overview to see where the coupling is; graph_neighborhood on the
file it is about to edit to see what it touches; graph_impact to check
what else would be affected before making the change; graph_cycles
before an "extract this into its own package" or "clean up the architecture"
task, where the actual blocker is often a cycle no single-edge inspection
would have found; and graph_paths when the question is narrower than either
— "if I delete or gate this one type, is A really cut off from B, or is
there a route around it" — which graph_impact's shortest-path mode cannot
answer, since a shortest path proves reachability but says nothing about
every other path. Because graph_neighborhood, graph_impact, and
graph_paths accept a source file path directly, an agent that already has
a file open does not need to look up the type's full name first.
Edge semantics and limits
Every result — CLI and MCP alike — carries notices describing exactly what its edges are and are not. There are three families of edge:
- Structural edges — signature-level: a type's declared base type, its directly implemented interfaces, and every type referenced in a constructor/method parameter, method return type, property type, field type, or event delegate type. This is enough to reconstruct the coupling that DI-style, layered codebases care about — who depends on what through their public shape.
callsedges — from method-body invocations that resolved to a unique method symbol, attributed to the callee's declaring type (not its method — this is a type-level graph, no method nodes). Included by default, and the category structural edges cannot see at all: a static helper (PathNormalizer.Normalize(p)), an extension method, or a concrete type constructed and called entirely inside one method body never reaches a signature, so acallsedge is often the only way such a type gets an inbound edge. Weight is a call-site count, not a location —weight: 6means six call sites resolved to that type, not that the result can point at where they are.attributesandcreatesedges — also from resolved bindings rather than signatures.attributesconnects a type (or a member declared on it) to the class of an attribute it applies; only the declaring type is ever the edge's source, so a member-level attribute still attributes to its declaring type's node.createsconnects a type to every other type its method/constructor bodies construct withnew T(...)(or target-typednew(...)) that resolved to a unique constructor symbol. Both are included by default, and both use weight the same waycallsdoes — a site count, not a location. An attribute class or created type outside the solution, or one whose stripped name is ambiguous within the solution, produces no edge.
Use --edge-kinds/edge_kinds to narrow which kinds are included
(inherits, implements, uses, calls, attributes, creates,
comma-separated, case-insensitive; all six by default). The filter removes
edges only — a node whose every edge was filtered out still appears in the
node list and totals, so counts stay comparable across --edge-kinds values.
What calls still cannot see, and how the result says so:
- Ambiguous or non-method calls. An overload resolution that genuinely
cannot pick one candidate, or a
dynamiccall, produces no symbol to resolve; thecallsnotice reports how many such calls there were. (A delegate/Action/Func<>invocation is not one of these: it is captured like any other call, usually pointing at an external delegate type, which contributes no edge either way.) - Ambiguous callee type names. A resolved call whose callee type's
stripped name matches more than one solution type (a
Result/Result<T>sibling pair, say) cannot be attached to a unique node; it is dropped and counted separately, with its own notice sentence, since this is a different loss than an unresolved invocation. - Multi-targeted solutions. A project with
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>yields one project instance per TFM; only the first-analysed TFM's method bodies feedcallsedges, so a call inside#if NETFRAMEWORK-conditional code for another target is not represented. A notice says so when this applies. - External types are not nodes. A parameter, base type, or call target from a NuGet package or the .NET runtime is not part of the graph; only types declared in the analyzed solution are.
- Namespaces are per-assembly. The same namespace name declared in two
projects produces two separate namespace nodes at
--level namespaces;overview's node id encodes both. - Multi-targeted projects are counted once for nodes. Every TFM copy of
a type collapses to a single node instead of inflating node counts and
structural-edge weights per TFM (the
callsfirst-TFM-wins rule above is the one place TFM choice still matters).
None of this is hidden in a caveat at the bottom of a results page — it is part of every payload, so an agent (or a script) never has to guess whether an edge set is "the whole truth".
Related
- MCP rule authoring — the full tool list, including the seven graph tools.
- codecharter analyze and codecharter coverage — the other two CLI verbs that read the same solution.
- Configuration file — the
excludelistcodecharter graphshares withanalyze. - Guardrails for AI-generated code — how the CLI and MCP surfaces fit into an AI coding loop.