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
# Emit isthmus http route declarations: net/http ServeMux, chi, gin, echo
# routes as (method, canonical path template) with the handler's vertex id
# (platform "go", target "http", roles ["server"]).
gartograph routes --role server --out go-routes.json
# Emit isthmus http route calls: net/http and resty v2 requests plus
# wrappers declared in an isthmus http-wrappers v1 file, as (method, canonical
# path template) owned by the enclosing declaration (roles ["client"]).
gartograph routes --role client --wrappers http-wrappers.json --out go-calls.json
# isthmus language-traversal documents for `isthmus trace`: what the roots
# depend on (reach) and what depends on them (impact), many roots in one pass.
# Struct field types are followed from the fields that name them
# (--type-edges members, the default); --type-edges all restores the old spread.
gartograph reach --roots-from go-routes.json
gartograph impact --format language-traversal --roots-from go-schema-facts.json
gartograph impact --format language-traversal --roots-from go-calls.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.
dispatchEvidence: true (symbol-level harvests) marks documents whose edges
carry candidate: true when every site of the relation is an interface-dispatch
(CHA) fan-out to a possible implementation — a relation with at least one
compiler-resolved site stays unmarked. Traversal documents grade evidence from
it; older saved documents lack the marker and are not graded.
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 deadschema relation-use facts carry symbol: {qualifiedName, usr} where usr is
the enclosing declaration's vertex id — the same id query/impact/reach
use (pkg.Func, pkg.(Type).Method with the pointer stripped and type
parameters dropped, pkg.init, pkg.var, and pkg._ for blank package-level
declarations that run at initialization; #symbol suffixed ids when a symbol
collides with a package path). The owner is whatever the symbol harvest draws
that site's edges from: functions and methods (closures fold into them), type
declarations (struct column tags attach to the type — a handler that reaches
the row type is taken to depend on its mapped columns), and package variables
(value i of var a, b = x, y belongs to name i). qualifiedName is the id
from the last import-path element (shop.(Handler).GetOrder). A usr is only
written when it is a symbol vertex of the graph impact would harvest with the
same .gartograph.yml excludes; facts without one (blank functions, blank
declarations that touch no module symbol, excluded packages, packages without
type information) carry no symbol and are counted in the chain-only
missing-relation-usrs: limitation.
reach (direction dependencies) and impact --format language-traversal
(direction dependents) write an isthmus
language-traversal v1
document over the same dependency edges as impact:
- Roots are positional ids plus
--roots-from FILE|-(a JSON string array or a bridge-facts document, whosefacts[].symbol.usrare used), deduplicated in first-seen order; that order is the meaning ofreached[].roots. - One multi-root pass: every symbol reached from a root other than itself, the
ascending indexes of all roots that reach it (64 at most, then
rootsTruncated), the nearest root'sdepthand a shortest-pathviawitness, and the edge kinds of the via hop. Checked against a per-root BFS oracle on random graphs. evidenceon every reached symbol is the per-root lower bound:directwhen every root that reaches it does so over compiler-resolved edges only,candidatewhen some root needs an interface-dispatch fan-out edge. Calls through function values and reflection are not counted, so the document carries neitherdispatchnorunresolvedCalls(no completeness claim).--depth(1–128, default 128) and--max(1–100000 reached, default 100000) cut withtruncationReasonsdepth/max-reached.projectis the same realpathschemawrites;revisionis--revisionor gitHEADwhen the work tree is clean (omitted with--graph);graphRevisionis the SHA-256 of the graph JSON;--generated-atfixes the timestamp.- Ids that are not vertices are listed in
rootswithoutsymbol, with aroot-not-found:limitation and truncation reason; the document is written and the command exits 64. Usage errors (no roots, control characters, out-of-range numbers, a bad--revision/--generated-at,--since/--filesor a non-symbol--levelwith the traversal format) exit 64 with empty stdout.
--type-edges members|all (default members) decides how struct field types
are followed. The symbol harvest draws a struct's field-type references from the
type vertex, so with all every method of a shared Handler struct reached the
row types behind every field through its receiver (/api/health → the users
columns), and in reverse a field type spread to every method of its container.
members moves each field-declaration edge T → X (references/signature, not
from the type-parameter header) to the vertices of the fields whose type names
X in the type's field list (fields, canonical types: a, b *X, m map[K]X,
Box[X], struct{ x X }), so T.f → X: reaching T no longer reaches X;
code that reads f still does. Code that reads a field of an unreached struct
now reaches the field's type, which all missed. Both directions stay
one plain graph, so depth = via depth + 1 and the roots-inclusion rule of the
contract hold. What members gives up: a whole struct value handed to
reflection without naming the field (json.Marshal(h), an ORM Save(&u)) does
not reach the non-embedded field types (embedding is an embeds edge and is
kept; the type itself and its own tags are still reached). When it cut anything
the document says so with a type-edges-members: limitation counting the
symbols all would add at the same roots and depth; rerun with
--type-edges all for the previous over-approximation. Old graph documents
without field lists have nothing to move and traverse as all. The plain
impact JSON format is unchanged (--type-edges there is a usage error).
isthmus trace then joins route declarations (routes) to handler reach and
handler reach to relation uses by exact usr — route → handler → relation-use →
tables and back.
gartograph routes --role server writes an isthmus bridge-facts http document
(platform: "go", target: "http", roles: ["server"],
dispatch: "specificity", sourceSets.tests: "excluded") with one
route-decl per (method, canonical template) a registration serves. Calls are
recognized by type (package path, receiver type, name), not by name:
| Router | Registrations | Prefix composition |
|---|---|---|
net/http ServeMux, http.Handle/HandleFunc (DefaultServeMux) |
Handle, HandleFunc |
mux.Handle("/api/", http.StripPrefix("/api", inner)) mounts inner under /api |
chi v5 (Mux, Router) |
Get…Trace, Handle/HandleFunc ("POST /x" too), Method/MethodFunc |
Route, Mount (chi routers; an opaque handler serves P, P/, P/*), Group, With |
gin v1 (RouterGroup, IRoutes, IRouter) |
GET…OPTIONS, Handle, Any, Match, Static* |
Group (joinPaths: path.Join keeping the relative trailing slash) |
echo v4 (Echo, Group) |
GET…CONNECT, Add, Any, Match, Static*, File* |
Group (string concatenation), Host (narrowed) |
Router values are followed flow-insensitively through variables, struct fields,
function parameters/results and chi Route/Group callbacks. A registration
whose router cannot be traced to a constructor (a Register(g *gin.RouterGroup)
called only from outside the module) is emitted with pathAnchor: "base" and a
templateSuffixes-scoped unresolved-route-prefix: limitation. A non-constant
path is a dynamic fact plus route-coverage:; a non-constant method (chi
Method, gin/echo Match) emits no fact and a route-coverage: limitation
scoped to that template. Methods outside the contract (CONNECT, PROPFIND)
emit nothing: no modeled call can send them.
Pattern semantics were read from the official sources (Go 1.27.1 net/http
pattern.go/routing_tree.go, chi v5.2.5 tree.go/mux.go, gin v1.10.1
tree.go/routergroup.go/utils.go, echo v4.16.0 router.go/group.go) and
checked against the real routers (below):
- ServeMux (Go 1.22+):
[METHOD ][HOST]/PATH; literals compare afterurl.PathUnescape;{x}is one non-empty segment (not the trailing slash);{x...}and a trailing/match the rest including nothing, so/a/→/a/{**}plus/a/; the root/→/{**}pluscatchAllPrefix/;{$}is the trailing slash only. A pattern that panics or can never match (unclean path with a method) emits nothing. A host pattern isnarrowed.gobelow 1.22 in go.mod, a go.modgodebug httpmuxgo121=1or a//go:debug httpmuxgo121=1switches to the old literal/subtree patterns. - chi:
{name}/{name:regexp}run to the next tail byte, so/{name}.jsonis a partial segment; a regexp is anchored and becomesparamConstraints(intfor[0-9]+/\d+, otherwiseregexwith the pattern);*only ends a pattern and also matches nothing. A partial parameter followed by a literal accepts an empty value, so/f/{name}.jsonalso emits/f/.json(the contract's empty-value variant). Two parameters in one segment are not a canonical template (route-coverage:). - gin:
:nameruns to/(/:file.jsonis a whole-segment parameter namedfile.json) and may follow a literal prefix (/avatar_:n→/avatar_{});*nameends a path after/and matches/x/too. - echo:
:nameruns to/(\:is a literal colon);*matches the rest including nothing. A parameter node without children takes the rest of the path across/(isLeafinFind): those routes gettrailingSlash: "optional"and aroute-coverage:limitation scoped to their templates and methods.e.Static("/static", …)registers/static*:/static,/static/,/static/{**}plus aroute-coverage:limitation (prefix of the parent) for/staticXpaths. - Empty-value variants for whole-segment parameters in the middle of a path
(chi and gin accept
/users//posts) are not emitted: they only come from unclean paths and would add aroute-decl-without-callwarning per route. A variant is dropped when the same router declares that template explicitly.
trailingSlash: ServeMux strict, except templates ending in / (a request
without the slash is redirected with 301) which omit it; chi and echo strict,
omitted when the module uses middleware.StripSlashes/RedirectSlashes or echo
Add/RemoveTrailingSlash; gin optional (RedirectTrailingSlash defaults to
true and answers 301/307 to the other form), strict when the engine sets it to
a constant false; omitted for {**} templates.
Dispatch is specificity for all four. ServeMux rejects at registration two
patterns where neither is more specific, so in a running program its tree order
(literal, then single wildcard, then multi wildcard, left to right with
backtracking) is exactly the most specific match; the chi/gin/echo radix trees
also try static before parameters before catch-alls from the left with
backtracking. Known differences from the consumer (false matches at worst, never
a false error): chi tries a regexp before a partial segment; chi, gin and echo
GET routes do not answer HEAD (405/404, checked by request) although the
consumer joins HEAD calls to GET declarations; for OPTIONS only echo answers a
known path (204), chi and gin do not.
symbol.usr is the handler's vertex id: a method value (s.handleX) or
function; a handler constructor call without handler arguments (handleX(db),
whose returned closure's edges start at that function); the inner handler of a
wrapper with exactly one handler argument (auth(h), http.TimeoutHandler);
http.HandlerFunc(f); the ServeHTTP method of a module type. A function
literal (or a local variable holding one) is attributed to the enclosing
declaration and counted in anonymous-route-handlers:. Handlers outside the
module (static file servers, promhttp.Handler()) and excluded packages carry
no symbol and are counted in missing-route-usrs:. Imports of routers
gartograph does not harvest (gorilla/mux, httprouter, fiber, chi v1–v4 paths,
echo v3/v5, grpc-gateway, …) add an unscoped route-coverage: limitation, so
a zero-fact document never reads as "scanned, none". The document is
self-checked against the contract (templates, methods, catch-all prefix
originals) before it is written; conformance/ vendors the isthmus
http-template, http-dispatch and url-compose vectors (conformance.lock),
and all 114 producer cases pass (51 for route declarations, 63 for route calls).
experiments/routes-oracle (a separate module; run.sh fetches chi, gin and
echo from proxy.golang.org) builds synthetic ServeMux/chi/gin/echo servers and
compares the document with each router's own table (chi.Walk,
Engine.Routes(), echo Routes()/Routers(), the ServeMux registration index
read by reflection) by executing sample requests: precision and recall are 100%
for all four (19/16, 28/34, 20/24, 19/23 facts/entries).
A module with several servers (separate main packages) should emit one
document per server with --pattern ./cmd/api/... and --service NAME;
otherwise their routes share one scope and collide as route-decl-conflict.
gartograph routes --role client [--wrappers http-wrappers.json] writes an isthmus
bridge-facts http document (platform: "go", target: "http",
roles: ["client"], sourceSets.tests: "excluded") with one route-call per
request a call site builds. Calls are recognized by type, not by name:
| Library | Calls | Base URL |
|---|---|---|
| net/http | http.Get/Head/Post/PostForm, the same (*http.Client) methods, http.NewRequest(WithContext) — the fact sits where the request is built, since client.Do sends that URL |
none: the URL string as written |
| resty v2 | R()/NewRequest() chains ending in Get…Patch or Execute, clients followed through variables, fields, parameters and results |
SetBaseURL/SetHostURL (trailing / trimmed) or a direct BaseURL/HostURL assignment (not trimmed) |
| declared wrappers | http-wrappers v1 entries with "language": "go" |
the declaration's pathAnchor |
URL expressions resolve string constants (folded across packages), +,
fmt.Sprintf (%s/%v inline their argument, constant %d renders),
locals and unexported package variables assigned once, a local whose every
assignment is empty or starts with ? (a query tail), url.URL{Scheme, Host, Path} (Path is the decoded path, escaped as EscapedPath does; a
non-constant Host cannot carry a path because String() escapes /, so the
path stays root without an authority), url.Parse/ParseRequestURI,
(*url.URL).String/JoinPath/ResolveReference/Parse, url.JoinPath,
path.Join and strings.TrimSuffix/TrimRight(x, "/"). Anything else — fields,
parameters, call results, exported package variables another module could
reassign, variables whose address is taken — is a value: a value that fills a
whole segment is {}, a value inside a segment makes the call dynamic with a
channelPrefix.
Base joins follow the isthmus http-wrappers join styles
(HTTP-WRAPPERS "Go, Rust, Python clients"),
read from Go 1.27.1 net/url (resolvePath, joinPath) and path, and resty
v2.17.2 client.go/middleware.go (parseRequestURL); tests also compare every
literal case with the real url.JoinPath, ResolveReference and path.Join:
| Join style | API | /x after an unknown base |
x after an unknown base |
Literal base |
|---|---|---|---|---|
rfc3986 |
ResolveReference, (*URL).Parse |
root (no authority) |
base; a .. climbing into the base or an empty reference is dynamic + ambiguous-base-join: |
RFC 3986 merge and dot-segment removal; //host/p takes that host |
go-join-path |
url.JoinPath, (*URL).JoinPath |
base |
base; a .. climbing into the base is dynamic + ambiguous-base-join: |
path.Join cleaning (// collapsed, .. may leave the base path), the last element's trailing / kept |
resty-base-url |
resty base URL | base |
base |
base (trimmed by SetBaseURL) + the path with a leading /; // and dot segments are sent as written; an absolute URL ignores the base |
String concatenation (+, fmt.Sprintf) is not a join: the URL string is
composed as written (// and dot segments are sent unchanged). A value followed
by a /-rooted literal is a base tail, a value followed by anything else is
dynamic + ambiguous-base-join: (the contract's rule for joins without a
vector); path.Join cleans its arguments the same way and a leading value is a
base tail. A string URL with a dynamic host ("https://" + host + "/v1") is
base (the value could carry a path); a net/http path-only URL is base but is
not counted as an unresolved base (no base expression was joined). resty path
params are resolved per receiving client: {name} becomes {} when a
SetPathParam(s) on that client or its requests sets it (resty path-escapes the
value, one segment; an escaped key wins over raw keys, as in parseRequestURL),
a raw param (SetRawPathParam(s)) may contain / and makes the call dynamic, a
client with no path params sends {name} literally (%7Bname%7D), and an
untraceable client leaves {name} as {} (a literal would risk a false
route-call-without-decl error). Several base URLs for one client give one fact
each. Methods are fixed per API; NewRequest/Execute need a string that
resolves to a contract verb ("" is GET, as in http.NewRequest), otherwise the
fact carries methodDynamic. Method expressions ((*http.Client).Get(c, u))
shift the receiver out of the argument list.
Go wrapper declarations: a package function has owner = import path, a
method owner = importpath.Type (the declaring type, interfaces included);
kind: "constructor" matches struct literals T{…}/&T{…} with owner =
importpath.T and name = T. An owner that reads both ways (a last path
element with a dot, example.com/api.v2) resolves to the package function when
one exists. index is the call argument position (the receiver excluded) and
label the declared parameter (constructor: field) name; methodEnum keys are
constant names (api.Get → Get). A declared wrapper's own dynamic requests
are not reported; call sites whose pathArg does not bind are counted under
http-wrapper-unresolved:. Unknown fields or invalid entries exit 2, as does a
--service different from an entry's service.
symbol.usr is the enclosing declaration's vertex id, the same attribution as
schema relation uses, so impact --format language-traversal --roots-from go-calls.json walks from each call site to its callers. location is where the
call expression starts. Dynamic facts carry channel: null (the source
expression could hold credentials); userinfo, query and fragment are stripped,
and high-entropy or webhook segments are masked (maskedSegments). baseRef
is the vertex id of the field or package variable holding an unresolved base,
for workspace match.baseRefs. Client-side limitations are counted, not
guessed: route-call-coverage: (load errors, imports of unmodelled clients —
resty v1/v3, fasthttp, req, retryablehttp, … — and requests built without a URL
argument: http.Request literals, resty Send), unresolved-base-url:,
ambiguous-base-join:, url-rewrite-interceptors: (assignments to a built
request's URL/Method), http-wrapper-unresolved:,
http-wrapper-undeclared: (functions that pass a parameter as the URL head or
method) and missing-route-usrs:. No limitationScopes are emitted.
The shared url-compose vectors (isthmus 3a45450) pass 63/63 cases for this
producer, 22 of them the producer:gartograph Go joins (dio-concat runs as
plain concatenation — its cases do not use dio's // or dot handling; Spring,
Rust and Python cases belong to other producers). experiments/client-oracle (a separate
module; run.sh fetches resty v2.17.2 from proxy.golang.org) runs 32 synthetic
calls through an httptest server on 127.0.0.1 set as HTTP_PROXY, records
each request's method, host and path, and compares them with the facts:
30 match, 2 are dynamic, 0 mismatch. source/clientoracle_test.go replays the
recording offline against a resty stub.
isthmus accepts go route-call facts from 3a45450 (#133). With that build,
check parses all 32 oracle facts, and a workspace trace follows
GET /users/{} from the Go server handler to the Go client call site and its
callers.
gartograph 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— doneschemausr,reach/impact --format language-traversal— doneroutes --role server(ServeMux·chi·gin·echo),--type-edges members— doneroutes --role client(net/http·resty v2·declared wrappers)
MIT — see LICENSE.