softmap statically analyzes a Go codebase and turns each entrypoint - an HTTP route, a gRPC method, a Kafka consumer - into a readable flow map: the chain of meaningful steps from the entrypoint through checks and decisions to database writes, published events, and every way the flow can end. As more code is written by AI, the bottleneck shifts from writing it to understanding what the system actually does; softmap makes that understanding a shared, regenerable artifact instead of one developer's mental model.
The core mechanism is aggressive, honest noise reduction: Gitea's
create-pull-request handler is a raw call graph of 1639 functions; softmap
renders it as a 46-node flow with every SQL query and HTTP call tagged - and
every dropped node accounted for in --debug-tree.
Requires Go 1.21+ - go install automatically fetches the newer toolchain
softmap builds with.
go install github.com/softmapio/softmap/cmd/softmap@latestScan your own service:
softmap scan . --all --html -o softmap-out
open softmap-out/index.html # xdg-open on LinuxOr try it on a codebase you don't know - Gitea. Gitea registers routes
through its own wrapper around chi, which discovery does not see through, so
this is also a demo of the func: escape hatch - point softmap at any
function and get its map:
git clone --depth 1 --branch v1.24.3 https://github.com/go-gitea/gitea
softmap scan gitea \
--entrypoint "func:code.gitea.io/gitea/routers/api/v1/repo.CreatePullRequest" \
--html -o gitea-map
open gitea-map/index.htmlOn this machine (M-series laptop) that scan loads 362 packages, builds a VTA
call graph, and writes the map in well under a minute. On a repo that uses
net/http, chi, gin, echo, fiber, or gorilla/mux directly, plain softmap scan <path>
lists what it found:
ID FUNC POS
grpc:Orders/GetOrder (grpcserver.Server).GetOrder grpcserver/server.go:28
http:GET:/orders/{id} (*handlers.Handler).GetOrder handlers/handler.go:69
http:POST:/auth/login (*chiapi.Handler).Login chiapi/handler.go:53
kafka:orders.created:consumer.Run consumer.Run consumer/consumer.go:16
...
The flags that matter:
| Flag | Meaning |
|---|---|
--entrypoint <id> |
a discovered id, or func:<pkg>.<Name> for anything discovery missed |
--all |
one flow per discovered entrypoint, plus index.json with the entity shelf |
--html |
self-contained interactive viewer (with --all -o: all flows in one page) |
--debug-tree |
full flow tree on stderr with every filter decision annotated |
--no-filter |
raw module-code graph, no noise pipeline |
Flow maps with decisions and outcomes. Semantic guards - permission
gates, business validations - render as decision nodes with the
developer-written error text as red exits; success ends in an explicit
terminal ("✓ responds HTTP 200"). Mechanical if err != nil propagation
stays off the map as a "may fail" badge, so decisions that remain are the
ones that mean something.
Effects on nodes. Steps are tagged with what they touch: SQL with the query text, Kafka topics (publish and consume), Redis, outgoing HTTP and gRPC with the target service, password hashing and JWT issuing, object storage.
Business and Tech modes. The same map, two audiences. Business mode: humanized step names, effect phrases ("writes database", "publishes event orders.created"), outcome-first decisions - readable by an analyst without an IDE. Tech mode: full identifiers, query texts, return types, positions.
Entity shelf. With --all, softmap derives the nouns your flows touch -
from SQL table names, URL segments, and Kafka topic prefixes - and the viewer
opens on an entity home screen ("order - 7 flows: 1 create · 5 read ·
2 publish"), click-through grouped by access kind. Flows with no signal land
on an honest "Other" shelf; an entity is never guessed.
Tunable labels. A .softmap.yaml next to the analyzed repo's go.mod
renames what the code names badly - overridden text carries a • marker so
manual labels stay distinguishable from derived ones:
overrides:
"(*service.Service).FindByPhone": "Find order by phone" # node label
"repo/repo.go:31": "load order from DB" # effect label
entities:
order: "Customer order" # entity shelf display name
merge:
order: [shipments] # fold satellite tables under one entitysoftmap builds on the Go toolchain's own analysis stack: go/packages +
go/ssa, with a VTA call graph (CHA fallback). No code is executed.
- Runs entirely on your machine. The scan makes no network calls and
sends nothing anywhere; the only downloads are your target's own Go module
dependencies, fetched by the standard toolchain exactly as
go buildwould. The binary depends ongolang.org/x/toolsandyaml- nothing else. - No LLM anywhere. Every label, decision, and effect is derived from the
code by deterministic rules. Given the same code and the same call-graph
algorithm, output is byte-identical (golden-tested), so maps are diffable
between versions with plain
diff- pin--algo vtain CI if you rely on that, since the only run-to-run variable is the VTA time budget falling back to CHA on a slow machine. - Every claim is traceable. Each node and effect carries its
file:line, shown in the detail panel. - Uncertainty is marked, never papered over. Dynamic calls the analysis
cannot resolve, Kafka topics it cannot trace to a constant, and filter
decisions are all reported as such -
--debug-treeshows the full tree with every kept/dropped decision annotated.
- Go only, for now. Deep static analysis is per-language; a manifest format for non-Go services in a polyglot map is a possible later step.
- Discovery is best-effort. It covers net/http (incl. Go 1.22 route
patterns), chi, gin, echo, gorilla/mux, fiber (v2 and v3),
protoc-generated gRPC registration, and common Kafka consumers. Codebases
that wrap their router (like Gitea) need
--entrypoint func:<pkg>.<Name>per flow - and--allfinds nothing there, since it maps only discovered entrypoints. See My router isn't detected. - Your repo must compile. Analysis loads and type-checks the target;
broken builds or unreachable private modules fail the scan. The softmap
binary itself must be built with a Go toolchain at least as new as the
target's
godirective -go installpicks one automatically, and a scan that fails with "requires newer Go version" tells you the exact reinstall command. - Dynamic dispatch is approximated. VTA resolves most interface calls;
reflection and hand-built function tables show up as unresolved rather
than guessed. On very large repos VTA can hit its time budget
(
--vta-timeout, default 2m) and fall back to the coarser CHA - the stats line tells you when. - Filter rules ship embedded.
softmap rules --defaultsprints them;--no-filterdisables them. A flag to layer your own rules file on top is planned but not implemented yet.
phase=discover ... entrypoints=0 means route registration wasn't
recognized - most often because the router is registered through a wrapper
of your own (s.router.GET(...) inside a helper) rather than called
directly. Nothing is wrong with your repo, and every map still works: point
softmap at a handler and it builds that flow.
softmap scan . --entrypoint "func:(*api.Handler).CreateOrder" --html -o outThe name is the handler's qualified Go name, and any unambiguous suffix of
it is enough (func:api.Handler).CreateOrder, or func:consumer.Run for a
plain function). A zero-discovery run prints request-shaped names taken from
your own code, so you can copy the form from there. Route paths in generated
ids use one syntax whatever your router writes - :id, {id} and * all
appear as {id} / {*} - and --entrypoint accepts either spelling.
If your router is a common one, please open an
issue with the
registration snippet (the app.Get("/x", h.X) line and how the router value
is built). Adding a framework is a matcher plus a fixture; fiber support
started as exactly that report.
Can't I just ask an AI assistant about my code? You can, and the answer
helps you - once, and only if it's right. A chat reply is per-developer,
non-reproducible, and can hallucinate. softmap is a deterministic team
artifact: the same map for everyone, including analysts who don't have an
IDE, every step traceable to file:line, regenerable in CI on every merge,
and diffable between versions (a diff CLI is on the roadmap). Static
analysis is the fact layer; an optional LLM labeling pass may come later -
on top of the facts, not instead of them.
Is it safe to run on private code? Yes. The scan runs locally, executes nothing, and transmits nothing. There is no telemetry. The output is files in the directory you chose.
Why only Go? Because this depth - SSA-level guard classification, call graphs, effect detection per driver library - has to be built per language. Go is where we can be precise today; precision is the product.
How is this different from go-callvis or doc generators? go-callvis draws the call graph itself - on a real service that's thousands of edges. softmap's core is what it removes and what it adds: the noise pipeline takes 1639 raw nodes to 46, and the semantic layer adds what a call graph doesn't have - decisions with their error texts, SQL/Kafka effects, outcomes, and a non-developer mode. Doc generators (godoc, Swagger) describe the declared surface; softmap describes behavior.
softmap is early and we want brutal feedback - especially "I scanned my repo and the map was wrong/confusing here". Screenshots of confusing fragments are the most useful thing you can send.
- Bugs (crash, missed entrypoint, wrong effect) and map feedback
(technically correct but unreadable or misleading):
open an issue -
the templates ask for the stats lines, a
--debug-treeexcerpt, and "what did you expect to see, what did you get". - Email: hello@softmap.io
make build # bin/softmap
make test # hermetic: fixture deps are local stubs, no network
scripts/bench-gitea.sh # real-world benchmark (clones Gitea on first run)docs/ARCHITECTURE.md covers the pipeline (loader → entrypoints → call graph → extraction → filter → output), the honesty and degradation model, and how to extend softmap with new frameworks, effect libraries, or filter heuristics.
Apache-2.0. Next up, without dates: map diffs between releases, and hosted maps for teams.




