Architecture
An index is a deterministic graph in SQLite. Queries, MCP, and the visual view read that graph. What each language extracts lives on its own page. This page is the pipeline and the rules every plugin shares.
Invariants
These hold everywhere in the code base. A change that breaks one is a design change.
- Deterministic edges. Every edge comes from a parser, the type checker, or a named rule. The same code gives the same graph.
- Edges carry evidence. The
file:linethat produced the edge, plus a confidence:exact,resolved, orheuristic. - Static analysis. The indexer reads source files. The app, its code, and its databases stay untouched.
- Plans are overlays. Loading or checking a plan leaves the graph DB unchanged.
- Read-only serving. MCP and the visual view only read the graph. The MCP
indextool rebuilds it from source.
Index, store, query
source files
│ detect languages and frameworks
▼
language plugin.index()
│ framework hooks, then framework.contribute()
▼
GraphBuilder → SQLite (core/store.py)
│
├── query.py reaches, impact, path, writers, readers
├── link.py N repo DBs → one DB (`link` is the two-repo form)
├── plans.py overlay checks (the DB stays unchanged)
├── mcp_server stdio; read-only except index
└── viz/ local read-only web view
| step | who | result |
|---|---|---|
| detect | core/detect.py, then .cg.yaml frameworks.add / remove | the active languages and frameworks |
| index | LanguagePlugin.index | syntax nodes, calls, a per-file report |
| hooks | FrameworkPlugin.register_hooks | type rules and fact handlers, before resolution |
| contribute | FrameworkPlugin.contribute | framework nodes and edges |
| finish | indexer.py | completeness, entry tags, the SQLite write |
Marker files include composer.json, package.json, Cargo.toml, pyproject.toml, and Package.swift.
Presets in presets/ supply guard names and skip lists (common, then language, then framework) and are stored in graph meta. routes reads them back.
core/paths.py is the one skip list and .cg.yaml exclude set.
Every plugin and the coverage scan use it, so the same rules decide what is indexed and what cg coverage counts.
GraphBuilder.add_node(kind, key, …) gives the id kind:key. add_edge stores the edge with its evidence.
After index(), a plugin may set self.file_report (repo-relative paths):
| key | meaning |
|---|---|
seen | files that became nodes |
parse_failed | syntax errors |
skipped_oversize | over the size limit |
unmapped | parsed, not placed in the module table |
excluded | left out by skips or exclude globs |
roots | directories (ending in /) and files the plugin reads |
coverage.py buckets every discovered file of that language from the report (Completeness).
A file missing from seen counts as excluded, or as unmapped when the report has roots and the file sits under none of them.
Plugins
class LanguagePlugin(ABC):
name: str
def detect(self, project: Project) -> bool
def index(self, project, builder: GraphBuilder, frameworks: list[FrameworkPlugin]) -> dict
class FrameworkPlugin(ABC): # Laravel on PHP, Django on Python, Nuxt on TypeScript
name: str; language: str
def detect(self, project) -> bool
def register_hooks(self, lang_ctx) -> None
def contribute(self, project, builder, lang_ctx) -> dict
A framework teaches the host resolver, then adds its own nodes.
| hook | example |
|---|---|
| type rule | Model::query() → builder:Model; app(X::class) → X |
| fact handler | ->where('col') on that builder → READS_COLUMN; DB::connection('x') → USES_CONNECTION |
Compiler indexes (rust-analyzer, scip-clang, scip-java, the Swift index) match SCIP occurrences onto the syntax-layer nodes by file, line, and name.
Ids stay the same in heuristic and exact mode. cg index --scip FILE merges an existing index.
Go has an indexer recipe only (plugins/stubs). Java is a heuristic plugin (Java).
Per-language facts: Python, PHP, TypeScript / JavaScript frameworks, Kotlin, Java, Swift, Rust, C and C++.
Queries
| command | walk |
|---|---|
reaches | propagating edges in reverse; depth recorded; shortest evidence path per dependent (KIND @file:line [confidence]); grouped by entry kind and module |
impact | that walk from a method, stopping at entry points |
writers / readers | WRITES_* / READS_* edges for a table or a stored property |
siblings | the same method on sibling types; other users of the same tables, columns, config keys, and connections; co-callers by Jaccard similarity of callee sets |
path | one shortest chain between two specs |
Override hops are the override relation: impact shows overrides: and (via override), and tests / reaches seed the same way.
With a gate scenario indexed, dependents reached only through gated code sit in a GATED group (gate scenarios).
Specs: query targets.
Cross-repo link
cg link copies every repo graph into one SQLite DB (workspace).
file gains a repo prefix and attrs.repo is set. An id that occurs in more than one repo is stored as <repo>:<id> and its edges are rewritten, except external: and endpoint: ids, which stay one node (one system, one protocol name). Other ids are unchanged, so two repos with no shared ids keep the same ids.
The command adds MATCHES_ROUTE from each repo's http: endpoints to routes of every other server (a backend may be the client), then runs channels, protocols, payload checks and entry tagging once over the union.
A Laravel app is a client when it calls Http, an injected Factory / PendingRequest, or Guzzle. The PHP plugin writes the same http: endpoints as the TypeScript and Kotlin clients, including a base URL read from a container binding through config() and env(), so cg link --repo api=…:both --repo payments=…:both matches those calls to the other backend. See PHP.
impact, downstream, and path then cross the whole workspace, including a chain of three or more repos.
A frontend links: list limits which servers that repo is matched against (apps and workspace).
Matching (cg_code_graph/link.py) is deterministic: the method must agree, then each path segment.
A literal fitted into {param} is resolved. Catch-alls absorb the tail. The best candidate has the fewest heuristic fits, then the most literal agreements; a tie is ambiguous.
exact means the segments agree and the client base was traced. An unknown origin is a suffix match at heuristic, and only when the dropped route prefix is a base path such as api or v1. A short path does not match a longer route by dropping a leading literal (/status does not match /payments/{id}/status). When the client origin is an env placeholder or a literal base URL and that text names one linked repo, only that repo's routes are considered. When it names a service that is none of the linked repos, an unmatched call is external (other origin) and is not suffix-matched. A generic base such as API_BASE_URL still matches by path. The report lists every endpoint with its match and evidence. A match whose calling function has no entry point (test entries excluded) is listed under "Matched, no live caller" and is not counted as a live call site.
Payload / field check
For a client endpoint with one route match, cg_code_graph/payload.py compares the keys the client sends and parses with the server schema or the returned shape, and writes payload_checks (each issue has file:line on both sides).
Ambiguous matches are skipped. Kind names: Graph schema.
Laravel routes store attrs.request.keys from the action's FormRequest rules() (VALIDATED_BY), inline $request->validate([...]), Validator::make($request->all(), [...]), and $request->validated() reads. A key records required, nullable, sometimes, a type from the rule (integer, numeric, boolean, array, string, in:a,b as enum values), and file:line. Nested items.*.id stays dotted. GET and HEAD keys are query; other methods are body.
TypeScript, Dart, Kotlin and Swift body and query keys are normalised to one list before that compare. The TypeScript shape is {keys, conditional} (a payload variable in the same function, or a typed parameter whose optional members are conditional). Dart, Kotlin and Swift use [{key, type, line}]. cg api-calls prints body keys: and query keys: under each call site; --json includes both.
A client key the schema does not declare is request_unknown_field (medium: Laravel ignores it). A required key the client does not send is request_missing_required. orderId against order_id is request_case_mismatch when that folded spelling matches one server key and the route has no ConvertCase / CamelCase / SnakeCase middleware. sometimes, nullable, and required_if / required_with / Rule::requiredIf are not reported missing. A spread, Object.assign, FormData, or a value built in another function is opaque and produces neither missing nor unknown keys. items sent as a whole value satisfies items.*. An Elysia model name that does not resolve on that instance is an unknown schema, so nothing is checked. $request->validated() and $request->input() keys are stored and are not required; a route with no rules is not checked.
AI harnesses: LLM tools, MCP servers and agents
A tool is registered under a name and a harness dispatches to the handler. There is no call edge in the code, so cg models the tool as a protocol endpoint:
Web / native bridges
A hybrid app calls native code by name. cg links each call to the Kotlin, Java, Swift or Objective-C handler on every platform, so impact, downstream, tests and --platform cross the bridge. Electron IPC and Tauri commands use the same endpoint model across processes.