Graph

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:line that produced the edge, plus a confidence: exact, resolved, or heuristic.
  • 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 index tool 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
stepwhoresult
detectcore/detect.py, then .cg.yaml frameworks.add / removethe active languages and frameworks
indexLanguagePlugin.indexsyntax nodes, calls, a per-file report
hooksFrameworkPlugin.register_hookstype rules and fact handlers, before resolution
contributeFrameworkPlugin.contributeframework nodes and edges
finishindexer.pycompleteness, 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):

keymeaning
seenfiles that became nodes
parse_failedsyntax errors
skipped_oversizeover the size limit
unmappedparsed, not placed in the module table
excludedleft out by skips or exclude globs
rootsdirectories (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.

hookexample
type ruleModel::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

commandwalk
reachespropagating edges in reverse; depth recorded; shortest evidence path per dependent (KIND @file:line [confidence]); grouped by entry kind and module
impactthat walk from a method, stopping at entry points
writers / readersWRITES_* / READS_* edges for a table or a stored property
siblingsthe same method on sibling types; other users of the same tables, columns, config keys, and connections; co-callers by Jaccard similarity of callee sets
pathone 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.

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.