geml-code-graph
Build, view, update, and navigate a project's call graph as GEML codemap documents. Use when asked to see/update/build a project's code graph or codemap (看下/更新下 code-graph), when asked "who calls X" / "what does X call" / to trace a call chain or impact path, or whenever a .geml-
Install
npx skills add https://github.com/geml-spec/geml/tree/main/integrations/codex-plugin/skills/geml-code-graph
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install geml-spec-geml@llmmart
git clone https://github.com/geml-spec/geml.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole geml-spec/geml collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Code-graph navigation (codemap profile)
The call graph lives as text documents, not a database
(profile):
one GEML document per container (module / dir /
file), each with ONE meta (module, src, entry, resolution-default),
empty-body code blocks per method, and up to three CSV edge tables —
#calls (out), #called-by (in), #unresolved (blind spots). The build's
verify has checked that every edge reference resolves.
The moves
# 1. resolve a name — where does a symbol live
node -e "console.log(JSON.stringify(require('./.geml-code-graph/_index/name-lookup.json')['hashtableFind'],null,1))"
# → [{"anchor":"c:hashtable.c#hashtableFind(…)","doc":"hashtable.c.geml","id":"hashtableFind"}, …]
# Multiple entries = real ambiguity (e.g. a .c definition and a .h inline) — inspect each.
# 2. container overview — the module's surface, one glance
head -8 .geml-code-graph/hashtable.c.geml # meta: entry = the externally-called methods
# 3. open the method block (src= tells you exactly where the code is)
geml get .geml-code-graph/hashtable.c.geml '#hashtableFind'
# 4. forward: what it calls (grep your method's rows; follow doc.geml#id refs)
geml get .geml-code-graph/hashtable.c.geml '#calls'
# 5. reverse: who calls it (aggregated, with file:line sites)
geml get .geml-code-graph/hashtable.c.geml '#called-by'
A reference is #id (same document) or sibling.geml#id (that document, that
block) — geml get it the same way. index.geml holds the repo-level view:
app entries in its meta, #modules / #module-edges aggregate tables.
Reading the tables
| Line | Meaning |
|---|---|
#calls row, empty confidence |
resolved at the document's resolution-default, high confidence |
#calls row kind=candidate |
dispatch ambiguity: one of several implementations, right after its main call row. Treat the SET as the answer, never just the first |
#calls row confidence medium/low |
the extractor is less sure — say so when reporting |
#unresolved rows (hidden table) |
calls the extractor could NOT resolve — blind spots, not evidence of absence; fall back to grep when one matters |
#called-by absent for a method |
no resolved callers. Under resolution-default = heuristic that means little; under cpg it is strong (but pointer/dynamic dispatch still lands in #unresolved) |
Symbol classes: .accessor (bean get/set/is leaves — the graph view hides
them by default, tables keep them) · .leaf (calls nothing, only called — usually skippable when
tracing logic) · .test (test territory) · .flow-entry (critical-flow start).
"看下/更新下 X 项目的 code-graph" — the end-to-end move
The toolkit ships inside the @geml/geml package: geml codemap …
(without a global install: npx -y @geml/geml codemap …).
Dispatch first — generation is slow, the conversation must not block on it
Indexers take real time (scip: seconds–minutes; Joern on a repo: minutes). Pick the executor BEFORE starting:
- Codemap exists, user wants to look → inline, seconds:
serve --background+ open the browser. No subagent. - Update asked and
_index/refresh.jsonexists → no subagent either:geml codemap refresh <dir> --background(detached process, costs the conversation nothing). Open the CURRENT graph immediately — serve renders live, so when the refresh lands, F5 shows it; say exactly that. - geml files must be (re)generated agentically — first build, no recipe
recorded, adapters change, or a refresh failed → hand the WHOLE generation
to ONE subagent (Agent tool;
run_in_background: trueso the user can keep working). Its prompt must be self-contained: project root; detect the languages per the table below (never ask); the exact indexer +geml codemap build --history+geml codemap verifycommands; verify MUST exit 0; write_index/refresh.jsonwith the exact commands used; return container/method/entry counts, verify result, and any language gaps. The MAIN conversation does the last mile itself when the subagent reports:serve --background, open the browser (if an older codemap was already on screen, telling the user to F5 is the whole move).
Have a codemap?
<proj>/.geml-code-graph/index.gemlexists → skip to step 4 (view) or step 3 (update was asked). An oldercodemap//graph/tree from before the rename is not special: regenerate into.geml-code-graph/(one build; carry the*.gemlhistorysidecars over first if they matter) and remove the old directory.Detect the language(s) — NEVER ask the user. (Steps 2–3 are the generation work — per Dispatch above they normally run inside the subagent.) Judge from manifests first, then source-file counts (
Glob/ls). Multiple languages with real code (≥ a handful of files each) → one build with REPEATED--adaptergroups; the codemap merges them (Java+TS validated).Signal Indexer → adapter tsconfig.json/ mostly.ts.tsx.jsnpx --yes @sourcegraph/scip-typescript index --output index.scip(run IN the target repo/subproject) →--adapter scip --raw index.scipReact / JSX ( .tsx.jsx)same scip route, verified tier: <Child />render edges, custom-hook calls, anduseReducer(reducer, …)wiring all resolve high — arrow components (const Foo = () =>) included. Indirect dispatch is absent, not#unresolved: callback-prop calls (onToggle(…)),dispatch()→reducer case handling, and context-injected functions ride scip locals/members and leave NO edge — grep when one matters. Also invisible:memo()/forwardRef()-wrapped components (const = call, inner fn is a local) and module-scoperender(<App />)callersCargo.toml/.rsrust-analyzer scip . --output rust.scip(run IN the crate/workspace root; missing →rustup component add rust-analyzeror the rust-analyzer GitHub releases page) →--adapter scip --raw rust.scip. Precise tier: rust-analyzer-resolved, cross-file/cross-crate calls included; calls into std/external crates land in#unresolvedpom.xml/build.gradle/.javaJoern (locate per Locating Joern below; JDK required): GEML_SRC=<abs-src> GEML_OUT=<abs-raw> GEML_LANG=JAVASRC joern --script <pkg>/codemap/joern-export.sc→--adapter joern --raw <raw>. GEML_LANG takes Joern's--languagenames, UPPERCASE — lowercasejavasrcfails with "No CPG generator exists".c/.hsame Joern route, GEML_LANG=NEWC(valkey-validated).py/go.mod/.ktJoern frontends, GEML_LANG=PYTHONSRCetc. (usable tier — SAY SO in your report)only a code-review-graph graph.db--db <graph.db>(heuristic tier — say so)none of the above report honestly which languages are unsupported; do not guess .vue/.svelteSFCs: covered — use the AUTO build (geml codemap build --root <proj>), not the manual per-indexer route. It virtualizes each SFC project (Volar / svelte2tsx, fetched hermetically via npx) into shadow TS with line-map sidecars, runs one scip pass over shadows + the project's real TS/JS, and attributes every symbol back to the original file and line. Template event handlers surface as edges from a synthetic<Component>.templatenode (@click="save"→#App-template, #save; mustapi-validated across three Vue apps, 85/85 SFCs). Honest residuals — say them when reporting: component-TAG usage (<Child/>) is not a call edge; Nuxt auto-imports (unimportedref, auto-registered components) don't resolve, so those references drop; top-level<script setup>calls, includingcomputed(() => …)bodies, drop exactly like module-level calls in plain TS; a failed virtualization falls back to plain TS indexing and says so.Vendored source trees explode the job list — next.js's
packages/next/src/compiled/carries ~140 checked-in package.json bundles, each becoming its own scip job. Prune them at build time:geml codemap build --root <proj> --exclude "src/compiled/**"(repeatable; the exclusion also keeps their symbols out of the graph).Locating Joern — never hardcode a path. Resolve it fresh on each run, in this order: (1)
joernon PATH — ifjoern --versionworks, use it; (2) else read~/.claude/skills/geml-code-graph/config.json({"joern": "<launcher-or-dir>"}) and pass it asgeml codemap build … --joern <path>(or exportGEML_JOERN); (3) else ASK the user for the joern-cli location (Windows: the folder unzipped from joern-cli.zip; macOS/Linux: the joern-install.sh install dir), WRITE it into that JSON file, then reuse it.<path>may be the launcher itself or the directory holding it (joern.baton Windows,joernon unix). Ask at most once per machine — after that the JSON answers. Mirrors the CLI's own--joern/GEML_JOERNresolution.Build + verify (also the "更新" path — builds are deterministic, only changed documents are rewritten):
geml codemap build --adapter scip --raw index.scip --root <proj> \ --out <proj>/.geml-code-graph --history # --container module|dir|file: match # the layout (default dir; flat C repo → file) geml codemap verify <proj>/.geml-code-graph # MUST exit 0 before showing anyoneFirst successful build: record the recipe so
refresh(and the commit hook) can replay it — write<proj>/.geml-code-graph/_index/refresh.jsonwith the EXACT commands you ran:{ "root": "..", "steps": ["npx --yes @sourcegraph/scip-typescript index --output index.scip", "geml codemap build --adapter scip --raw index.scip --root . --out .geml-code-graph --history", "geml codemap verify .geml-code-graph"] }From then on, "更新下" =
geml codemap refresh <proj>/.geml-code-graph(skips itself when git HEAD hasn't moved; log at_index/refresh.log).View — finish with the browser OPEN, not with instructions.
geml codemap serve <proj>/.geml-code-graph --background # detached: SURVIVES the agent session; # http://localhost:8140, pages render live # from .geml — rebuild + F5, never stale. # already-running port → reused, not stacked. geml codemap serve <proj>/.geml-code-graph --stop # stop it (pid: .geml-code-graph/_index/serve.pid) geml codemap render <proj>/.geml-code-graph # serverless alternative: bake .html next to # each doc; open file:///…/.geml-code-graph/index.htmlAlways
--background(a viewer must not die with the session). Then open it for the user: Windowsstart "" <url>(orStart-Process <url>), macOSopen <url>, Linuxxdg-open <url>. Port taken by something else → pick another (--port), open that one.
index.html is the module overview; clicking a module opens its page inside
the graph area (nested view). Method pages: click = callee chain, ⊕ on an
entry = full caller chain, breadcrumb walks back up.
Keep it in sync on every commit (optional per-project hook)
With the recipe recorded (step 3), a Claude Code PostToolUse hook makes any
git commit Claude runs in that project refresh the codemap in the
BACKGROUND (never blocks the commit; non-commit commands exit instantly;
projects without refresh.json are silently skipped). Add to the project's
.claude/settings.json:
{ "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [
{ "type": "command", "command": "geml codemap refresh .geml-code-graph --hook --commit" }
] } ] } }
(.geml-code-graph = the codemap dir relative to the project root; use an absolute
path if the hook cwd differs.) With --commit, the refreshed documents land
as their own follow-up commit — chore(codemap): refresh for <sha>, codemap
dir only — so the next push carries code + graph together. It is loop-safe
(the follow-up commit changes no source file, so the refresh it triggers
skips) and it stands down when HEAD moved during the refresh or a merge is in
progress. Drop --commit to keep the old behavior: refreshed files stay in
the working tree for you to include in a later commit.
Between commits (editing-time sync), geml codemap serve <dir> --watch
re-runs the recipe after 30s of quiet whenever an indexed source file
changes — pages render live, so a browser reload shows the new graph.
Add --history [-m msg] to build to snapshot changed documents into
.gemlhistory sidecars — then geml history get .geml-code-graph/<doc>.geml shows
the graph's evolution and geml revert .geml-code-graph/<doc>.geml '#method' --rev -1
rolls one method's edges back. Language maturity tiers and the smoke-test
gate: DESIGN-geml-code-graph.md §3.4. An MCP wrapper exists (geml mcp --root <dir>, which serves the four
read-only geml_codemap_* tools next to the document tools when the root holds
a graph); the CLI path works without it.
Files (geml)
-
SKILL.md 13.8 KB
--- name: geml-code-graph description: >- Build, view, update, and navigate a project's call graph as GEML codemap documents. Use when asked to see/update/build a project's code graph or codemap (看下/更新下 code-graph), when asked "who calls X" / "what does X call" / to trace a call chain or impact path, or whenever a .geml-code-graph/ directory with index.geml and _index/name-lookup.json exists. Detects the project's languages itself — never asks the user; viewing ends with the browser OPEN on the graph. --- # Code-graph navigation (codemap profile) The call graph lives as **text documents, not a database** ([profile](https://github.com/geml-spec/geml/blob/main/spec/profiles/geml-codemap/geml-codemap-profile.md)): one GEML document per container (module / dir / file), each with ONE meta (`module`, `src`, `entry`, `resolution-default`), empty-body `code` blocks per method, and up to three CSV edge tables — `#calls` (out), `#called-by` (in), `#unresolved` (blind spots). The build's `verify` has checked that every edge reference resolves. ## The moves ```sh # 1. resolve a name — where does a symbol live node -e "console.log(JSON.stringify(require('./.geml-code-graph/_index/name-lookup.json')['hashtableFind'],null,1))" # → [{"anchor":"c:hashtable.c#hashtableFind(…)","doc":"hashtable.c.geml","id":"hashtableFind"}, …] # Multiple entries = real ambiguity (e.g. a .c definition and a .h inline) — inspect each. # 2. container overview — the module's surface, one glance head -8 .geml-code-graph/hashtable.c.geml # meta: entry = the externally-called methods # 3. open the method block (src= tells you exactly where the code is) geml get .geml-code-graph/hashtable.c.geml '#hashtableFind' # 4. forward: what it calls (grep your method's rows; follow doc.geml#id refs) geml get .geml-code-graph/hashtable.c.geml '#calls' # 5. reverse: who calls it (aggregated, with file:line sites) geml get .geml-code-graph/hashtable.c.geml '#called-by' ``` A reference is `#id` (same document) or `sibling.geml#id` (that document, that block) — `geml get` it the same way. `index.geml` holds the repo-level view: app entries in its meta, `#modules` / `#module-edges` aggregate tables. ## Reading the tables | Line | Meaning | |---|---| | `#calls` row, empty confidence | resolved at the document's `resolution-default`, high confidence | | `#calls` row `kind=candidate` | dispatch ambiguity: one of several implementations, right after its main `call` row. Treat the SET as the answer, never just the first | | `#calls` row confidence `medium`/`low` | the extractor is less sure — say so when reporting | | `#unresolved` rows (hidden table) | calls the extractor could NOT resolve — **blind spots, not evidence of absence**; fall back to grep when one matters | | `#called-by` absent for a method | no *resolved* callers. Under `resolution-default = heuristic` that means little; under `cpg` it is strong (but pointer/dynamic dispatch still lands in `#unresolved`) | Symbol classes: `.accessor` (bean get/set/is leaves — the graph view hides them by default, tables keep them) · `.leaf` (calls nothing, only called — usually skippable when tracing logic) · `.test` (test territory) · `.flow-entry` (critical-flow start). ## "看下/更新下 X 项目的 code-graph" — the end-to-end move The toolkit ships inside the `@geml/geml` package: `geml codemap …` (without a global install: `npx -y @geml/geml codemap …`). ### Dispatch first — generation is slow, the conversation must not block on it Indexers take real time (scip: seconds–minutes; Joern on a repo: minutes). Pick the executor BEFORE starting: - **Codemap exists, user wants to look** → inline, seconds: `serve --background` + open the browser. No subagent. - **Update asked and `_index/refresh.json` exists** → no subagent either: `geml codemap refresh <dir> --background` (detached process, costs the conversation nothing). Open the CURRENT graph immediately — serve renders live, so when the refresh lands, F5 shows it; say exactly that. - **geml files must be (re)generated agentically** — first build, no recipe recorded, adapters change, or a refresh failed → hand the WHOLE generation to ONE subagent (Agent tool; `run_in_background: true` so the user can keep working). Its prompt must be self-contained: project root; detect the languages per the table below (never ask); the exact indexer + `geml codemap build --history` + `geml codemap verify` commands; verify MUST exit 0; write `_index/refresh.json` with the exact commands used; return container/method/entry counts, verify result, and any language gaps. The MAIN conversation does the last mile itself when the subagent reports: `serve --background`, open the browser (if an older codemap was already on screen, telling the user to F5 is the whole move). 1. **Have a codemap?** `<proj>/.geml-code-graph/index.geml` exists → skip to step 4 (view) or step 3 (update was asked). An older `codemap/`/`graph/` tree from before the rename is not special: regenerate into `.geml-code-graph/` (one build; carry the `*.gemlhistory` sidecars over first if they matter) and remove the old directory. 2. **Detect the language(s) — NEVER ask the user.** (Steps 2–3 are the generation work — per Dispatch above they normally run inside the subagent.) Judge from manifests first, then source-file counts (`Glob`/`ls`). Multiple languages with real code (≥ a handful of files each) → one build with REPEATED `--adapter` groups; the codemap merges them (Java+TS validated). | Signal | Indexer → adapter | |---|---| | `tsconfig.json` / mostly `.ts` `.tsx` `.js` | `npx --yes @sourcegraph/scip-typescript index --output index.scip` (run IN the target repo/subproject) → `--adapter scip --raw index.scip` | | React / JSX (`.tsx` `.jsx`) | same scip route, verified tier: `<Child />` render edges, custom-hook calls, and `useReducer(reducer, …)` wiring all resolve high — arrow components (`const Foo = () =>`) included. Indirect dispatch is **absent, not `#unresolved`**: callback-prop calls (`onToggle(…)`), `dispatch()`→reducer case handling, and context-injected functions ride scip locals/members and leave NO edge — grep when one matters. Also invisible: `memo()`/`forwardRef()`-wrapped components (const = call, inner fn is a local) and module-scope `render(<App />)` callers | | `Cargo.toml` / `.rs` | `rust-analyzer scip . --output rust.scip` (run IN the crate/workspace root; missing → `rustup component add rust-analyzer` or the rust-analyzer GitHub releases page) → `--adapter scip --raw rust.scip`. Precise tier: rust-analyzer-resolved, cross-file/cross-crate calls included; calls into std/external crates land in `#unresolved` | | `pom.xml` / `build.gradle` / `.java` | Joern (locate per **Locating Joern** below; JDK required): `GEML_SRC=<abs-src> GEML_OUT=<abs-raw> GEML_LANG=JAVASRC joern --script <pkg>/codemap/joern-export.sc` → `--adapter joern --raw <raw>`. GEML_LANG takes Joern's `--language` names, UPPERCASE — lowercase `javasrc` fails with "No CPG generator exists" | | `.c` / `.h` | same Joern route, `GEML_LANG=NEWC` (valkey-validated) | | `.py` / `go.mod` / `.kt` | Joern frontends, `GEML_LANG=PYTHONSRC` etc. (usable tier — SAY SO in your report) | | only a code-review-graph `graph.db` | `--db <graph.db>` (heuristic tier — say so) | | none of the above | report honestly which languages are unsupported; do not guess | `.vue` / `.svelte` SFCs: covered — use the AUTO build (`geml codemap build --root <proj>`), not the manual per-indexer route. It virtualizes each SFC project (Volar / svelte2tsx, fetched hermetically via npx) into shadow TS with line-map sidecars, runs one scip pass over shadows + the project's real TS/JS, and attributes every symbol back to the original file and line. Template event handlers surface as edges from a synthetic `<Component>.template` node (`@click="save"` → `#App-template, #save`; mustapi-validated across three Vue apps, 85/85 SFCs). Honest residuals — say them when reporting: component-TAG usage (`<Child/>`) is not a call edge; Nuxt auto-imports (unimported `ref`, auto-registered components) don't resolve, so those references drop; top-level `<script setup>` calls, including `computed(() => …)` bodies, drop exactly like module-level calls in plain TS; a failed virtualization falls back to plain TS indexing and says so. Vendored source trees explode the job list — next.js's `packages/next/src/compiled/` carries ~140 checked-in package.json bundles, each becoming its own scip job. Prune them at build time: `geml codemap build --root <proj> --exclude "src/compiled/**"` (repeatable; the exclusion also keeps their symbols out of the graph). **Locating Joern — never hardcode a path.** Resolve it fresh on each run, in this order: (1) `joern` on PATH — if `joern --version` works, use it; (2) else read `~/.claude/skills/geml-code-graph/config.json` (`{"joern": "<launcher-or-dir>"}`) and pass it as `geml codemap build … --joern <path>` (or export `GEML_JOERN`); (3) else ASK the user for the joern-cli location (Windows: the folder unzipped from joern-cli.zip; macOS/Linux: the joern-install.sh install dir), WRITE it into that JSON file, then reuse it. `<path>` may be the launcher itself or the directory holding it (`joern.bat` on Windows, `joern` on unix). Ask at most once per machine — after that the JSON answers. Mirrors the CLI's own `--joern` / `GEML_JOERN` resolution. 3. **Build + verify** (also the "更新" path — builds are deterministic, only changed documents are rewritten): ```sh geml codemap build --adapter scip --raw index.scip --root <proj> \ --out <proj>/.geml-code-graph --history # --container module|dir|file: match # the layout (default dir; flat C repo → file) geml codemap verify <proj>/.geml-code-graph # MUST exit 0 before showing anyone ``` **First successful build: record the recipe** so `refresh` (and the commit hook) can replay it — write `<proj>/.geml-code-graph/_index/refresh.json` with the EXACT commands you ran: ```json { "root": "..", "steps": ["npx --yes @sourcegraph/scip-typescript index --output index.scip", "geml codemap build --adapter scip --raw index.scip --root . --out .geml-code-graph --history", "geml codemap verify .geml-code-graph"] } ``` From then on, "更新下" = `geml codemap refresh <proj>/.geml-code-graph` (skips itself when git HEAD hasn't moved; log at `_index/refresh.log`). 4. **View — finish with the browser OPEN, not with instructions.** ```sh geml codemap serve <proj>/.geml-code-graph --background # detached: SURVIVES the agent session; # http://localhost:8140, pages render live # from .geml — rebuild + F5, never stale. # already-running port → reused, not stacked. geml codemap serve <proj>/.geml-code-graph --stop # stop it (pid: .geml-code-graph/_index/serve.pid) geml codemap render <proj>/.geml-code-graph # serverless alternative: bake .html next to # each doc; open file:///…/.geml-code-graph/index.html ``` Always `--background` (a viewer must not die with the session). Then open it for the user: Windows `start "" <url>` (or `Start-Process <url>`), macOS `open <url>`, Linux `xdg-open <url>`. Port taken by something else → pick another (`--port`), open that one. `index.html` is the module overview; clicking a module opens its page inside the graph area (nested view). Method pages: click = callee chain, ⊕ on an entry = full caller chain, breadcrumb walks back up. ## Keep it in sync on every commit (optional per-project hook) With the recipe recorded (step 3), a Claude Code PostToolUse hook makes any `git commit` Claude runs in that project refresh the codemap in the BACKGROUND (never blocks the commit; non-commit commands exit instantly; projects without `refresh.json` are silently skipped). Add to the project's `.claude/settings.json`: ```json { "hooks": { "PostToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "geml codemap refresh .geml-code-graph --hook --commit" } ] } ] } } ``` (`.geml-code-graph` = the codemap dir relative to the project root; use an absolute path if the hook cwd differs.) With `--commit`, the refreshed documents land as their own follow-up commit — `chore(codemap): refresh for <sha>`, codemap dir only — so the next push carries code + graph together. It is loop-safe (the follow-up commit changes no source file, so the refresh it triggers skips) and it stands down when HEAD moved during the refresh or a merge is in progress. Drop `--commit` to keep the old behavior: refreshed files stay in the working tree for you to include in a later commit. Between commits (editing-time sync), `geml codemap serve <dir> --watch` re-runs the recipe after 30s of quiet whenever an indexed source file changes — pages render live, so a browser reload shows the new graph. Add `--history [-m msg]` to build to snapshot changed documents into `.gemlhistory` sidecars — then `geml history get .geml-code-graph/<doc>.geml` shows the graph's evolution and `geml revert .geml-code-graph/<doc>.geml '#method' --rev -1` rolls one method's edges back. Language maturity tiers and the smoke-test gate: [DESIGN-geml-code-graph.md](https://github.com/geml-spec/geml/blob/main/docs/design/specs/geml-codemap/DESIGN-geml-code-graph.md) §3.4. An MCP wrapper exists (`geml mcp --root <dir>`, which serves the four read-only `geml_codemap_*` tools next to the document tools when the root holds a graph); the CLI path works without it.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.