Codegraph extracts dependencies between code entities (classes, functions, modules) from multi-language corpora — including non-compilable legacy — into a trait-based metamodel (FamixNG-style), and analyzes the result in TypeScript: dependency graphs, coupling, architecture, and eventually a 3D "code city" visualization (Three.js) of the code structure.
Pipeline: per-language extractors (Java/Spoon first) → versioned
model.jsonl interchange files → TypeScript analyzer → reports and the
city renderer.
The full implementation plan, milestones, and locked design decisions live in
PLAN.md; the conceptual reference for every concept, its attributes, and its
relations is METAMODEL.md — read them before structural changes.
extractors/java/ Maven project (Spoon, noClasspath) → emits model.jsonl. JVM code
only. Also builds a GraalVM native binary per platform, which
embeds the Java API distilled from ct.sym (an image has no JVM
class library for ECJ to borrow).
extractors/csharp/ .NET 10 project (Roslyn, NO MSBuild: one compilation over
every *.cs, BCL reference pack embedded) → emits
model.jsonl. Published as one self-contained binary per OS.
extractors/typescript/ pnpm workspace package `codegraph-typescript` (the
compiler API: ONE program over every *.ts under the roots,
a tsconfig read for resolution options only, no build, no
node_modules needed) → emits model.jsonl. Runtime dependency:
`typescript` alone — never @codegraph/* (a boundary test).
extractors/elixir/ Mix project `codegraph_elixir` (the compiler's PARSER as a
library: `Code.string_to_quoted/2` + a lexical resolver + an
OTP module/export table baked at compile time; no compile, no
deps/) → emits model.jsonl. Shipped as an escript (Elixir
embedded, Erlang/OTP on the machine). `mix codegraph.trace`
writes the compiler trace `--trace` merges as `generated`
edges. No runtime dependency.
schemas/ Generated per-record JSON Schemas + the container contract
(README.md) — THE cross-language contract, committed.
packages/core/ @codegraph/core — traits, edges, language profiles (data),
Zod validation, JSON Schema export. Pure data + validation.
packages/analyzer/ @codegraph/analyzer — graph construction, derived indexes,
queries, metrics (coupling, cycles), exports. Pure computation.
packages/city/ @codegraph/city — the city MODEL: modules → districts,
types → buildings (dimensions from configurable metrics),
dependencies → roof-to-roof arrows. No placement, no
rendering. Pure computation.
packages/navigator/ @codegraph/navigator — the navigator MODEL: the browsable
tree (modules → types → operations/attributes) plus one
classified dependency row per base edge, carrying member,
provenance and anchor included. Pure computation.
packages/insights/ @codegraph/insights — the bottom-up EXPLANATION walk: units
(operations → types → modules), SCC-condensed walk order,
context packs, prompts, Merkle fingerprints, plan/run, the
insights STORE (a port + a SQLite adapter handed an open
database) and its export, the `.insights.jsonl` side-car
(Specy domain vocabulary). Pure computation: source, model
calls and the database are injected.
packages/llm/ @codegraph/llm — LlmClient + OpenRouter (SDK) + Cloudflare
AI Gateway (REST) implementations + a deterministic fake.
The ONLY package that may import @openrouter/sdk.
packages/cli/ @codegraph/cli — `codegraph` command.
packages/viz/ @codegraph/viz — Three.js code city: renders a laid-out
city.json artifact (its ONLY input; guard-enforced). The
ONLY package that may import three. Exports ONE thing,
`mountCityView(host)`, which navigator-ui embeds; its own
Vite app is the standalone page (replay/history).
packages/navigator-ui/ @codegraph/navigator-ui — React model navigator, five
tabs: Navigate (virtualized tree + fan-in/fan-out
evidence), City (the viz view, full-width, a building's
panel opens it in Navigate), Graph (Cytoscape+fcose
dependency graph, module/type modes), Cycles (the
artifact's precomputed tangle report), Coupling (ranked
metrics). Reads navigator.json + city.json (guard-enforced),
plus ONE lookup, `insight.json?id=…`: the selected type's or
module's LLM explanation, shown as an inference, never a fact.
It imports @codegraph/insights not even for types.
The ONLY package that may import react and cytoscape; the
city comes through @codegraph/viz, never through three.
Vite app, no library.
apps/desktop/ The desktop app (Tauri 2): a Cargo workspace — `discovery/`
(the extractor catalogue looked up on PATH/Homebrew, written as
the daemon's registry; pure Rust, tested anywhere) and
`src-tauri/` (window, menu, dialog, drag-and-drop, the
single-executable `codegraph` as its ONE sidecar; needs the
platform's WebKit to compile). No model logic in Rust; the
page it shows is the daemon's, with no Tauri IPC.
fixtures/ Reference corpora + expected model.jsonl snapshots.
core,analyzer,cityandnavigatornever import Three.js or React — they must run in Node with no DOM.vizreads the city model andnavigator-uithe navigator model; neither mutates its model and neither re-derives graph facts the analyzer already computes.- A frontend imports its model package for TYPES ONLY. A value import
drags the whole Node pipeline (→ analyzer → core, zod,
node:sqlite) into the browser bundle and breaks the build. Constants a frontend needs — the artifactkind, the dependency-role vocabulary — are restated as literals and pinned equal to the package's own by a test. - Extractors contain no metamodel intelligence. They emit JSON conforming
to
schemas/— the per-record schemas AND the container contract — and nothing else. All trait/profile/validation logic lives once, incore. An extractor in any language (Java, Go, .NET…) must be able to conform using only the published schema. - The interchange is line-based and closed. One JSON record per line,
sections in order, identity as
(m, s, d), every reference a file-scoped surrogate — so a dangling reference is unwritable, not merely reportable. A producer that cannot close a reference drops it and says so. coreowns the vocabulary. Trait names (TNamed,TInvocable,TAttachedTo…), edge kinds, and provenance values are canonical — never rename or alias them locally.- Only
packages/llmtalks to a model provider.@openrouter/sdkis imported in exactly one file there (a boundary test scans the workspace);insights,core,analyzerand the CLI never open a socket, and every test in the workspace runs against the fake client or a fake transport. Explanations live in<model>.insights.dband its export, the side-car<model>.insights.jsonl— never inmodel.jsonl/model.db. - The insights store is not a cache.
model.dbis rebuilt on any doubt because the model says everything it holds;<model>.insights.dbholds what was PAID for, so it is migrated (never regenerated), a newer or foreign file is refused with its bytes untouched, and there is no silent fallback to the JSONL.decode → import → exportof a side-car is the identity — a tested property; any change to the store must keep it.
- TypeScript, strict mode,
"module": "NodeNext"; Node ≥ 22; pnpm workspace - Zod v4 — one definition per trait yields the TS type, the runtime validator,
and the JSON Schema (
z.toJSONSchema()) - Vitest + fast-check (property-based tests); tsup for builds
- Java 17+ / Maven / Spoon / Jackson for
extractors/java - (future) Three.js + Vite for
packages/viz— Three.js must never leak into other packages' dependency graphs
pnpm install
pnpm -r build # build all TS packages
pnpm -r test # all tests (unit + property)
pnpm run gen:schemas # regenerate schemas/*.schema.json from core (commit the result)
cd extractors/java && ./mvnw package # Maven Wrapper — `mvn` is NOT installed
java -jar target/codegraph-java.jar --src <dir> --out model.jsonl
# the jar needs a JDK on PATH; non-interactive shells do not source sdkman:
# export JAVA_HOME="$HOME/.sdkman/candidates/java/25.0.4-tem"
./build.sh --java --native # GraalVM binary for THIS platform only —
# native-image cannot cross-compile, so CI
# builds one RID per runner
extractors/java/dist/<rid>/codegraph-java --src <dir> --out model.jsonl
# needs nothing installed: it carries the Java API distilled from ct.sym
cd extractors/csharp && dotnet test -c Release # needs the .NET 10 SDK (see extractors/csharp/README.md)
./build.sh --csharp # publish the host binary → extractors/csharp/dist/<rid>/codegraph-csharp
./build.sh --csharp --publish-all # linux-x64/arm64, osx-x64/arm64, win-x64 — all from this host
./test.sh --csharp # dotnet test + the published binary must reproduce the snapshot
extractors/csharp/dist/linux-x64/codegraph-csharp --src <dir> --out model.jsonl
# runtime-only or SDK forms (dotnet <dll>, dotnet run): docs/csharp-extractor.md
./build.sh --ts --sea # ONE Node single-executable of the CLI + daemon + both frontends
packages/cli/dist-sea/<rid>/codegraph --version # for THIS platform only (the image is this Node)
node packages/cli/scripts/sea-smoke.mjs packages/cli/dist-sea/<rid>/codegraph # its three gates (test.sh runs it)
pnpm --filter @codegraph/desktop tauri:dev # the desktop shell (after --sea): copies the image as the
# sidecar, then `tauri dev` — needs Rust + the platform's WebKit (apps/desktop/README.md)
cd apps/desktop && cargo test -p codegraph-discovery # the discovery crate alone, no WebKit needed
node scripts/homebrew/render.mjs --version X.Y.Z --sums SHA256SUMS [--npm-sha256 HEX] --out tap
# the Homebrew cask + formulae the release job pushes to defsquare/homebrew-tap (docs/distribution.md);
# `node --test scripts/homebrew` pins the templates to the release's asset names
./bin/codegraph-typescript --src <dir> --out model.jsonl # after `pnpm -r build`; no other toolchain
./bin/codegraph-typescript --src packages --src extractors/typescript --out codegraph.jsonl # self-hosting
# docs/typescript-extractor.md; `test.sh --ts` checks the built bundle against fixtures/typescript
./build.sh --elixir # mix escript.build → extractors/elixir/dist/codegraph-elixir
./bin/codegraph-elixir --src <dir> --out model.jsonl # needs Erlang/OTP 27+ on PATH (or ~/.local/share/beam)
./bin/codegraph-elixir --src <dir> --deps <dir>/deps --trace <dir>/codegraph-trace.jsonl --out model.jsonl
# docs/elixir-extractor.md; `test.sh --elixir` = mix test + the escript must reproduce fixtures/elixir
# the trace, inside the project: ERL_FLAGS="-pa <repo>/extractors/elixir/_build/dev/lib/codegraph_elixir/ebin" mix codegraph.trace
./bin/codegraph analyze model.jsonl --report deps # after `pnpm -r build`
./bin/codegraph serve model.jsonl # navigator + City tab at http://localhost:4177
# binds EVERY interface by default; --host 127.0.0.1 keeps it local
./bin/codegraph serve --app --data-dir DIR --extractors registry.json # the desktop app's daemon (M14a)
# loopback, ephemeral port, ONE stdout line {"port","token"}; every route under /<token>/;
# POST /jobs {src} runs a registry entry (or opens a model.jsonl) and builds the page;
# exits when stdin closes. registry.json = [{ name, path, extensions: [".java"], launch?, env? }]
./bin/codegraph city model.jsonl --layout --out city.json # the artifacts alone
./bin/codegraph navigator model.jsonl --out navigator.json
./bin/codegraph explain model.jsonl --src DIR --dry-run # the walk plan, no call
./bin/codegraph explain model.jsonl --src DIR --estimate --price-in 0.10 --price-out 0.60 # tokens in/out + cost, no call
OPENROUTER_API_KEY=… ./bin/codegraph explain model.jsonl --src DIR [--max-calls N] [--yes]
# [--model SLUG] (default openai/gpt-5.6-luna) [--rollup-model SLUG] (types+modules; defaults to --model)
# prints the estimate on stderr and asks [y/N] first; --yes (required without a TTY) skips it
# or CLOUDFLARE_API_TOKEN=… CLOUDFLARE_ACCOUNT_ID=… [--provider cloudflare]
# → model.insights.db (the working copy: one commit per finished unit) and its export
# model.insights.jsonl, both beside the model; re-runs redo only what changed
./bin/codegraph explain model.jsonl --export # the side-car from the store (after a killed run); no model read
./bin/codegraph explain model.jsonl --import FILE [--yes] # REPLACE the store's records with a side-car's; asks first
./bin/codegraph insights model.jsonl # the store's summary: records, concepts, what is owed, spend
./bin/codegraph insights model.jsonl --concept aggregate # a list (also --level, --min/--max-confidence, --limit)
./bin/codegraph insights model.jsonl --id ID # one explanation whole; --failures, --runs, --json
# reads <model>.insights.db and NOTHING else (not the model, no provider); writes nothing
./bin/codegraph serve model.jsonl --insights FILE # the explanation panel's store, if not beside the model- Traits, not hierarchy. An entity is
{id, kind, traits[], ...trait keys}. Never introduce an entity class hierarchy; capabilities compose as traits. The canonical test: a Clojure fn-var isTNamed + TStructural + TInvocable. - Provenance on every edge, always one of
declared | derived | dynamic-candidate | generated. Never mix facts and inferences — an analysis that needs facts only filters ondeclared. - Evidence everywhere: entities and edges carry
anchor {file, span}. - Outgoing edges only. Inverse indexes — incoming invocations, subtypes,
importers, and
children(the inverse of the storedparent) — are derived in memory by the analyzer, never serialized. v1 files still carrychildren; M6 drops the key whileTWithChildrenstays a declared trait. - Containment ≠ attachment.
TChildOf/TWithChildren= where it is written;TAttachedTo= what it semantically belongs to. Distinct, both kept. - Stub discipline: external types are degraded
isStubnodes; their edges are kept. Internal-only view = filter stubs. Membership is decided by a whitelist of corpus-declared ids — never by package/name prefix (Spoon noClasspath invents plausible FQNs). - Identity is the natural key
(lang, module, symbol, disambiguator?). A rendered id (lang:module/symbol#disambiguator, from core'srenderId) is a display projection and is never parsed — v1 files carry only the rendered form, so the analyzer compares those strings as opaque tokens; from M6 the key is carried structurally and compared component-wise./and#are reserved in the key's components so rendering stays injective (two keys can never collide into one id); encoding-level surrogates are not identity and never leave the file that assigns them. - Profiles are data. A language profile must be specifiable without being
implemented. Validation:
required ⊆ traits ⊆ required ∪ optionalper kind. - Import graph is the first-class layer — the only one comparable across all languages. Cross-language analyses use the intersection of the profiles involved.
- Graph closure is a tested property: no edge, parent, or child may point to an unknown id (stubs count as known). Enforced by the property suite, not by convention.
- Meaning controls appearance. Every visual channel (height, footprint, color, glow) maps to a documented metric (LOC, fan-in, kind, provenance…). Color is semantic — never reused for aesthetics alone; no decorative effects.
- The city renders the model, honestly. Derived/
dynamic-candidateedges must be visually distinguishable fromdeclaredfacts. Never draw a relationship the model does not contain. - Per-frame paths allocate nothing. Reuse vectors, colors, materials; instanced meshes for buildings. Visual richness does not permit GC pressure in the render loop.
- Judge visible work at user-facing camera angles — review screenshots of the actual render, not coordinates in code.
- Every bug fix starts with the smallest deterministic failing test.
- The property suite (closure, no self-reference
from !== to, provenance set, profile validity, deterministic sorted output) runs against every extractor output — it is the acceptance gate for any new extractor. - Extractor cross-validation: when two extractors cover one language, the richer one (Spoon) is the oracle; the other's edge set must be a subset. Any gap is a missed resolution case, not noise.
- Never move a knob to keep an assertion green — when a test breaks because the model improved, restate it as the durable property it guards.
- Before handing off:
pnpm -r test,pnpm -r build, typecheck green; for the Java extractor,./mvnw package+ schema validation of its output. - Verify new code is actually imported, constructed, and called — an unwired subsystem is not delivered.
- If
schemas/changed, it was regenerated (pnpm run gen:schemas) and committed together with thecorechange that caused it. - For visual changes: exercise them in the browser and read the resulting screenshot before claiming they work.
- Strict typing throughout; make state ownership visible.
- Comments explain constraints and non-obvious invariants, not narration. Keep per-function comments to 1–3 lines.
- Language-specific extraction quirks (e.g. "Spoon invents FQNs in
noClasspath") belong as documented
notesin the language profile, not as tribal knowledge in comments.
- Conventional Commits (
feat:,fix:,docs:,refactor:,chore:,test:), subject < 50 chars, present tense; scope by package when useful (feat(core): …,feat(extractor-java): …). - Describe what the commit actually did — inspect the diff before naming it.
- One logical change per commit/PR. Never amend or force-push without an explicit request.