To check a refactor before you make it, ask a warm code graph three questions: query to pin down the exact node you're about to touch, impact to get its transitive blast radius, and path to explain how any surprising dependent is actually connected. Below is that workflow run for real — on this site's own repository, where a one-line slug helper turns out to reach 31 nodes across every route that renders a tag.
This is Workflow 2 from the CGraph examples, expanded from three commands into the decisions they inform.
The refactor that looks safe
The candidate is toSlug, the helper that turns a blog tag like "code intelligence" into the URL segment code-intelligence:
import slugify from 'slugify'
const toSlug = (text: string) => slugify(text, { strict: true, lower: true })
export default toSlug
Say you want to change its behavior — drop strict mode so slugs keep more characters. One line, one file, no type signature change. Grep will list the files that import it in a second. What grep won't tell you is the transitive story: what depends on the things that depend on it, and whether any of those dependents treat its output as a contract.
Step 1 — pin the node: query
impact and path address graph nodes, not strings, so first confirm what the symbol resolves to:
cgraph-client query '{"q":"toSlug"}'
{ "total": 1, "nodes": [{ "label": "toSlug", "kind": "function",
"source_file": ".../src/app/lib/toSlug.ts" }] }
One node, unambiguous. That matters more in bigger repos, where query is how you discover that there are three functions named parse and you meant the one in lib/config. A note on parameters: query and context take a fuzzy q; the ops that address a single node — explain, impact, path — take an exact node id or symbol name (id, source, target).
Step 2 — size the blast radius: impact
cgraph-client impact '{"id":"toSlug","direction":"dependents"}'
The response is the transitive set of nodes that reach toSlug, breadth-first with depth and the edge that connected each one. On this repo it returns 31 nodes across 3 hops (output trimmed to labels):
{ "total": 31, "nodes": [
{ "depth": 1, "label": "lib/blog.ts", "via": "imports" },
{ "depth": 1, "label": "getAllTags", "via": "CALLS" },
{ "depth": 1, "label": "blog/post-list.tsx", "via": "imports" },
{ "depth": 2, "label": "[tag]/page.tsx", "via": "imports_from" },
{ "depth": 2, "label": "app/sitemap.ts", "via": "imports_from" },
{ "depth": 2, "label": "feed.xml/route.ts", "via": "imports_from" },
{ "depth": 2, "label": "lib/search.ts", "via": "imports_from" },
{ "depth": 3, "label": "search-index.json/route.ts", "via": "imports_from" },
...
] }
Read as a list of files, it's unremarkable. Read as a set of surfaces, it changes the decision: the tag index pages, the per-tag pages, the blog index, the sitemap, the RSS feed, the search index, and the JSON-LD metadata all sit downstream of this one-liner. Every one of them either renders a slug or publishes a URL built from one.
Which means the "one-line behavior tweak" is not a tweak. Tag slugs are public URLs — indexed by search engines, syndicated in the feed, enumerated in the sitemap. Changing toSlug's output silently moves every tag page to a new address and leaves the old ones as 404s. The graph didn't make that judgment; it put the evidence for it in front of you before the edit, which is the entire point of the check.
Step 3 — explain the surprise: path
Blast-radius sets usually contain one entry that makes you frown. Why does the sitemap depend on a slug helper?
cgraph-client path '{"source":"sitemap","target":"toSlug"}'
{ "path": ["sitemap", "getAllTags", "toSlug"] }
The sitemap enumerates the tag index pages, so it calls getAllTags, which slugs every tag it returns. path answers "how are these two even connected?" with the actual chain of edges instead of leaving you to reconstruct it from imports — and it works just as well in reverse, when you're staring at an entry in an impact set and can't see the route back to your change.
Narrow it when the set is noisy: typed traversal
impact follows every edge kind by default — imports, calls, containment. When you only want one relation, explain takes the same relation filter the impact traversal uses. Direct callers only:
cgraph-client explain '{"id":"toSlug","direction":"in","relation":"CALLS"}'
[{ "label": "getAllTags", "file": "src/app/lib/blog.ts", "line": 100 }]
One direct caller. So the whole 31-node radius flows through a single function — which is also the graph telling you where the compatibility shim would go if you did change the slug format: normalize inside getAllTags, and the blast radius collapses to one file.
The same check, from an agent
Each command above is also an MCP tool — graph_query, graph_impact, graph_path, graph_explain — served by the same daemon from the same graph, so an agent registered with CGraph runs this exact workflow before editing a shared function instead of skipping it as too tedious. The eight MCP tools post covers the full set, and impact analysis in ~10ms explains why the answers come back fast enough to run on every refactor, not just the scary ones.
Try it on your next rename
Build the graph and start asking:
cgraph --root . --out cgraph-out
cgraph-client query '{"q":"yourFunction"}'
cgraph-client impact '{"id":"yourFunction","direction":"dependents"}'
If the impact set is bigger than you expected, run path from the strangest entry back to your symbol — that chain is usually the most useful thing you'll read before the refactor. The full workflows live in the CGraph examples, and the tool itself is on GitHub.