Go dependency graph tool — read a Go module, build its dependency graph, and run queries on top: cycles, reachability, symbol neighbors, layer rules.
The design in one sentence: the graph is the artifact; everything else is a query over it.
Sibling projects: cartograph (Swift) · kartograph (Kotlin/Android) · dartograph (Dart/Flutter) · schemagraph (databases) · isthmus (cross-language joins).
Go already has deadcode, goda, go-arch-lint, and go-callvis — but each
answers one kind of question with its own output shape. gartograph instead
builds one versioned graph at four levels (module/package/type/symbol) and
expresses every analysis as a query over it, with a deterministic JSON contract
meant for coding agents: facts and evidence, never delete verdicts.
brew install ictechgy/tap/gartograph
# or
go install github.com/ictechgy/gartograph/cmd/gartograph@latestOr from source:
git clone https://github.com/ictechgy/gartograph.git
cd gartograph
go build -o gartograph ./cmd/gartograph# Emit the dependency graph (deterministic JSON)
gartograph graph # package level
gartograph graph --level symbol # call/implements/embeds/references
gartograph graph --level module # modules (go.work workspaces)
gartograph graph --level type --format mermaid # or --format dot for Graphviz
gartograph graph --level symbol --out .gartograph/graph.json
# Detect dependency cycles — package cycles are impossible in Go,
# so real checks run at type/symbol level
gartograph cycles --level symbol --strict
# Report symbols unreachable from retention roots (main, init)
# Symbol level includes struct fields — unreferenced members are reported
# with kind "field" (see limitations for reflection/serialization blind spots)
gartograph dead # symbol level, always
gartograph dead --retain-public # libraries: keep exported API
gartograph dead --root my/pkg.Setup # extra retention root
gartograph dead --explain my/pkg.F # why alive? show a reachability path
gartograph dead --algo rta # RTA precision: needs source, not --graph
gartograph dead --algo rta --explain my/pkg.F # path on the RTA call graph itself
# Source-level keep: //deadcode:keep or //gartograph:keep on a declaration
# (also on a struct field) marks it as a retention root in the document.
# Emit an isthmus bridge-facts document (platform "go")
# Go reports cgo via unscanned-ffi-interop limitations — no channel facts.
gartograph bridges --out go-facts.json
# Emit isthmus persistence facts: SQL relation references harvested from
# typed database/sql·sqlx·gorm calls, SQL literals, TableName() bindings,
# and db/sql/gorm column tags (platform "go", target "persistence").
gartograph schema --out go-schema-facts.json
# Check layer rules from .gartograph.yml
gartograph rules --strict
# Ask about one vertex: what it uses, what uses it (JSON for agents).
# query/impact/path/shared harvest at symbol level by default, so package,
# type, function, and method IDs all resolve; --level package is faster
# on large repos but only package IDs exist there.
gartograph query github.com/ictechgy/gartograph/cli --depth 2
gartograph query 'github.com/ictechgy/gartograph/graph.(Document).Sort'
# Reverse transitive closure: what breaks if this vertex changes
gartograph impact github.com/ictechgy/gartograph/graph --depth 2
# Diff-aware impact: what breaks given the files changed since a revision
gartograph impact --since origin/main...HEAD # or --files cli/cli.go
# Shortest dependency path: why does 'from' reach 'to'
gartograph path github.com/ictechgy/gartograph/cmd/gartograph github.com/ictechgy/gartograph/graph
# Shared reachables: what do two roots both pull in (and each alone)
gartograph shared example.com/a example.com/b
# go.mod hygiene: requirements no package imports (test imports count)
gartograph unused-deps --strict
# Compare two saved documents: structure drift and breaking signals
# breaking = exported symbol removed/unexported, kind changed, signature
# reference dropped, interface gained a method or changed a method
# signature (declared or through any embedding, io.Reader included, when
# both documents carry interface method sets), constraint type set changed,
# struct field contract broken, exported const value changed
gartograph diff old.json new.json --strict
# Coupling metrics (Ca/Ce/instability) + abstractness (A) and
# main-sequence distance (D = |A+I-1|) + orphan packages.
# Harvested at type level so A has a denominator; a package-level saved
# document omits A/D and says so in limitations.
gartograph metrics # component-level when .gartograph.yml exists
gartograph mapping # how packages resolve to components
# Scaffold .gartograph.yml — deps mirror observed imports, so rules pass clean
gartograph init
# Serve the harvested document to agents over MCP stdio
gartograph mcp --level symbol
# Query a saved document instead of re-harvesting
gartograph cycles --graph .gartograph/graph.json --level type
gartograph dead --graph .gartograph/graph.jsonHarvest flags (all analysis commands):
| Flag | Default | Meaning |
|---|---|---|
--dir |
. |
module root to analyze |
--pattern |
./... |
package pattern (repeatable) |
--tests |
off | include test variant packages (Test/Benchmark/Example/Fuzz become retention roots) |
--deps |
off | include dependency packages/modules outside the main module |
--tags |
— | build tags for the loader; files excluded by constraints are counted in limitations |
--goos/--goarch |
host | harvest for another target platform; a limitations note records the choice |
--graph |
— | read a saved graph document instead of harvesting |
Exit codes: 0 ok · 1 --strict violation · 2 usage/analysis error.
gartograph rules reads .gartograph.yml (or .gartograph.yaml) at the
module root. Components map module-relative package paths; deps is an
allowlist — a component with no entry may depend on nothing outside
itself.
components:
cli: ["cli"]
analysis: ["analysis"]
core: ["graph"]
deps:
cli: ["analysis", "core"]
analysis: ["core"]
core: []Two optional sections narrow it further:
deny: # hard bans — beat deps entries
analysis: ["cli"] # analysis must never reach back into cli
web: # entries may carry a reason for the reader
- {to: db, reason: "go through internal/store instead"}
signature: # public-API type leakage (needs symbol level)
api: ["core"] # api's exported signatures may only name core typesdenywins overdeps— "usually allowed, but this pair is forbidden".signaturecheckssignatureedges of exported symbols: a component's public API may only reference types from listed components, even when body dependencies are allowed. Violations carryrule: "deny"|"signature"|"allow".
More optional sections widen the contract vocabulary:
common: ["core"] # every component may depend on these, unlisted
visibleTo: # provider-side rule: who may depend on me
db: ["store"] # only the store component may import db
forbidden: # transitive bans — no path at all, direct or not
- {from: api, to: db} # api must not reach db even via other components
independent: [web, cli] # web and cli must not reach each other either waycommonremoves allowlist boilerplate for shared components.visibleTois the mirror ofdeps:depssays what I may use,visibleTosays who may use me. Both are allowlists —visibleToonly narrows, never widens. Violations carryrule: "visibleTo".forbiddenchecks reachability, not just direct edges — deps can only see direct imports. A violation reports one witnesspath.independentis a bidirectionalforbiddenbetween every listed pair — the name keeps the intent. Violations carryrule: "independence".stability: trueenforces the stable-dependencies direction — dependency-cruiser'smoreUnstable. A component may not depend on a component with higher instability (I = Ce/(Ca+Ce), the same numbermetricsreports). Violations carryrule: "stability"and both instability values in the reason.limitscaps component size:maxOutbounds how many other components it may depend on (Ce),maxInbounds how many may depend on it (Ca) — dependency-cruiser'smax-dependenciescontract. Exceeding a limit is a component-level fact, not an edge violation — violations carryrule: "limit",name: "maxIn"|"maxOut", and the observed count.excludeis a harvest-time filter, not a rule: packages matching its patterns (same glob semantics as components — module-relative for the main module, full import path otherwise) never become vertices, and imports to them are counted inlimitations. Use it for generated or vendored trees:fileRulesscopes a ban to the file where the import happens — dependency-cruiser'snot-to-dev-depcontract.fromis a glob matched against the file basename, or the module-relative path when it contains/; a leading!inverts it. Violations carryrule: "fileScope", the rulename, and the offending position. Edges without positions can't be checked and are counted infileScopeUnchecked.denyentries accept areason— it lands on the violation so the reader knows what to do instead.- Every name referenced by a rule must be a defined component —
Loadrejects dead references instead of letting a typo pretend to be a rule.
fileRules:
- name: no-testdeps-in-prod # production files may not import test helpers
from: "!*_test.go" # files NOT matching the glob violate
to: testhelpers # a component
reason: "test helpers must not leak into production code"
stability: true # deps must point toward more stable components
limits: # size caps, not direction
- {component: web, maxOut: 4} # web may depend on at most 4 components
- {component: db, maxIn: 2} # at most 2 components may depend on db
exclude: [gen/**, testdata/**] # these packages are never harvestedExternal/vendor rules. Component patterns also match the full import
paths of external packages harvested with --deps, so deps/deny can
gate third-party modules:
components:
store: ["internal/store/**"]
aws: ["github.com/aws/**"] # matches external vertices under --deps
deps:
store: ["core", "aws"] # only the store layer may import AWSRun rules --deps so external vertices exist. A component pattern matching
zero packages is reported in unmatchedComponents — the signal that an
external pattern ran without --deps (or the pattern is stale). External
packages matching no component are reported separately as
unmappedExternal.
Baseline. Adopting rules on an existing repo: record today's violations
once, then only new violations fail --strict. The same flags work on
cycles and dead — each kind carries its own baseline file kind, so a
file can't silently cross-apply.
gartograph rules --write-baseline .gartograph-baseline.json
gartograph rules --baseline .gartograph-baseline.json --strict
gartograph cycles --level symbol --write-baseline .cycles-baseline.json
gartograph dead --baseline .dead-baseline.json --strictBaselined violations are reported under baselined; entries that stop
occurring come back as staleBaseline — regenerate when they pile up.
SARIF output and --strict see only fresh violations.
Patterns: exact match, x/** recursive prefix, * segment glob. Packages
matching no component are reported as unmapped — a mapping gap is "rules
don't know this area", not "no rules apply".
rules, cycles, and dead all accept --format sarif for CI code
scanning — cycles report as dependency-cycle errors, unreachable symbols
as unreachable-symbol warnings (a fact, not a deletion verdict).
- Deterministic JSON: sorted keys, sorted arrays — same input, same bytes.
queryreportsdepth,truncated, and every edge kind between neighbors (edges: ["call", "implements"]).deadreportsstate+reason+exportedper finding and always includes therootsit used — reachability depends on them.exportedis the triage axis: an unexported unreachable symbol is confined to the repo, while an exported one may have external callers, reflection, or plugin use — classify on the fact, the tool does not grade confidence.deadalso reports struct fields (kind: "field") — a field with no named access (x.F,T{F: v}, positional literal, promotion path,==) is unreachable. Reflection, serialization, and whole-struct copies are invisible — the report carries that limitation when fields appear.//deadcode:keepand//gartograph:keepon a declaration (function, method, var, const, type, or struct field) mark it as a retention root inroots— the keep intent lives next to the declaration, not in a CLI flag. The marker must be the first token of its comment line (//deadcode:keepor// deadcode:keep <reason>); put it on the doc comment above the declaration or on a field line inside a struct — a trailing comment after afuncbody does not attach to it.limitationsis counted per run (omitted external imports/references,reflectuse,//go:linkname, packages without type info) — absent means nothing to report, not a boilerplate warning.- No delete verdicts.
unreachableis a graph fact ("not reachable from retention roots"), never "safe to delete". - Methods implementing interfaces declared outside the module (
error,fmt.Stringer,flag.Value,encoding.TextUnmarshaler, …) carrysatisfiesandreceiverin the symbol graph. Their callers live in the standard library or a dependency, sodeadcounts such a method as reachable whenever its receiver type is reachable. A reported method keeps itssatisfieslist as a triage fact, anddead --explainmarks the receiver → method hop as(external dispatch: …)because no graph edge backs it. The rule applies todeadonly —shared,path, andimpactfollow dependency edges. Anonymous interfaces in dependency source (errors.Is/As/Unwrap'sinterface{ Unwrap() error }, function- local interface types, interface-literal parameters, package-scope aliases) count too, named by their method set without parameter names (interface{Unwrap() error}). Documents harvested this way carryanonymousDispatch: true; older saved documents get a re-harvest limitation instead. Dispatch through reflection or generic interfaces is still invisible and the report says so. - Optional fields are omitted when empty (
omitempty).
version: 2, tool: "gartograph", level, root (filesystem dir),
module (module path), roots (harvested retention roots: main, init,
plus Test*/Benchmark*/Example*/Fuzz* entry points under --tests),
vertices, edges, limitations, anonymousDispatch: true when
method satisfies facts include anonymous interfaces from dependency
source (symbol-level harvests; older documents lack it), and
interfaceMethodSets: true when interface type vertices carry methods
(the full method set, embeddings included, as sorted Name(params) results
entries — without the marker an absent list means "not harvested", with it
"no methods"). Types in fields and methods are canonical — aliases
resolved, byte→uint8, type parameters by position (P0…) — so a
spelling-only refactor (interface{}→any, renaming T→E) is not a
breaking change. Constraint interfaces also carry typeSet — the effective
type elements (an intersection), with embedded constraints (unexported ones
included) and interface union terms flattened, union terms sorted,
comparable kept as an entry (marker interfaceTypeSets: true). diff
reports an exported constraint whose type set changed (narrowing breaks
instantiations, widening can break generic code relying on the allowed
operations); an empty set renders as any. Equivalent rewrites of a
multi-element intersection may still be reported.
Edges carry positions — every source site where the relation holds
(import decls for import, call expressions for call, and so on).
Relations without a single site (contains, implements, module edges)
omit it. dead --algo rta swaps the harvested CHA edges for SSA-based
rapid type analysis — narrower, source-only, and it under-approximates:
the report says so in limitations.
Vertex IDs: pkg/path for packages, pkg/path.Name for package-level
symbols, pkg/path.(Recv).Name for methods. Each package has at most one
package-initialization root vertex pkg/path._ (kind var, positioned at a
hand-written contributing declaration when there is one, so it is
generated only if all contributions are), like init, created only when
the contributing declarations reference module symbols (an iota-skipping
const ( _ = iota ) creates none); documents carry initializerRoots: true,
and dead on an older saved document suggests re-harvesting. It references
what blank var _/const _ declarations use (var _ I = (*T)(nil) uses
T and I) and what named variable initializers that execute a call use
(var registered = register() runs register at init even if registered
is never read). Initializers without a call (a table of function values,
conversions, builtins, function-literal bodies) stay behind their variable;
an initializer expression that does execute a call is attached as a whole
(function values and literal bodies in the same expression included — an
over-approximation toward "alive"). dead --algo rta roots every loaded
package's synthetic initializer for the same reason. A package path with a dot (example.com/m/x.y, or the
--tests main package x.test) can equal a symbol ID (y or test in
example.com/m/x); only such colliding symbol IDs get a #symbol suffix
(example.com/m/x.y#symbol) — # cannot appear in an import path, so the
package keeps its plain path and every other ID is unchanged. The suffix
depends on which packages are harvested (a sibling x.y/ directory, --tests,
--deps, exclude), so diff and baselines pair ID with ID#symbol as
the same symbol.
dead --algo rta --explain may route through pkgpath#init, the synthetic
package initializer (not a graph vertex). Vertex kind:
module/package/type/func/method/var/const. Vertices carry
generated: true when they come from files marked
// Code generated ... DO NOT EDIT. — marked, never hidden. Type vertices
also carry interface: true or fields (declared "name:Type" list) so
diff can classify interface method additions and struct-field contract
breaks as breaking. Method vertices implementing interfaces declared
outside the module carry satisfies (sorted interface names) and
receiver (the receiver type vertex ID). Named interfaces module code
cannot name (unexported like context.stringer, or under an internal/
path) are listed only when no other interface (exported named, error, or
an anonymous method set) explains the method — the list stays non-empty,
so reachability is unchanged.
Edge kind:
import/contains/embeds/implements/references/call/signature
(signature = type references inside declaration signatures; a dependency
edge, unlike contains which is ownership).
Interface calls get CHA fan-out: an edge to the interface method and to
every known implementation — over-approximation errs toward "alive" so
dead never reports reachable code.
go/packages ──> source ──> graph.Document ──> analysis ──> export ──> cli
(harvest) (pure domain) (queries) (json/mermaid)
graph— pure domain, zero external deps. The artifact.source— the only package importinggolang.org/x/tools. Harvests, never judges.analysis— queries over the document (SCC cycles, neighbors, reachability, rules).config—.gartograph.ymlparsing (the only yaml.v3 importer).export— deterministic JSON, Mermaid, graph file I/O.cli— commands and the exit-code contract.
go test ./... # tests
go vet ./... # vet
Scripts/coverage.sh # tests + 90% coverage gate
Scripts/verify-cli-contract.sh # exit-code contract on a fixture moduleDogfooding — this repo's own .gartograph.yml encodes the layering:
go run ./cmd/gartograph rules --strict
go run ./cmd/gartograph cycles --level type --strict
go run ./cmd/gartograph cycles --level symbol --strict
go run ./cmd/gartograph deadgartograph mcp serves the harvested document over MCP stdio
(newline-delimited JSON-RPC): gartograph_summary, gartograph_query,
gartograph_impact, gartograph_path, gartograph_cycles,
gartograph_dead, gartograph_rules, gartograph_metrics,
gartograph_mapping. The document is harvested once at startup so every
tool call answers over the same snapshot. Example client config:
{"mcpServers": {"gartograph": {
"command": "gartograph",
"args": ["mcp", "--dir", "/path/to/repo", "--level", "symbol"]}}}Symbol/type level harvest— done viago/packages+go/types+ AST— donedead,rules, persistedgraph.jsonModule-level graph— done (go.work workspaces;--depsadds dependency modules)Verification scripts + CI—Scripts/coverage.sh,Scripts/verify-cli-contract.shHomebrew tap—brew install ictechgy/tap/gartograph— doneimpact,deny/signaturerules, SARIF, MCP,--tags, generated marking— donepath,diff,impact --since/--files, rules baseline, external (vendor) rules,--format dot— donevisibleTo,common, transitiveforbidden, deny reasons,metrics,mapping,initisthmus bridge-facts producer—bridgesemits platformgodocs; cgo observations areunscanned-ffi-interoplimitations per the v1 contractRTA—dead --algo rta(opt-in; Andersen pointer analysis deferred)Edge positions (schema v2),test-variant deduplication— done— donefileRules, cycles/dead SARIF + baselines, deeperdiffbreaking classification
MIT — see LICENSE.