CLAUDE.md · git:20261015.74a76a6 · 2026-10-15 · sha256 e38e8762ec964078
CLAUDE.md git:20261015.74a76a6A
Immutable. This exact content never changes and is served at /api/v1/blob/e38e8762ec964078.
<!-- markdownlint-disable MD013 -->
<!-- CLAUDE.md is agent-facing prose: long unwrapped paragraphs on
purpose, because an agent reads the file whole and rewrapping
would churn it on every edit. Every other rule still applies. -->
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
`scripts/docs-check.py` reads this file (check 7). If you change the
self-analysis `--filter` below, change it in `.githooks/pre-commit`,
`scripts/update-readme-stats.sh`, `scripts/patch-readme-stats.py` and
`README.md` too, or the next commit fails.
## What this is
`cgg` is a CLI that generates call graphs (mermaid by default; also json/dot/graphml) from source trees. It is offline, deterministic, single-binary — no language servers, no build artifacts required. The same pipeline is also a Rust library (`cgg::analyze`), a Python extension module (`import cgg`, `crates/cgg-py`), a C ABI (`crates/cgg-ffi`) and a Node/N-API module (`crates/cgg-node`); the CLI remains a single static binary with no Python or Node linkage, and none of the four front ends may fork the resolver ordering. Supports 46 languages via tree-sitter plugins (including the Smithy, Protobuf, GraphQL, OpenAPI/Swagger, and AsyncAPI interface/descriptor languages, whose shape graphs are mapped onto the call-graph model), plus Jupyter notebooks (`.ipynb`) via a JSON cell extractor that feeds the Python plugin. OpenAPI/AsyncAPI documents are YAML or JSON, both parsed with the YAML grammar and content-detected by their root `openapi:`/`swagger:`/`asyncapi:` key (see `cgg-lang::detect`), so ordinary `.yaml`/`.json` files are untouched. Primary consumer of the output is coding agents reading mermaid in their context window.
## Commands
```bash
# Build the CLI (workspace member `cgg`)
cargo build --release -p cgg
# Run the full test suite — this is what the pre-commit hook gates on
cargo test --workspace
# Run a single crate's tests
cargo test -p cgg-resolve
# Run a single test by name
cargo test -p cgg-lang python_shebang_beats_extension
# Run cgg against itself (sanity check) — this is also the README showcase
# graph. `analyze_in_pool` is the pipeline body; `cgg::run` is a private
# shim in main.rs and matches several test helpers of the same name.
./target/release/cgg ./crates -t mermaid --filter 'cgg::analyze_in_pool$' -n 1
# Doc/README consistency gate (also the last step of the pre-commit hook).
# Needs target/release/cgg to exist — it shells out to `cgg --help`.
python3 scripts/docs-check.py
# Build + test the Python extension module (needs `uv`; network on first
# run). The pytest suite under crates/cgg-py/tests/ runs from HERE, not
# from `cargo test` — cgg-py sets `test = false`. Neither `cargo build`
# nor `cargo test` needs an interpreter.
./scripts/build-python.sh
./scripts/build-python.sh --wheel # release wheel into dist/
# Build + test the Node module (needs node + npx; downloads @napi-rs/cli)
cd crates/cgg-node && npx --yes --package=@napi-rs/cli@3.8.5 -- napi build --platform --release
cd crates/cgg-node && node --test tests/*.test.js
# Reproduce the README benchmark table (clones repos into $CGG_BENCH_DIR, default /storage/cgg-test_repos)
./scripts/benchmark.sh
# Every gate this repo has, in order, before tagging a release.
# Never commits, tags or pushes — it prints the commands and stops.
./scripts/release.sh --purpose "what this release is for"
# After every release (once the tag is pushed): upgrade every local cgg —
# PATH copies, binaries listed in ~/.config/cgg/local-installs (copies other
# projects stage), the in-repo Python venv and Node module — and verify each
# reports the new version. release.sh prints this as its last step.
./scripts/upgrade-local.sh
# Install the project pre-commit hook (test + release build + README regen)
./scripts/install-hooks.sh
# Bypass the pre-commit hook for a docs-only commit
CGG_SKIP_PRECOMMIT=1 git commit ... # or: git commit --no-verify
```
No lint runs on commit, but `scripts/release.sh` gates on `cargo clippy --workspace --all-targets -- -D warnings` and `cargo fmt --all --check`, so code that fails either blocks a release. It also gates on **outstanding work** (`scripts/unmerged-inventory.sh`: open PRs, unmerged branches, and every contributor fork's branches ahead of main — 0.9.0 missed a finished Lean 4 plugin on a fork; deliberate exclusions go in `scripts/unmerged-acknowledged.txt` with a reason, or pass `--allow-unmerged`) and on **packaging exactly as each registry receives it**: a verified `cargo package --workspace` (every publishable crate rebuilt in isolation against crates.io, which catches a `[patch]` or git dependency that builds here and breaks `cargo install`), `npm pack` of the root and five platform packages, and a Python sdist with `twine check`. The release workflow runs the same crate verification as a job that PyPI and npm publishing wait on. `deny.toml` exists for `cargo-deny` license/advisory auditing; nothing runs it automatically.
## Architecture
Nine workspace crates. The core is a strict pipeline — data flows one direction, later crates depend on earlier ones, never the reverse — and the four front ends are translation layers over one entry point.
```text
cgg-walk → cgg-lang → cgg-resolve → cgg (query) → cgg-format
↑
cgg-core (Graph, IDs, audit — shared types)
front ends (all four call cgg::analyze — one copy of the resolver ordering):
cgg (bin) → cgg::analyze → cgg::emit [the CLI]
cgg-py → cgg::analyze [the PyO3 module, import cgg]
cgg-ffi → cgg::analyze [the C ABI, libcgg.so]
cgg-node → cgg::analyze [the N-API module, require()]
```
- **cgg-core** — the substrate. `Graph`, callable/edge IDs, audit records, facts, framework rules, stdlib lookup tables, `cpu::default_jobs`. Every other crate depends on this; it depends on nothing internal.
- **cgg-walk** — file discovery. Honors `.gitignore` + a built-in deny list, classifies files (binary detection, symlink-chain guards), emits `WalkOutcome`.
- **cgg-lang** — language plugin layer. `LanguageDetector` (extension/shebang/header), `ParserPool` (tree-sitter parser caching), and `PluginRegistry::with_v1_plugins` (defined in `crates/cgg-lang/src/lib.rs`), which calls `plugins::register_all` — the 46 `reg.register(…)` lines in `crates/cgg-lang/src/plugins.rs` that wire in every `LanguagePlugin` impl under `crates/cgg-lang/src/plugins/`. `.ipynb` files are pre-processed in `cgg-lang::notebook::extract_python_source` before being handed to the Python plugin. Each plugin implements `extract` to pull callables + raw call sites out of a tree-sitter AST. The Rust plugin also expands item-position invocations of `macro_rules!` defined in the same file (`plugins/rust_macro.rs`: no-repetition arms only, no nested expansion; a macro invoked by path outside any function is a `toplevel:macro-invocation` root, and identifiers passed to an unexpanded item-position macro go to `name_mentions`; members of a `pub trait` are public); what they generate is tagged `macro:<name>` — a call target, kept out of duck-typed by-name fan-out and implicit-trait rooting, and reported dead at no more than medium (`macro-generated`). The C and C++ plugins do the same for function-defining `#define`s (`plugins/c_macro.rs`, `FileFacts::fn_macros`/`macro_invocations`, `c_macro::expand` called in `lib.rs` before `unify_declarations`): invocations at file, namespace or class scope of the last same-named `#define` earlier in the file, or the first in the `#include "…"` closure (depth 8), are expanded one level (`#`, `##`, `__VA_ARGS__`), keeping only clean function definitions, tagged `macro:<name>`; an invocation outside any function is a dead-code root (`toplevel:macro-invocation`), and an unexpanded one records its identifier arguments in `name_mentions`. Elixir and Clojure definitions whose name is unquoted inside a quote (`def unquote(n)`, `(defn ~n …)`) are not extracted; Elixir `defmacrop`/`defdelegate`/`defguard`/`defguardp` are definitions. `cgg_lang::receiver::peel` reads a receiver's shape by node kind — parenthesized, `await`/`try`, `!`, deref/address-of, casts (call-form casts from `cgg_core::stdlib::CAST_INTRINSICS`) and constructions (`CONSTRUCTING_METHODS`) — and plugins record a cast or construction as its type with `RefRecord::typed_receiver`. TypeScript parses `.ts`/`.mts`/`.cts` with the TypeScript grammar and `.tsx` with TSX (`LanguagePlugin::ts_language_for`); TSX misreads `<T>x` as JSX. A call written with explicit type arguments is recorded under its bare name, read from the tree: C++ `template_function`/`template_method`/`dependent_name` (scope split on nested `qualified_identifier`s), Dart `instantiation_expression`, Scala `generic_function`, C# `generic_name` and `x?.M()` (`conditional_access_expression`; receiver `x?.y` reads as `x.y`), F# `typed_expression`, Julia `parametrized_type_expression`; Go `F[T](x)` and `F[T](a,b)` count as calls only through a bare name or an imported package, and Swift's `Wrap<Int>.s()` comparison misparse reads as `Wrap.s()`. Julia short-form methods under `where`/`::T` are definitions. The C# plugin re-parses a file that fails to parse with its `#if`/`#else` lines blanked (offsets unchanged), keeping only the first branch if that still fails; the detector classifies a `.h` with a line beginning `virtual` as C++; the C++ plugin cleans qualified names produced by error recovery (return type or macro folded in). The C and C++ plugins capture function names passed to registration calls (the registrar-argument capture) as value references. Names cgg cannot turn into edges (serde attribute strings, functions passed as values inside macro arguments) go to `FileFacts::name_mentions`, which only the dead-code report reads. Plugins mark value-position sites that carry a receiver (Java method references, C# method groups through `this`/`base`/a type/a bare name, Python `return`/`yield`/decorator/default/comprehension/`is`/chained assignment) with `RefRecord::value_ref`; they are recorded only when `ExtractCtx::reference_sites` is set (`--reference-edges`, or `--dead-code`, which forces it on). The Kotlin plugin rebuilds receivers from the tree (`x!!` → `x`, `Foo(…)` → `Foo`, `this@Outer` → `Outer`) and records `: Base()` superclasses and a `ClassDecl` per class and object. Plugins also record the names the file's code is importable under (`FileFacts::packages`: Java/Kotlin `package`, C# namespaces, the `go.mod` module, `package.json`/`deno.json` names and tsconfig `paths` keys); the Go and JS/TS plugins read those manifests through `plugins/manifest.rs`, as `rust.rs` reads `Cargo.toml`.
- **cgg-resolve** — links call sites to definitions. The order in `analyze_in_pool` (`crates/cgg/src/lib.rs`, phase comments `Phase 3` … `Phase 3f`) matters:
1. `type_hints::build_return_type_map` + `propagate_types_with_returns` — infer variable types from params, locals (Rust: the `let` in scope at the site; Zig: the `let` in scope at the site, typed from written or declared-return types only; C++: per call site; every other language file-wide, last write wins), constructors, return types. A Rust `let x = Type::f(..)` is settled against `f`'s declared return type (`type_hints::settle_assoc_call_locals`, before propagation; `Self` or the type keeps the reading, another named type retypes it, anything else leaves it untyped), annotated and struct-literal `let`s type their local, and a later binding hides an earlier `let`'s or parameter's type (`RustBindings::shadowed_since`). Rust parameter and field types see through `Arc`/`Box`/`Mutex`/… chains (the wrapper lists live in `cgg_core::rust_types`), and a lock guard (`x.lock()`) types as what the lock holds. Parameter lists are located structurally (`param_list` skips annotation, attribute, `pub(…)` and C++ attribute groups) and split only between parameters (`split_top_level`), so a generic parameter (`Map<String, Handler> h`) is its outer type, never a type argument; C++ records a template's pointee under `name#deref` (`cgg_core::DEREF_SUFFIX`) and uses it only for `->` calls (`RefRecord::arrow`). Parameter, return-type and field annotations are read through `cgg_core::declared_type`, driven by the `cgg_core::stdlib` tables `MAYBE_TYPE_WRAPPERS`, `UNION_TYPE_CONSTRUCTORS` and `NULL_TYPE_MEMBERS`: nullability markers (`Foo?`, `?Foo`, `Foo!`), null union members and maybe-wrappers (`Optional[Foo]`, `Nullable<T>`) are stripped and Python forward references unquoted, while a union of several real types stays untyped and containers (`list[Foo]`, `Option<T>`) stay containers; defaults are stripped and type-first parameters skip annotations, attributes and modifiers. C# and Kotlin locals are looked up in the calling function before the file-wide map. Dart/C#/Scala typed closure parameters and typed patterns are recorded by span in `FileFacts::scoped_types` (`cgg-lang/src/closure_types.rs`) and win over every other binding; `type_hints::settle_closure_args` (run before propagation) types an untyped closure passed positionally to a bare call from the callee's declared callback type (`CALLBACK_TYPE_CONSTRUCTORS`), only when every same-named definition agrees, the type is concrete (not a type parameter or one of `TOP_TYPES`) and the arity matches
2. `intra_file::link_file` — scope/containment within a single file, with owner-qualified disambiguation of same-name candidates (`names::owner_from_qn`, handling `Self`/qualifier owners). A C++ scope-qualified call (`ns::f()`) binds only to a definition declared in that scope — exact path suffix, unnamed namespaces transparent, or the constructor `ns::K::K` — never to a member of a class inside it
3. `cross_file::resolve` — import chains, `#include` transitive closure (depth 8), pub-use chains (a relative `use child::x` / `pub use child::x` in a module declaring `mod child` resolves to the child module: `rust_child_module_path`), and an `(language, owner type, method)` index for typed-receiver method calls (replaces the old O(callables) suffix-scan). A `typed_receiver` site enters the typed owner lookup whatever its case and never reaches by-name fan-out. Step 4e binds Solidity calls through `using L for T` / `using L for *` (`using-for` import records). A Solidity bare call inside a contract or library binds to that scope's own functions first; library functions carry the `library-member` attribute and are never reached by bare name from outside the library. C-family names reached through the include closure bind only to definitions in the caller's file or its closure files (`c_include_copies`); same-file preprocessor alternatives bind once, and a closure holding only a prototype reaches the single body elsewhere. The Java, Scala and C++ plugins set `RefRecord::arity`, and overload sets in `OVERLOAD_ARITY_LANGS` keep only overloads whose parameter list accepts that count (`type_hints::accepts_arity`; defaults, varargs and packs handled; never empties a set); a cross-file lookup that lands on one overload expands to that file's whole overload set before narrowing. An overload set — one file, one qualified name, an overloading language (`one_method`) — is one callee and is never refused by the fan-out cap except at `--fanout-cap 0`; it binds every surviving overload at medium when more than one survives. Sets spanning owners, files or names, and clause-language arity sets (Erlang/Elixir/Haskell), stay capped. JVM typed receivers narrow their owners by the qualifier written (`Response.Builder`) and prefer the type nested in the caller's scope (`narrow_owner_paths`). A Rust receiver typed with a crate path binds only in that crate (`narrow_rust_crate_head`). Rust `arg_type` and `local_types` may carry the `cgg_core::ARG_VAR_PREFIX` marker for a variable argument or an `if let`/`match` binding, settled by `type_hints` (`settle_unwrapped_bindings` and the variable-argument pass) from the parameter, visible `let` or `Option`/`Result` the value came from. `cgg_core::stdlib::RUST_UNTYPED_ADAPTER_METHODS` (`ok`, `err`, `as_deref`) are never fanned out by name on an untyped receiver (step 5 only; they are kept out of `stdlib/rust.txt` so a workspace macro of that name still binds). Clojure `alias/f` resolves through the `require` alias to its exact name; OCaml `open A.B` narrows to the module's file (`module_files`). In Python a receiver type the calling file itself declares binds only within that file's class and its bases. Scala joins the external-head refusal (imports, `cgg_core::stdlib::SCALA_IMPLICIT_PACKAGES`, skipped when a local, member `val` from `class_fields`, package segment or class of that name exists) and the "member of the calling class beats an import" rule. Step 1c's scoped candidates (`ScopedSimple`) are evaluated per queried name from a per-language index, never built as a whole per-file map (that was O(files²) and timed out a 9k-file Go repo). Steps are tried in order and the last two are pure additions, so they can only resolve what nothing earlier did: **step 6** maps a bare `Widget(3)` onto `Widget.__init__` (`constructor_names`), **step 7** maps `agent("x")` onto `type(agent).__call__` (`call_operator_names`) using the file's `local_types`. A missed owner-method lookup retries through the declared base chain (`resolve_via_bases`, depth 8, visited-guarded), which is what makes an *inherited* method resolve. Java/C# bare calls first try the calling class by its qualified owner path, then its declared bases (`member_of_calling_class`); copies of one class narrow to the caller's (longest shared directory prefix, a tie refused: `nearest_class_copies`), and a member that a class derived from the calling class overrides binds with those overrides (`overrides_below`, never a sibling), refused as `fanout-cap-exceeded` over `--fanout-cap`. `super.m()` resolves through that base chain first, and is refused as `super-base-out-of-graph` in languages that record every class (Python, Kotlin, Java, C#) when the chain leaves the tree (C# `base.M()` reads as `super`; a Java anonymous class body's `super` is its own base; Java/C# class records are the authoritative bases, and an inherited member reached through same-named class copies narrows to the copy nearest the caller); a bare call to a member the caller's class declares or inherits beats an import of the same name (Java/Kotlin/C#). Kotlin extensions bind when declared in the file or imported (step 0b); duck fan-out drops out-of-scope extensions and top-level non-extension functions after the cap check. Duck-typed fan-out is bounded by `--fanout-cap` (default 5) and narrowed twice before the cap applies: `Protocol`/`ABC`/`@abstractmethod` declarations and the bodiless TS/Swift/Scala/Dart interface/abstract declarations (tagged `abstract`; intra-file also prefers a concrete candidate over them) are dropped when a concrete candidate survives, and candidates whose `signature_hint` cannot accept the call's keyword arguments are dropped when any candidate can. Both narrowings are one-sided — if they would empty the set, the original set stands. Exceeding the cap emits `UnresolvedReason::FanoutCapExceeded` with the count; typed-owner lookups landing on several template specializations (`Name<args>` owners, not Rust `<T as Trait>` wrappers) bind to the one the receiver writes exactly, and otherwise are refused the same way when more specializations than the cap are involved (`narrow_specializations`); a Rust typed lookup over several impls of one generic trait for one type binds to the impl whose argument type a literal states (`RefRecord::arg_type`), and otherwise, over the cap, to the trait's single in-graph declaration or is refused (`narrow_trait_overloads`); a drop is never silent — except that an over-cap Rust set made entirely of one workspace trait's impls (plain variable/field receiver, exactly one in-graph declaration) binds to that declaration instead (`homogeneous_trait_declaration`). A Rust path receiver (`RefRecord::path_receiver`, `serde_json::from_str`) is never duck-typed: step 4c refuses an external head even with no `::` in the hint, and a head is in-workspace when it is a crate root, an import, a workspace type, or a child module of the caller's module (`RustPathVocab`). A bare type name the file `use`s from outside the workspace is not a workspace type: only workspace trait impls for it match, `Wrapper<Arg>` still reaches `Arg`, and anything else is `external-module`. Every other language with an import table gets the same refusal before step 1 (`heads.rs`: `WorkspaceVocab` / `FileHeads`, Python/JS/TS/Go/Java/Kotlin/C#/C++): a call whose receiver head or bare name is bound to an import of an *external* module, package, namespace or class is recorded as `external-module` in the per-file external bucket instead of being name-matched. Workspace membership comes only from what the tree declares (module paths, `FileFacts::packages`, C++ scopes/aliases/include closure), and C# bare type names reach only namespaces visible from the file. The C++ plugin sets `path_receiver` on `ns::f()` too. A call written *through* an import of a workspace module (JS/TS namespace and `require` imports, Python modules and packages, Go packages, Erlang `m:f`) is looked up only in the file that import names, following re-exports, tsconfig `paths` and `package.json`/`deno.json` `exports` (`modules.rs`: `ModuleIndex`, `FileModules`); when the module's file cannot be determined or declares nothing the call names, the site is refused as `not-in-imported-module`, never bound by name elsewhere. Locals and parameters shadow import names, a file's own declarations and file-local aliases win before any cross-file choice, and copies of one module narrow to the caller's own. `--guess-unique-names` (opt-in) binds an otherwise-unresolved Rust site to a globally unique name at `low`, tagged `cross-file:name-unique`. Plugins record parameters and locals with their scope span (`FileFacts::locals`, `cgg-lang/src/locals.rs`, from grammar node kinds; skipped in minified files, any line over 2,000 bytes): a bare call through one is never another file's function (`shadowed-by-local`), intra-file drops same-named definitions outside the binding's function, and a receiver headed by one is not an import. A declaration is in scope after its initializer (Rust `if let`/`while let`/`for` bindings start after their value; macro invocations are never shadowed), and a local that is its own `require`, a same-named member alias (`var pick = H.pick`), or a name the file imports keeps the import reading. Lexical narrowing of nested definitions applies to JS/TS (when locals were read), Lua, OCaml, F#, Scala, Kotlin, Swift, Rust, Dart, Haskell and Julia, with positional shadowing in OCaml, F# and Lua. `cgg-resolve/src/libscope.rs` holds library scopes: Dart parts, imports, `export` chains, `show`/`hide` and `package:` to the pubspec root; Zig `@import` paths, member re-exports and `build.zig` `addModule` modules; PHP namespace and `use` FQNs, `parent::`, and copies narrowed by the `require`/`include` chain or refused. Scala bare calls go through `bare_call_reaches` like Java/C#, plus imported objects and their bases; an inherited member beats an import, and owner-less defs are reachable only as package-object members. Elixir bare calls reach the caller's module and its `import`/`use` modules. Elixir qualified calls (`Mod.f()`, `&Mod.f/1`, `__MODULE__.f()`, `:m.f()`) carry `path_receiver` and resolve through `LibIndex::resolve_elixir` — the file's `alias` records (normalized: plain, `as:`, `A.{B, C}`), then a module nested in the caller's, then the full name, arity-filtered — never by bare name; a module outside the tree (or an Erlang atom with no `-module`) is `external-module`. Zig: a file-level `const f = mod.path.f;` alias is followed through the file's aliases to its `@import`; found in that module's file it binds at `high`, and an aliased package no Zig file stem or directory in the tree names is `external-module`. Zig receivers typed as containers bind their member through `FileFacts::type_spans`, following `@This()`, container-level aliases and type-function results, and are otherwise refused. Perl `$self->m` (`RefRecord::invocant`) walks the package and its `@ISA`/`parent`/`extends` chain. Scala bare calls go through `LibIndex::resolve_scala_bare`, with bases resolved via the declaring file's imports and package. A PHP conditional definition (`DefRecord::conditional`) yields to the unconditional one. Kotlin scope-function lambdas use `RefRecord::implicit_receiver`, settled by `type_hints::settle_implicit_receivers` after propagation. An OCaml non-`rec` binding excludes itself (`RefRecord::not_def`). Scala and Ruby record paren-less calls (`RefRecord::paren_less`); `value_refs::prune_paren_less` keeps one only when some parameterless `def` of that name exists, and such a site binds only through a typed receiver or scope, is never fanned out by name, and leaves no audit record when it binds nothing. Step 4d binds a C# call on a typed receiver to a static extension method whose first `this` parameter is that type, a base of it, or a type parameter. Ruby joins `member_of_calling_class`; Dart and Ruby bare calls reach only the caller's class ancestry (Ruby: plus included modules) or top-level functions, and Dart intra-file prefers the enclosing class's member, as C++ does. Lua `require` resolves to a file through `modules.rs` (external when no file matches), table members are `InherentMethod`, and a Lua bare call never reaches a table member. PHP `$this->`, `self::` and `static::` resolve through the class chain and `use`d traits in `libscope.rs` (traits count as bases); through a base outside the tree they are external.
4. `ffi::link_ffi` — PyO3 / wasm-bindgen / napi / JNI / P/Invoke / `extern "C"` cross-language edges; an asm name binds as written, and only when no C function carries it loses one leading `_`; the C/C++ → asm pass skips value and string references, so a function handed to a registration call is not a call edge
5. `descriptor::link_descriptors` — `$ref`/shape edges for the Smithy/Protobuf/GraphQL/OpenAPI/AsyncAPI interface languages; the Protobuf→host-language link reaches methods (rpcs) only, never a `message`
6. `frameworks::detect` — entry-point matching (routes, jobs, handlers) → `<framework-entry>` nodes. On by default; `--no-entry-nodes` suppresses it. Built-in rules can also root members through two rule tables in `cgg-core/src/frameworks/rules.rs`: `member_sources_for` (an attribute's string/`nameof`/`typeof` argument names the member to root, e.g. test data sources) and `signature_methods_for` (exact name plus parameter list, e.g. serialization hooks); rules with `node: false` mark roots without minting a node, and neither table ever creates an edge; the web-framework response names the dead-code pass reads (`RESPONSE_TRAITS`, `SELF_CONVERTING_WRAPPERS`) live there too
7. `dispatch::fanout` (opt-in, `--dynamic-dispatch`) — interface/trait declaration → implementation edges (`Via::Dynamic`), driven by `CallableNode::trait_impl_target`; `dispatch::inheritance_fanout` links a base/interface member to its same-named overrides for Python, Java, C#, Kotlin, Scala, Groovy, TS, JS, Swift, Dart, PHP and Ruby (`INHERITANCE_LANGS`), and also runs under `--dead-code`. In the dead-code report an override of a contract with no in-tree declaration (`@Override`/`override`, C# explicit `void I.M()`, C# `partial` method parts, Rust impls of outside traits, Python methods of a class with an outside base) caps at medium (`implements-external-contract`), and an unresolved same-name site with an untyped, generic or over-cap receiver counts as doubt only where it could legally reach the member (never a Java/C# `private` from outside its class or package-private from another package). `dynuse` covers Python, JS, Java, C#, Ruby and PHP: a reflective lookup by literal name (screen `reflection`) caps every same-named finding at medium and never creates an edge, while module loads (screen `module-load`, `cgg_core::DYN_MODULE_LOADERS`) do not cap
`cgg_resolve::value_refs::prune` drops value-ref records no callable of the language names, or a local shadows, before Phase 3, and `tag_reference_edges` tags edges from those sites `Via::Reference` before the drop. Between 5 and 6 the driver drops `Via::Reference` edges unless `--reference-edges`, reconciles the audit buckets against everything later resolvers bound, and then mints exit nodes (`synthesize_exit_nodes`). `names.rs` holds the shared `owner_from_qn` used by intra_file, cross_file and descriptor. Every edge carries a confidence level and resolver provenance, so downstream consumers can filter by quality. `high` means structurally certain (`CertainCtx` in `cross_file.rs`): exactly one candidate, reached through an exact or import-anchored path (one callable carries the name: no vendored copies, `#[cfg]` twins or stubs), a typed receiver whose type comes from a declaration, cast or construction and names exactly one class (`OwnerFacts`; an import of the type must point at it; Python and JS/TS need the type declared in the calling file, Python also imported from the candidate's own package), the caller's own class with no override below it, an inherited member or `super` only when the file's imports confirm the bases and exactly one ancestor declares it (`one_declaring_ancestor`; never in Scala/Kotlin/Groovy `super`), or an overload set narrowed to one by arity. A receiver type propagation guessed (`RefRecord::weak_receiver_type`, Python `x = mod.Cls()` via `FileFacts::module_typed_locals`) is never `high`; by-name matches, fan-out, anything near the cap and minified files stay `medium`. Mermaid marks an arrow with no `high` site with a leading `?` in its label.
`stack_graphs_resolver.rs` still exists but is a **no-op stub** (46 lines) — the integration was removed in the tree-sitter 0.26 upgrade (upstream pins tree-sitter 0.24 / ABI 14). `--stack-graphs` is parsed and discarded (`options.rs:206`, destructured as `stack_graphs: _` so it never reaches `RunOptions`). Do not add code paths that assume it resolves anything.
Steps 1, 2, 3 and 6 run under rayon — `par_iter_mut` for type propagation and `par_iter` for the intra-file link in `lib.rs`, plus `par_iter` inside `cross_file.rs` and `frameworks/mod.rs` — as do the per-file parse loop (`lib.rs`) and audit serialisation (`cgg-core/src/audit.rs`). Every one of them must produce output in input order — `par_iter().collect()` preserves it — because the graph, and therefore the whole output, is required to be identical at any `--jobs` value (`crates/cgg/tests/determinism.rs`, `crates/cgg/tests/lib_api.rs::output_does_not_depend_on_worker_count`).
- **cgg** (library + binary) — `lib.rs` (~2120 lines) is the pipeline: `pub fn analyze` builds a **per-call** rayon pool (`ThreadPoolBuilder … pool.install(…)`; nothing sets a global pool, because a global can only be set once and a second call would silently reuse the first's width); every analysis thread — the pool workers and the `cgg-analyze` host thread `analyze` spawns for the run — gets a 128 MiB stack (`ANALYZE_STACK_SIZE`), and extraction is gated on `cgg_lang::exceeds_depth` against `MAX_TREE_DEPTH` (4,000 tree levels), because the plugin walkers recurse and a stack overflow is an uncatchable abort, so a deeper file is skipped as `SkipReason::TooDeep` rather than walked. The private `analyze_in_pool` orchestrates everything, returning a `RunOutcome` and performing **no I/O beyond reading source** — no writes, no stdout/stderr, no `process::exit`, enforced by `crates/cgg/tests/lib_api.rs::analyze_writes_nothing`. `main.rs` (104 lines) is a thin shim that owns only what belongs to an application: the global allocator, the tracing subscriber, the startup log line, and the exit code (3 for `--fail-on-dead` findings, 1 for errors). `options.rs` holds `RunOptions` (only what changes the graph; `From<&Cli>` destructures with no `..` rest, so a new flag fails to compile until it is routed). `outcome.rs` holds `RunOutcome` and `Emission` — the ordered transcript of everything a run writes, diagnostics *and* artifacts in one list because their relative order is observable when the graph goes to stdout and diagnostics to stderr. `emit.rs` is the only place any file descriptor is touched, which is why `cgg-py`, `cgg-ffi` and `cgg-node` need none of it. `cli.rs` parses flags; `query.rs` applies `--filter` + `-n`/`--hops` (BFS neighborhood / path extraction) and `--exclude-*`. `since.rs` resolves `--since <revspec>` by shelling out to `git diff` — with `-c core.fsmonitor=false`, `--no-ext-diff --no-textconv` and `--end-of-options` before the revspec, so the analyzed repo's own config cannot execute commands and the revspec cannot inject git options — and intersecting changed line ranges with callable spans; the resulting qualified names are appended to `--filter` as `^name$` regexes before `apply_query` runs, anchored on the tree being analyzed rather than the process cwd. `synthesize_exit_nodes` mints the deduplicated external/stdlib leaf nodes for `--include-external`/`--include-stdlib` (after the audit-reconciliation prune, before `dedup_edges`). All five of `--include-external`/`--include-stdlib`/`--dynamic-dispatch`/`--reference-edges`/`--guess-unique-names` are opt-in and never change the default graph. `--no-graph` and `--report-unreferenced` change what is *emitted*, not the graph, so the first lives on `Cli` only while the second is a `RunOptions` field (it replaces the artifact, like `--why-live`). `--node-ids` and `--locations` are presentation flags that nonetheless live on `RunOptions`, for the same reason `rollup_format` does: the `--rollup` budget is measured against the *rendered* document, and the hashed rendering is about a third larger than the numbered one, so a budget measured under the wrong scheme silently over- or under-folds. `node_ids` stores the *choice* (`Option<NodeIds>`), not a resolved scheme — one analysis can be rendered in several formats, and `NodeIds::resolve` is the single place that turns choice + format into an answer.
- **cgg-format** — terminal emitters: `mermaid.rs` (default), `json.rs`, `dot.rs`, `graphml.rs`. `Via` kinds tag the mermaid label slot — `dyn`/`ref`/`ext`/`std`/`desc`/`entry` — with per-kind edge styling in dot (`via_dot` in `dot.rs`). `node_ids.rs` owns how a node is *named*: mermaid numbers its nodes (`N0`, `N1`, …) because the id repeats on every edge and agents pay for it in context tokens, while every other format keeps the base36 content hash; `-t json` cannot be renumbered at all (`node_ids_are_identity`), because its ids are what `--from-graph` reads back. The qualified name is deliberately never the id — names are not unique (17.7% of corpus callables share `(language, file, owner, qn)`), so naming would merge distinct nodes, and it renders larger besides.
- **cgg-ffi** — the C ABI (`libcgg.so` / `libcgg.a`, header hand-written at `crates/cgg-ffi/include/cgg.h`). **Seven** exported functions (`cgg_version`, `cgg_analyze`, `cgg_graph_render`, `cgg_graph_meta`, `cgg_graph_callable_count`, `cgg_graph_free`, `cgg_string_free`); a translation layer over `cgg::analyze` with **no analysis logic**. Options cross as a JSON document and results as strings, deliberately: that is what lets one shared library serve every language with an FFI without gaining an entry point per flag, so **the ABI does not change when cgg gains a feature** — do not add a function for a new option, add a `RunOptions` field and it is reachable for free. `cgg_analyze` returns an opaque handle rather than a rendered string so a caller pays for one analysis, not one per format. Every entry point wraps its body in `catch_unwind` because a Rust panic crossing into C is UB, and every pointer argument is null-checked. `publish = false`.
- **cgg-py** — the PyO3 extension module (`import cgg`). A translation layer over `cgg::analyze` with **no analysis logic**: if you find yourself adding graph logic here, it belongs in `cgg`. `publish = false`; the artifact is a wheel built by `scripts/build-python.sh`, not a crate. `crate-type = ["cdylib"]`, `test = false`. The workspace pins pyo3 with `abi3-py39` + `extension-module`, so pyo3 links no libpython and needs no interpreter at build time — `cargo build --workspace` and `cargo test --workspace` work with no Python installed, and that is worth keeping true. `crates/cgg-py/tests/test_analyze.py` asserts **structural** parity with the binary — identical callable and edge sets including provenance and confidence, identical file-path→blake3 map — under the default run and under an option matrix, with a non-vacuity assertion that at least one option changed the graph. It is a pytest suite; `cargo test` does not run it.
- **cgg-node** — the N-API module (`require("cgg-callgraphgenerator")`), built with `@napi-rs/cli`. A native module rather than a wrapper over the C ABI: npm needs a per-platform artifact either way, so the C ABI would buy nothing while costing an FFI dependency and a slower boundary. Same no-analysis-logic contract as cgg-py and cgg-ffi. `analyze()` is async — the pipeline runs on `spawn_blocking` so a server's event loop does not stall — with `analyzeSync()` for scripts, plus `version()` and `languages()`. Keywords are camelCase and, like Python, un-negate the CLI's double negative: `entryNodes: false` rather than `--no-entry-nodes`. `publish = false` for cargo; the artifact is an npm package. Tests: `crates/cgg-node/tests/analyze.test.js` (`node --test`), which also checks parity against `target/release/cgg`.
### Adding a new language
1. Add the `tree-sitter-<lang>` crate to `[workspace.dependencies]` in `Cargo.toml`. If no crate is compatible with the workspace `tree-sitter` version (e.g. it pins an ancient `tree-sitter` and the deprecated `language()` API, as `tree-sitter-smithy` does), or none exists (Kivy KV), vendor the generated `parser.c` under `crates/cgg-lang/vendor/<lang>/`, compile it in `crates/cgg-lang/build.rs` (include `scanner.c` when the grammar has externals), and bind the raw `tree_sitter_<lang>()` C symbol via `tree_sitter_language::LanguageFn` (see `plugins/smithy.rs` and `plugins/kivy.rs`).
2. Add a plugin module under `crates/cgg-lang/src/plugins/` implementing `LanguagePlugin` (id, extensions, shebangs, resolver_kind, ts_language, extract). After `&self`, `extract` takes `&ExtractCtx<'_>` — the per-run extraction switches (dead-code signals, user-supplied registrar verbs). These were process-globals through 0.5.0; they are threaded now so two analyses in one process cannot write each other's state. Do not reintroduce a global for them.
3. Register it with a `reg.register(Box::new(…))` line in `plugins::register_all` (`crates/cgg-lang/src/plugins.rs`). That function is what `PluginRegistry::with_v1_plugins` calls, and counting `register(` in that file is how `scripts/docs-check.py` derives the language count that README, the skills and `scripts/benchmark.sh` are checked against.
4. If the language has cross-file semantics, extend `cgg-resolve::cross_file` to handle its import form.
5. Add a benchmark entry in `scripts/benchmark.sh` and a matching row in `scripts/update-readme-stats.sh` `ENTRIES` — docs-check fails if the two disagree — then rerun to update README stats.
6. Bump the `assert_eq!(reg.all().len(), 46)` in `crates/cgg-lang/src/lib.rs::v1_registry_has_all_languages`.
### Audit / metrics
A run writes a sidecar audit document **only when it has an output file**: `--metrics FILE` wins, otherwise `-o P` produces `P.audit.json`, and a run whose graph goes to stdout writes no audit at all (`emit.rs::sidecar`). Shape is chosen with `--audit-format json|jsonl` (`json` = one pretty document, `jsonl` = one event per line). It records files discovered/analyzed/skipped with reasons, every extracted callable, every unresolved call site with failure reason, and per-phase timing. This is the primary debugging surface — when a call edge is missing, run with `-o` and the audit log is where you find out why.
### Corpus runs are time-bounded
Every script that iterates the benchmark corpus caps itself, and none of
them may hang:
| Variable | Default | Meaning |
| --- | --- | --- |
| `CGG_REPO_TIMEOUT` | `60` | wall seconds per repo |
| `CGG_TOTAL_BUDGET` | `1800` | wall seconds for the whole run (30 min) |
Applies to `scripts/benchmark.sh`, `scripts/perf-compare.sh` and
`scripts/framework-coverage.py` (whose per-repo cap was 900s, which over
a hundred repos is not a timeout).
A repo that trips the cap is **named in the output and excluded**, never
silently averaged in or waited on. The corpus is ~113 repos and almost
all finish in seconds, so a timeout means a cgg bug — chase it, do not
raise the cap.
The rule exists because it was learned expensively: `erlang-otp`
(177 MB, 3,851 `.erl` files) ran for **3h40m** inside an unguarded sweep
before anyone noticed, and the *released* binary hangs on it too.
### Threads and performance
`--jobs 0` (the default) means **half the physical cores**, clamped (`cgg_core::cpu::default_jobs`) — not one thread per logical CPU. Extraction is allocation-heavy, so SMT siblings contend rather than add; the binary uses mimalloc for the same reason.
Measure, never estimate. `scripts/perf-compare.sh` does paired A/B against a baseline binary and `scripts/release.sh` runs it; published numbers are taken at `--jobs 1`. A cautionary example is in the tree: 0.6.x was reported as a "+4–6.8% corpus-wide regression" against 0.5.0 on the strength of a 9-repo sample. Re-measured across 29 repositories it is **not** corpus-wide — median per-repo delta +0.0%, total +2.7%, 10 of 29 faster — and the effect traces to one repository (`c-jq`) whose file-size skew defeats the work split. See the `### Performance` blocks in CHANGELOG.md for the full numbers.
## Pre-commit hook behavior
`.githooks/pre-commit` (installed via `scripts/install-hooks.sh`, which sets `core.hooksPath`) runs, in order:
1. `cargo test --workspace --quiet`
2. `cargo build --release -p cgg --quiet`
3. Installs the freshly-built binary to `$CGG_INSTALL_DIR` (default `~/.local/bin/cgg`) so other tools on the system — agents, sibling repos — pick up the same code about to be committed. Set `CGG_INSTALL_DIR=""` to opt out.
4. Runs the freshly built `cgg` three times into `target/cgg-readme-graphs/`: over `crates/cgg-walk`, over three files of `crates/cgg-lang`, and over `crates/cgg` with the showcase filter. Then `scripts/update-readme-graphs.py --self-test`, then the same script patching **three** README blocks — `walk`, `lang`, and `raw:self` (verbatim, because the README presents it as the literal output of the command printed above it).
5. Re-runs `cgg ./crates --filter 'cgg::analyze_in_pool$' -n 1` and patches the self-analysis stat line (between `<!-- cgg:begin:self-stats -->` markers) via `scripts/update-readme-stats.py`, fed that run's stderr. Sub-millisecond timing variation is rounded to keep commits stable.
6. Runs `scripts/docs-check.py`.
7. Stages `README.md` if any of the patches changed it.
`CGG_SKIP_PRECOMMIT=1` skips the whole hook, as does `git commit --no-verify`.
### What docs-check.py actually checks
Sixteen check functions, called from `main()` in `scripts/docs-check.py`. The numbering in the body runs 0–14; `check_framework_apps` is unnumbered. Any of them fails the commit:
- **Language counts** — `register(` calls in `plugins.rs` must equal README's `## Supported languages (N)` heading, the language-table row count, and `REPOS=( … )` in `scripts/benchmark.sh` (which may carry one extra row for the combined `xv6 (c+asm)` entry).
- **Benchmark-table coverage** — `REPOS` in `benchmark.sh` and `ENTRIES` in `update-readme-stats.sh` must name the same set of languages.
- **Framework apps** — every enumerating `RuleSpec` id in `cgg-core/src/frameworks/rules.rs` must be named by some `APPS` entry in `benchmark.sh` or listed in `APPS_UNVERIFIED`, and `APPS` must not name a rule that does not exist.
- **CLI flag freshness** — every flag in README's `## CLI` flag table must exist in `cgg --help`. The reverse is deliberately not checked; the table is curated.
- **CLI synopsis coverage** — the ```text usage block under `## CLI` must name every live flag and no dead one. Deprecated no-ops (`--stack-graphs`, `--no-update-check`) are exempt because their help text says "No effect".
- **Skill language count** — a skill saying "Supports N languages" must match the plugin count.
- **Skill inventory** — every `skills/*/SKILL.md` must be linked from README, and README's "N bundled skills" must match how many exist.
- **Attribute-capture count** — prose claiming "N plugins listed in Step 2" must match the plugins declaring `attributes: true`.
- **Self-analysis showcase (check 7)** — `.githooks/pre-commit`, `scripts/update-readme-stats.sh`, `README.md` and **this file** must all name the same `--filter`; `scripts/patch-readme-stats.py` must contain the bare callable name; and the committed `<!-- cgg:begin:self -->` block must span at least three crates.
- **Python keyword parity (check 8)** — every `RunOptions` field must be reachable as a `cgg-py` keyword argument or be listed in `PY_DEFERRED_OPTIONS` with a reason. Note this covers Python only: nothing yet checks `cgg-node` or `cgg-ffi` for the same drift.
- **Skill publish claims (check 11)** — no `SKILL.md` under `skills/`, `.claude/skills/` or `.kiro/skills/` may call a channel unpublished while `.github/workflows/release.yml` publishes it. This is the only check that reads skill *prose*; checks 3 and 4 only count skills and read their language numbers, which is why three skill files kept telling readers npm was unpublished and that the GitHub releases carried no binaries for the whole of 0.7.0.
- **Skill check-count claims (check 12)** — a skill stating how many checks this file has must match `def check_`. Adding a check means updating the skills that name the number.
- **npm binding-version parity (check 13)** — `crates/cgg-node/index.js` is generated by `napi build` and hardcodes the platform-package version it accepts; it must equal `package.json`'s, or `require()` throws on every platform. 0.8.4 shipped with it stale at 0.8.3.
- **Publishable manifests (check 14)** — no Cargo manifest may carry a `[patch]` or `[replace]` section or a git dependency. Both are dropped or rejected when a crate is published, so the workspace would build while `cargo install cgg` failed for every user. Vendor the dependency instead (`crates/cgg-lang/vendor/`, as Smithy and Kivy do).
- **Deliberate leaks (check 9)** — `Box::leak`, `.leak()` and `mem::forget` in the pipeline crates must be listed in `ALLOWED_LEAKS` with a reason. `analyze` is called in a loop by four front ends now, so "the process is about to exit" is no longer a justification. This check exists because `type_hints.rs` leaked ~161 bytes per call until 0.6.2.
If you intentionally edit the mermaid blocks or the self-stats line by hand, the hook will overwrite them — edit the generators (`scripts/update-readme-graphs.py`, `scripts/update-readme-stats.py`) or the underlying code instead.