Skip to content

About

Go dependency graph tool — the graph is the artifact; cycles, dead code, and layer rules are queries over it

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

gartograph

gartograph's bird mascot

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).

Why

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.

Install

brew install ictechgy/tap/gartograph
# or
go install github.com/ictechgy/gartograph/cmd/gartograph@latest

Or from source:

git clone https://github.com/ictechgy/gartograph.git
cd gartograph
go build -o gartograph ./cmd/gartograph

Usage

# 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.json

Harvest 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.

Rules configuration

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 types
  • deny wins over deps — "usually allowed, but this pair is forbidden".
  • signature checks signature edges of exported symbols: a component's public API may only reference types from listed components, even when body dependencies are allowed. Violations carry rule: "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 way
  • common removes allowlist boilerplate for shared components.
  • visibleTo is the mirror of deps: deps says what I may use, visibleTo says who may use me. Both are allowlists — visibleTo only narrows, never widens. Violations carry rule: "visibleTo".
  • forbidden checks reachability, not just direct edges — deps can only see direct imports. A violation reports one witness path.
  • independent is a bidirectional forbidden between every listed pair — the name keeps the intent. Violations carry rule: "independence".
  • stability: true enforces the stable-dependencies direction — dependency-cruiser's moreUnstable. A component may not depend on a component with higher instability (I = Ce/(Ca+Ce), the same number metrics reports). Violations carry rule: "stability" and both instability values in the reason.
  • limits caps component size: maxOut bounds how many other components it may depend on (Ce), maxIn bounds how many may depend on it (Ca) — dependency-cruiser's max-dependencies contract. Exceeding a limit is a component-level fact, not an edge violation — violations carry rule: "limit", name: "maxIn"|"maxOut", and the observed count.
  • exclude is 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 in limitations. Use it for generated or vendored trees:
  • fileRules scopes a ban to the file where the import happens — dependency-cruiser's not-to-dev-dep contract. from is a glob matched against the file basename, or the module-relative path when it contains /; a leading ! inverts it. Violations carry rule: "fileScope", the rule name, and the offending position. Edges without positions can't be checked and are counted in fileScopeUnchecked.
  • deny entries accept a reason — it lands on the violation so the reader knows what to do instead.
  • Every name referenced by a rule must be a defined component — Load rejects 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 harvested

External/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 AWS

Run 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 --strict

Baselined 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).

Output contract (for agents)

  • Deterministic JSON: sorted keys, sorted arrays — same input, same bytes.
  • query reports depth, truncated, and every edge kind between neighbors (edges: ["call", "implements"]).
  • dead reports state + reason + exported per finding and always includes the roots it used — reachability depends on them. exported is 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.
  • dead also 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:keep and //gartograph:keep on a declaration (function, method, var, const, type, or struct field) mark it as a retention root in roots — 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:keep or // deadcode:keep <reason>); put it on the doc comment above the declaration or on a field line inside a struct — a trailing comment after a func body does not attach to it.
  • limitations is counted per run (omitted external imports/references, reflect use, //go:linkname, packages without type info) — absent means nothing to report, not a boilerplate warning.
  • No delete verdicts. unreachable is 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, …) carry satisfies and receiver in the symbol graph. Their callers live in the standard library or a dependency, so dead counts such a method as reachable whenever its receiver type is reachable. A reported method keeps its satisfies list as a triage fact, and dead --explain marks the receiver → method hop as (external dispatch: …) because no graph edge backs it. The rule applies to dead only — shared, path, and impact follow dependency edges. Anonymous interfaces in dependency source (errors.Is/As/Unwrap's interface{ 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 carry anonymousDispatch: 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).

Graph document

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.

Architecture

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 importing golang.org/x/tools. Harvests, never judges.
  • analysis — queries over the document (SCC cycles, neighbors, reachability, rules).
  • config — .gartograph.yml parsing (the only yaml.v3 importer).
  • export — deterministic JSON, Mermaid, graph file I/O.
  • cli — commands and the exit-code contract.

Development

go test ./...                    # tests
go vet ./...                     # vet
Scripts/coverage.sh              # tests + 90% coverage gate
Scripts/verify-cli-contract.sh   # exit-code contract on a fixture module

Dogfooding — 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 dead

isthmus exchange — schema usr and traversal documents

schema 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, whose facts[].symbol.usr are used), deduplicated in first-seen order; that order is the meaning of reached[].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's depth and a shortest-path via witness, and the edge kinds of the via hop. Checked against a per-root BFS oracle on random graphs.
  • evidence on every reached symbol is the per-root lower bound: direct when every root that reaches it does so over compiler-resolved edges only, candidate when some root needs an interface-dispatch fan-out edge. Calls through function values and reflection are not counted, so the document carries neither dispatch nor unresolvedCalls (no completeness claim).
  • --depth (1–128, default 128) and --max (1–100000 reached, default 100000) cut with truncationReasons depth/max-reached.
  • project is the same realpath schema writes; revision is --revision or git HEAD when the work tree is clean (omitted with --graph); graphRevision is the SHA-256 of the graph JSON; --generated-at fixes the timestamp.
  • Ids that are not vertices are listed in roots without symbol, with a root-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/--files or a non-symbol --level with 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.

isthmus exchange — server routes (routes)

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 after url.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 / → /{**} plus catchAllPrefix /; {$} is the trailing slash only. A pattern that panics or can never match (unclean path with a method) emits nothing. A host pattern is narrowed. go below 1.22 in go.mod, a go.mod godebug httpmuxgo121=1 or a //go:debug httpmuxgo121=1 switches to the old literal/subtree patterns.
  • chi: {name}/{name:regexp} run to the next tail byte, so /{name}.json is a partial segment; a regexp is anchored and becomes paramConstraints (int for [0-9]+/\d+, otherwise regex with the pattern); * only ends a pattern and also matches nothing. A partial parameter followed by a literal accepts an empty value, so /f/{name}.json also emits /f/.json (the contract's empty-value variant). Two parameters in one segment are not a canonical template (route-coverage:).
  • gin: :name runs to / (/:file.json is a whole-segment parameter named file.json) and may follow a literal prefix (/avatar_:n → /avatar_{}); *name ends a path after / and matches /x/ too.
  • echo: :name runs to / (\: is a literal colon); * matches the rest including nothing. A parameter node without children takes the rest of the path across / (isLeaf in Find): those routes get trailingSlash: "optional" and a route-coverage: limitation scoped to their templates and methods. e.Static("/static", …) registers /static*: /static, /static/, /static/{**} plus a route-coverage: limitation (prefix of the parent) for /staticX paths.
  • 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 a route-decl-without-call warning 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.

isthmus exchange — client route calls (routes --role client)

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.

MCP server

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"]}}}

Roadmap

  • Symbol/type level harvest — done via go/packages + go/types + AST
  • dead, rules, persisted graph.json — done
  • Module-level graph — done (go.work workspaces; --deps adds dependency modules)
  • Verification scripts + CI — Scripts/coverage.sh, Scripts/verify-cli-contract.sh
  • Homebrew tap — brew install ictechgy/tap/gartograph
  • impact, deny/signature rules, SARIF, MCP, --tags, generated marking — done
  • path, diff, impact --since/--files, rules baseline, external (vendor) rules, --format dot — done
  • visibleTo, common, transitive forbidden, deny reasons, metrics, mapping, init — done
  • isthmus bridge-facts producer — bridges emits platform go docs; cgo observations are unscanned-ffi-interop limitations per the v1 contract
  • RTA — dead --algo rta (opt-in; Andersen pointer analysis deferred)
  • Edge positions (schema v2), test-variant deduplication — done
  • fileRules, cycles/dead SARIF + baselines, deeper diff breaking classification — done
  • schema usr, reach / impact --format language-traversal — done
  • routes --role server (ServeMux·chi·gin·echo), --type-edges members — done
  • routes --role client (net/http·resty v2·declared wrappers) — done

License

MIT — see LICENSE.

About

Go dependency graph tool — the graph is the artifact; cycles, dead code, and layer rules are queries over it

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages