CLAUDE.md · git:20261023.7b88b25 · 2026-10-23 · sha256 0384ef6449c8447c
CLAUDE.md git:20261023.7b88b25A
Immutable. This exact content never changes and is served at /api/v1/blob/0384ef6449c8447c.
<!-- 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
# Full-corpus graph + dead-code A/B between two binaries (diffs edges,
# confidence and dead findings). Results are cached in target/ab-cache by
# binary sha256 + repo git state, so a binary is never rerun on an
# unchanged repo; GRAPH_AB_NO_CACHE=1 bypasses it. Timing is never cached:
# compare-release.py always runs both binaries, paired.
python3 scripts/graph-ab.py OLD_BIN NEW_BIN OUTDIR [--repos a,b]
# 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
```
The only lint that runs on commit is markdownlint (pre-commit step 7), 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`. It also builds and tests the Python and Node modules (`scripts/build-python.sh`; `napi build` + `node --test`) and runs `scripts/smoke/python_smoke.py` / `scripts/smoke/node_smoke.js`, the same smoke tests the release workflow runs on every wheel and `.node` — edit those files, never an inline copy in `release.yml`. It clears `target/package` before packaging, because a re-run at the same version otherwise fails on the stale tmp-registry ("no hash listed for …"). 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 (including `STDLIB_MODULES`, exact stdlib module names read only by the external classifier, which splits a call's own qualifier — never an import path — on `::`, `/` and `\`; CMake has a built-in command manifest), `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` (shebang interpreter as a whole word, exact file name via `LanguagePlugin::filenames()` — `CMakeLists.txt`, `BUILD`, `Gemfile` … — then extension, then 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. `LanguagePlugin::parse_source` lets a plugin have the grammar parse a same-length rewrite while extraction reads the original bytes; the Groovy plugin uses it to make string method names (`def "…"(`) identifiers, naming the method by the string and marking it `string-named`; the same copy carries `plugins/groovy_rewrite.rs`'s same-length rewrites of shapes the grammar rejects: `''` → `""`, `[:]` → `[ ]`, a closure opening with a declaration (head blanked), `a?.b`/`a*.b`, `as T` inside expressions, Spock `_ *`, spread arguments, one-line slashy strings, `for (def x in`, keyword-like map keys, `assert a.b`, `x.@a`, one-line method bodies (`;` before `}`), and command expressions a method body (literal or named first argument) or any context (dotted or call first argument) rejects, read as `+`/`==` binary expressions; a scanner keeps every rewrite out of strings' content, GString interpolations and comments, and never touches a newline; a Groovy call on a continuation line (`x\n .m()`) is a `dot_call`. Clojure `#_` discarded forms are skipped and `#(…)` bodies are read as calls; method lists in `deftype`/`defrecord`/`reify`/`proxy`/`extend-type`/`extend-protocol` are methods (`ns/Type.m`, simple name `Type.m`, `protocol:P` attribute), a `defmethod` is `ns/m[dispatch]` carrying `multimethod:<defmulti qn>` (`cgg_core::CLOJURE_MULTIMETHOD_ATTR`), `import-vars` re-exports are `reexport` import records (`cgg_core::CLOJURE_REEXPORT_KIND`), and `defprotocol`/`definterface` signatures are abstract declarations (`ns/m`), linked by `dispatch::fanout`; a `letfn` spec, multi-arity included, is a callable nested in the definition holding the form (`ns/outer.k`, simple name `k`, `cgg_core::CLOJURE_LETFN_ATTR`), and the plugin builds `binding_spans` once and records it as the file's locals (lib.rs skips its own collection for Clojure); a qualified `x/defn`/`x/defn-`/`x/defmethod` head is a definition when `x` resolves to a namespace in `frameworks::rules::CLOJURE_DEFN_MACRO_NAMESPACES` or one that defines or `import-vars`-re-exports that macro (`cgg_lang::plugins::clojure::settle_defn_macros`, run in `analyze_in_pool` after extraction); `ns` requires inside reader conditionals are read. Clojure definition and operator names are read without metadata (`^:private`, `^{…}`, `#^T`, form-level metadata). `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; a Go `x := f(…)` of the calling package is recorded as the local type `f()` (not builtins (`cgg_core::stdlib::GO_BUILTIN_FUNCS`), parameters, or a single-name `NewFoo()`), and `f(…).M()`/`x.M()` resolve through `f`'s declared result (`modules.rs::go_call_result`), used only when some Go function `f` declares one (`ReturnTypeIndex::with_go_results`) and `f` is not a local there, and Swift's `Wrap<Int>.s()` comparison misparse reads as `Wrap.s()`. Julia short-form methods under `where`/`::T` are definitions. A Haskell definition's name is read from the node's `name` field only: `f x = …`, `x = …` and `let`/`where` bindings define, a pattern bind (a `do` statement's `x <- e` or `(a, b) <- e`, `(a, b) = …`) and a type-level arrow never do. 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; the C# capture reads `member_access_expression`. Registrar verbs of Test-kind rules gate only files `classify_test_file` marks as test code (`ExtractCtx::for_test_file`, set per file in `analyze_in_pool`), and only called bare or chained off another block verb (`registrar::admits`), so a JS/TS `it(…)`/`test(…)` callback becomes a `handler_at_<line>` closure callable with the description as its route; `ExtractCtx::is_registrar_context` admits a receiver-qualified registrar (Swift `Lambda.run`) only through that receiver. JS/TS class fields holding a function are methods; ObjC reads plain C calls and function names through `plugins/c.rs` (`call_ref`, `fn_name_from_declarator`); Groovy, Scala (primary, `def this`) and F# (primary, `new()`) extract constructors, reached by constructions, `this(…)`/`super(…)`, Scala `extends Base(x)` and F# `inherit Base(x)`. Each binding of an F# `let` (each `and` of `let rec … and …`) is its own callable (`locals::fsharp_let_bindings`, shared with the F# locals), and a value pattern that is a lowercase name applied to arguments (`let f (x: T) : R = …`) is a function; an F# application whose head the grammar folded into an operator expression (`xs |> f a` read as `(xs |> f) a`) calls the operator's right operand, unless its argument is a block (`while … do`). A module-level F# function is named by its module path (the file's `namespace`/`module A.B.Mod`, then each nested `module Inner =`; recorded in `FileFacts::packages`), and a call the grammar splits as `A.B` dot `f` is the dotted call `A.B.f`. The Kotlin/Swift/Scala/TS/C++ plugins record an `override` attribute (`attrs::with_override_marker`; C++ copies it from the in-class declaration to an out-of-class body) and Dart `@override`; the dead-code pass reads PHP `#[\Override]` through `override_marker_text`; these keyword markers and `abstract` do not count as attribute capture (`caps.rs`), and an `abstract` declaration whose same-file enum-constant or anonymous-class bodies are called carries `implemented-trait-surface` in the dead-code report, and a leading `::` in a base type is stripped. Rust items under `#[cfg(test)]` (outer, `all(test,…)`, file-level `#![cfg(test)]`, nested modules) get `TestRole::Support`. The Rust plugin records attributes on `impl` blocks (`FileFacts::attributed_containers`); the C# plugin reads a code-behind's sibling `.xaml`/`.axaml`, `.razor` or same-name Godot `.tscn` (`plugins/markup.rs`, `FileFacts::markup_bindings`), read only by framework detection; PHP stacked attribute groups are recorded one per group. 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; JS/TS arguments, array elements, object values and shorthand, assignment/declarator/field values, `export default`, `return`, arrow bodies and JSX attributes, bare or through `this` or a namespace/`require` import, and a bound function (`f.bind(x)`, `this.m.bind(this)`, `cgg_core::stdlib::JS_FUNCTION_VALUE_ADAPTERS`) names `f`/`m`; Kotlin callable references (`::f`, `T::m`, `x::m`, `this::m`, `a.b::m`; never class literals), C/C++ function names in arguments (not registrar verbs or ALL-CAPS macro-style callees), initializer lists, initializer and assignment values and returns, through `&`, casts and `?:`, qualified only behind `&` (`plugins/c_values.rs`, one cursor walk; never a name the source continues with `::` or `(`, a qualified name or call an error-recovering parse split; skips names an assignment of the enclosing function binds and file-scope variables, and, in ObjC, names a parameter or declaration of the enclosing function binds, and in C++ a parameter (a macro-mangled constructor keeps its parameter list when the table loses its declarator); C's and C++'s other bindings are pruned through their locals table), Go function and method values in arguments outside registrar verbs, composite-literal values, `=`/`:=`/`var` right-hand sides and returns (names a local in scope binds are pruned through the Go locals table); Ruby `method(:m)`/`instance_method(:m)` and kin through their receiver and the old name of `alias_method`/`alias` (`cgg_core::stdlib::RUBY_METHOD_NAMING_CALLS`), and receiver-less `validate :m` (`frameworks::rules::RUBY_SYMBOL_CALLBACK_VERBS`); PHP callables (`'f'`, `'C::m'`, `[$this|self::class|C::class|'C'|$o, 'm']`) at the callback position of the functions in `cgg_core::stdlib::PHP_CALLABLE_ARG_FUNCS`; Swift `#selector(name)`, call arguments, property, local and assignment values and returns, bare or through `self`, a type or a variable (never key paths or implicit members; type properties go to `FileFacts::data_members`); Dart tear-offs in arguments and named arguments, initializers, assignments and returns, and `Foo.new`/`Foo.named` (class fields and getters go to `FileFacts::data_members`); Scala method values (`f _`, `obj.m _`) and names in argument, initializer, return, infix-operand and assignment positions (such a name is also a paren-less site, and `prune_paren_less` drops the value reference when the paren-less call survives; `val`/`var` members and class parameters go to `data_members`); Lua function names in call arguments, table constructor values, `=`/`local` right-hand sides and returns, bare or through the table written (`M.f`, `obj.f`; never `obj:m`); Erlang `fun f/N`, `fun m:f/N`, `{M,F,[..]}` and `{M,F,N}` atom tuples outside patterns, a bare `{M,F}` only as a call argument, the right of `=`, a record field value or a clause value (never `high`: `weak_receiver_type`), and the M/F/A arguments of `cgg_core::stdlib::ERLANG_MFA_CALLS` callees; Groovy method pointers `this.&m` / `Type.&m` / `obj.&m` (read where the source between receiver and name is exactly `.&`: a `binary_expression`, a `field_access` or an ERROR node `recv.&` / `.&name`, since tree-sitter-groovy has no `.&`) and Groovy 3 `this::m` / `Type::m`; Objective-C `@selector(name:…)` and `NSSelectorFromString(@"…")` with a literal (`stdlib::OBJC_SELECTOR_FROM_STRING`), recorded by first keyword with receiver `cgg_core::OBJC_SELECTOR_RECEIVER`, and C function values in `.m`/`.mm` through `c_values` (ObjC methods are frames there, their parameters bindings); Zig call arguments, `const`/`var` initializers and assignments, `return`, initializer field values and `.{…}` elements (comptime vtables), through `&`, bare or through `Self`, a type, an `@import` alias or a container-level declaration, every later segment a type (a lowercase namespace segment allowed only after an import); a member through a local (`x.cb`) is a field read; Clojure symbol arguments of calls (threading steps included), `#'f`/`#'ns/f`, `def` values, binding-vector inits, `fn`/`defn` body forms, and map values and vector elements holding one, with a plugin-side shadow filter read from the locals table (`let`/`loop`/`doseq`/`for`, parameters, `letfn`, `deftype`/`defrecord` fields, `with-…` vectors, destructuring; `binding`/`with-redefs`/`…-redefs` names are vars; tables `cgg_core::stdlib::CLOJURE_*`) and bare names kept only when the file defines or `:refer`s them (`:refer :all`/`:use` of a non-stdlib namespace keeps all); none in files over `cgg_lang::MINIFIED_LINE_BYTES`; OCaml value paths as written in application arguments (labeled too), operator operands, record field values (punned too), list/array/tuple elements and a `let`'s value; Haskell bare variables as `apply` arguments, operator operands, record field values, list and tuple elements and an argument-less binding's value, never inside a type or pattern; F# `f`/`M.f` as application arguments, operands of `fsharp::FUNCTION_OPERATORS` (pipelines, composition, monadic), record and object-expression values, list/array/tuple elements and a `let`'s or member's value, never a capitalized bare name or a member through a variable) 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. The Go plugin types receivers and parameters name-first (`go_param_types`, skipping `GO_PREDECLARED_TYPES`, type parameters and composites), records inner-block and func-literal shadows of a parameter, and records embedded fields and interfaces as `ClassDecl` bases. 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 (the return-type map is keyed by `(language, name)`, and a name is typed only when every definition of it in the language declares the same type, the calling file's own definition first; 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. Type-first languages (`TYPE_FIRST_RETURN_LANGS`: Java, C#, Dart, Groovy, C, C++, ObjC) read the declared return type in front of the name; a bare-call receiver (`make().work()`) or a local assigned from a bare call (never a `value_ref` naming it) takes the agreed return type, never for a name the file imports, and never `AWAITABLE_TYPES`, `TOP_TYPES` or one-letter types; a Groovy `def` field holding a bare call's result types its name (weak) wherever no local or parameter of the name is in scope; the receiver-name heuristic (Strategy 4) is off for them. Kotlin reads its declared return type after the parameter list (`fun make(): Tool`; the plugin's `signature_hint` runs to the body) through the same call-result path (`CALL_RESULT_LANGS`): a bare call types its receiver or `val` local from the calling class's own member when that is the run's one member, extension or local function of the name (`ReturnTypeIndex::kotlin_scoped`, `kotlin_scoped_counts`), else from the top-level functions (`kotlin_top_level_defs`), the file's own first, which all agree; never for an imported name, a name a member, extension or local function also carries, a call in an extension body or a scope-function lambda, a type parameter (`kotlin_type_params`), a container or function type, or `Any`/`Unit`/`Nothing`. JS/TS constructed (`new T()`) and annotated locals type their receiver only where `FileFacts::locals` says that declaration is the binding in scope; a constructed `let`/`var` reassigned in its scope (`REASSIGNED_LOCAL`) is untyped. 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 (`included_copies`); the same holds for the other include-style imports (Bash `source`, Ruby `require`, R `source()`, PowerShell dot-source, Solidity `import`, Starlark `load`, CMake `include`, Lua `require`): a call binds the copy in the file the include names, an include path several files share expands to each at `medium` (`uncertain_includes`), and an imported name another file also carries is `high` only when the copy is in the caller's closure (`import_binding_certain`); preprocessor alternatives in an included file bind once; in the caller's own C or Objective-C file, same-named definitions that each sit in a different branch of one `#if`/`#ifdef`/`#elif`/`#else` chain (`DefRecord::preproc_branches`, recorded by `plugins/preproc.rs` for C, C++ and ObjC; nested chains count) bind all at `medium`, never `high` (`intra_file::PREPROC_ALTERNATIVE_LANGS`), and a call inside one branch binds only the alternatives that branch can see; a body outside any conditional beside one inside, or two in one branch, stays `ambiguous-in-file` (C++ binds such a set as an overload set already), and a closure holding only a prototype reaches the single body elsewhere. Plugins emit constructions as typed calls of the constructor: C# `new T()`/target-typed `new()`, Ruby `T.new` → `initialize` (the `new` record kept), PHP `new T` → `__construct` (class as written), Solidity `new C`, PowerShell `[T]::new()`, ObjC `[[T alloc] init…]` (`alloc` is in `CONSTRUCTING_METHODS`); Swift `init` carries its class's bases and `override` marker. Constructor delegation is recorded with receiver `super` (or the class's own name for delegation; `not_def` excludes the constructor itself) and resolves through the base chain: Java `this(…)`/`super(…)`, JS/TS `super(…)`, C# `: this`/`: base`, C++ member initializers (results limited to constructors; a data-member initializer leaves no record), Solidity base-constructor modifiers, Ruby bare `super`; a miss for a constructor name, in Ruby or C++, is refused rather than name-matched, and a base constructor reached through same-named class copies narrows to the caller's file, else the nearest copy. The Java, Scala, C++, C# (every call; `params` is variadic, an extension method's `this` parameter is the receiver or the first argument), Kotlin (every call; a trailing lambda counts, a spread leaves it unknown; `vararg`), TypeScript (every call and `new`; `x?:`, rest and `this:` read), Groovy and Swift (trailing closures count) plugins set `RefRecord::arity`, and Swift also `RefRecord::arg_labels`, which narrows same-count overloads by label (`type_hints::swift_accepts_labels`: defaults skipped, a variadic takes the unlabeled arguments after it, a trailing closure the next parameter it must fill), 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 (C#, Kotlin, TypeScript, Swift and Groovy read the whole set even when the count is unknown). 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. A Go bare type narrows to the caller's directory. A typed lookup landing on more same-named owners than the cap is refused as `fanout-cap-exceeded` (`SAME_NAME_OWNER_CAP_LANGS`), narrowing first to the nearest copy only when every candidate's file has the same name; Go and JS/TS join the C++/Zig untyped fallback (`untyped_receivers`). JVM and Ruby typed receivers narrow their owners by the qualifier written (a Ruby `T.new` or `super` that is not certain, or `A::T.new` landing in another namespace, is `candidates-in-other-files`) (`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; a bare Clojure name reaches only its own namespace (intra-file), a `:refer`'d var (`refer` import records → `direct_imports`) or a namespace referred whole (`refer-all`/`:use`, step 1b joins with `/`), never steps 1c/1d, and `require` registers only the alias; `alias/f` follows `import-vars` re-exports (indexed into `by_qn`); OCaml `open A.B` narrows to the module's file (`module_files`). An F# call through a capitalized path binds the `f` of the module that path names, written whole or relative to the file's own namespaces and modules or its `open`s, nearest first (`LibIndex::resolve_fsharp`); one target is `high`, copies `medium`, and a path no in-tree module declares falls through and stays external. 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`, Swift `init` included), **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/Scala/C# against an external import; an in-tree one only in Java and 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 interface/abstract declarations tagged `abstract` (TS, Swift, Scala, Dart, Java, C#, Kotlin, Groovy, Go, PHP, C++ `= 0`, ObjC `@protocol`, Solidity, Fortran interface bodies, Rust trait signatures; intra-file also prefers a concrete candidate over them; the narrowing never leaves only the caller or only candidates the arity rules out, and a bare or `this` call to the caller's own abstract member keeps the declaration) 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/Scala/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, Lua `require`, and a Go bare-call receiver through the calling package) 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`; Erlang `m:f` is resolved by module name in `cross_file.rs` instead and is never refused as `not-in-imported-module`); 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 (`LocalBinding::param`, lambda and closure parameters included) and locals with their scope span (`FileFacts::locals`, `cgg-lang/src/locals.rs`, Lua file-scope `local`s too except same-named member aliases of a call result and value-less declarations; Go parameters, receivers, named results, `:=`, `var`/`const`, `for`/`range`, `if`/`switch` initializers, type-switch and `select` bindings (a `func(x T)` type's names are not bindings); C# parameters (lambdas, local functions, anonymous methods, `params`), locals, `foreach`/`for`/`using`/`fixed`/`catch` variables, pattern and `out var` variables (in scope to the end of the enclosing block from an `if` condition or expression statement, otherwise the statement) and query range variables, with a local function's name bound in its block; C and C++ parameters (C++: default-valued, packs, lambda parameters and init-captures; template parameters are not locals; C: K&R ones only when a K&R declaration types them) and block-scope declarators (in scope from the end of the declarator; none inside a function-pointer declarator's parameter list, a block-scope prototype, a parenthesized name without `*`, or a declarator after a parse error in its declaration; C++ also structured bindings, range-for, `if`/`switch` initializers, `while` and `catch`, a body's `T x(arg);` read as a variable (most vexing parse), and no local from an ALL-CAPS macro alone on the line before a statement; read by `locals::collect_c`, its own walk); Groovy method, constructor and script-function parameters (untyped too), closure parameters (and a value-position closure's implicit `it`), Groovy 3 lambda parameters, `def`/typed locals (a typed one only with a value; in scope past continuation lines), classic `for`, `for … in`/`:`, `catch`, try-with-resources and `def (a, b) =`, and a script's own variables in its body and closures but never its methods or classes, only in a file declaring no type; parameter lists read from their text between the delimiters the tree places; no binding from a command expression, a lost method header, a declaration holding its own ERROR, or a bare word (`locals::collect_groovy`); R parameters (`function(…)`, `\(…)`) and, inside a function, `<-`/`=`/`->` and `assign("x", …)` (literal name, no environment argument; `cgg_core::stdlib::R_ASSIGN_FUNCTIONS`) locals and `for` variables, in scope from the end of the assignment to the end of the function (R scopes by function; never `<<-`/`->>` or a top-level assignment), skipping a binding known to hold no function, which R's call lookup passes by: a literal local, a parameter defaulting to a number, string or logical, a binding of a call of its own name (`x <- x()`) (`locals::collect_r`); Julia parameters of functions, macros, short-form methods, lambdas and `do` blocks (positional, optional, keyword, varargs, typed, destructured; each from the end of its own declaration, so its default sees the outer name; never `where` type parameters), function-body `=`/`local` locals and local functions to the end of the innermost `for`/`while`/`let`/`try`/`catch`/comprehension/lambda/function (not `if`/`begin`; never a name declared `global`), and `for`, comprehension, `let` and `catch` variables; a literal local shadows too, since Julia has no function-only lookup (`locals::collect_julia`); Haskell clause argument patterns over the clause (guards, body and its `where`), `where` bindings over the whole clause, `let … in` over the whole `let` (recursive), `do`-`let` from its statement and `p <- e` from its end to the end of the `do`, comprehension generators and `let`s over the head expression and from the qualifier on, lambda and `case`/`\case` alternative patterns over their alternative, and pattern guards to the end of their right-hand side; patterns bind through as, constructor, tuple, list, record (`R{f = x}`, puns), bang, lazy and infix patterns, never a constructor, field name, type or top-level declaration; operator-named bindings are not recorded, since no call site carries an operator (`locals::collect_haskell`); Clojure parameters of `fn`/`defn`/`defn-`/`defmacro`/`defmethod` over their arity's body (`&`, vector and map destructuring: `:keys`/`:strs`/`:syms`, `::keys`, `:ns/keys`, `:as`; never an `:or` default or a schema after `:-`), the `fn` self-name, method parameters and `letfn` specs, type fields over their methods, `letfn` names over the `letfn`, `let`/`loop`/`doseq`/`for` (`:let`)/`when-let`/… bindings from the end of their init, other `with-…` vectors' own symbols and `catch` bindings; `binding`, `with-redefs` and `…-redefs` macros rebind vars, never locals (`stdlib::CLOJURE_VAR_REBINDING_FORMS`/`_SUFFIXES`); inside a syntax-quote a macro's locals are in scope only under `~`; one table (`plugins::clojure::binding_spans`), also read by the WS26 value filter; Nix lambda parameters, formals and `@` names over the whole lambda (body and defaults; a file whose top level is a lambda binds its formals over the file), `let` bindings with `inherit x;`/`inherit (s) x;` over the whole `let`, and `rec { … }` attributes over the set; `inherit (import ./f.nix …) x;` is that file's `x` and binds nothing (`stdlib::NIX_IMPORT_BUILTIN`), and plain attrset keys, top-level bindings and `with e;` never bind (`locals::collect_nix`); Starlark `def`/`lambda` parameters from the end of the parameter list (a default sees the outer name), a `def` body's assigned names, `for` targets and nested `def`s over the whole body (the language rule), and comprehension variables over their comprehension; never module-level, `load()` or keyword-argument names (`locals::collect_starlark`); Solidity parameters and named returns of functions, modifiers, constructors, `fallback` and `receive` over the definition (never a function type's own parameters, an event's or error's), block-scope and tuple declarations from the end of their statement to the end of their block, `for` initializers over the loop, `try … returns` names over the success block and `catch` parameters over their clause; never state variables, and Yul is not read (its calls are not recorded) (`locals::collect_solidity`); F# `let`-function and member parameters (curried, tupled, typed, `?opt`, patterns) and a member's self identifier over the definition, `fun` patterns over the lambda, `match`/`function`/`try … with` rule patterns over their rule (guard included), local `let`/`let!`/`use`/`use!` from the end of the binding to the end of its `in` body (`let rec` from its own start) and `for` variables; never module-level or class `let`s, and a capitalized pattern head is a union case, not a variable (`locals::collect_fsharp`); OCaml punned record fields in patterns (`{ f; _ }`) bind `f`; Fortran dummy arguments, the result variable (`result(r)`, else the function's name), every variable the specification part declares (type declarations, `dimension`/`allocatable`/`pointer`/`target` statements, `common` members, procedure pointers) and any name assigned through an argument list (an array or statement function) over the whole program, function or subroutine including its `contains`, plus `block` declarations, `associate` names and `do concurrent`/`forall` indices over their construct; never a non-pointer `procedure`/`external` declaration (unless a dummy), an interface body, a derived-type component or a module variable; recorded lowercased, and `scope::CASE_INSENSITIVE_LANGS` folds a Fortran call's name to match (`locals::collect_fortran`); 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`; a C-family function-like macro is never shadowed: it expands before any scope applies), intra-file drops same-named definitions outside the binding's function, and a receiver headed by one is not an import (except a Go receiver type propagation rewrote from a declaration, `RefRecord::retyped_receiver`: a parameter is not in scope in the signature its type is written in). 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. A non-parameter local binds a function nested in some other function only when its initializer calls the function that definition is nested in (`teardown := setupDB(t)`, `const { a } = await make()`); any other local shadows. Not for a type's members (a Kotlin primary constructor's span) or a `let rec … and …` chain's own bindings; Haskell, whose actions run without parentheses, keeps the old reading. Lexical narrowing of nested definitions applies to Python (bare calls, and `dot_call` sites too), JS/TS (when locals were read), Go (a call through a `name := func…` local binds the closure its own declaration defines), Lua, OCaml, F#, Scala, Kotlin, Swift, Rust, Dart, Haskell, Julia, Nix and Starlark, and Clojure (`letfn` functions only, visible where the form's binding of the name, matched by its declaration byte, covers the site: `intra_file::clojure_letfn_visible`; never a cross-file, framework-handler or known-name candidate, though a file knows its own top-level `letfn` names), with positional shadowing in OCaml, F# and Lua; `cgg::analyze_in_pool` merges a plugin's own locals (C-family macro expansions) with the file's; a lone nested definition whose enclosing function does not contain the call is kept, at `medium`, only through such a local; from another file it is never a bare-name candidate in Python, Go, JS/TS, Lua, OCaml, F#, Haskell, Julia, Nix and Starlark (`cross_file::nested_function_ids`, applied after the fan-out cap; a call through a receiver may still reach one). `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, and a `local function` is never in a requiring file's scope. A required Lua module's members include the file-level functions its returned table names (`FileFacts::module_returns`); a file-scope `local a = m.b`, `m` a `require` (`FileFacts::member_aliases`), is resolved as `m.b(…)` (`member_alias_call`) and stays `shadowed-by-local` when that binds nothing. 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 (`value_ref` records included) and `super` receivers, 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, the `spock` rule enters `string-named` methods and the `Specification` fixtures as test cases, a Clojure registrar verb that is also a `clojure.core` function captures nothing without a route string, and neither table ever creates an edge; rules of kind `test` (test, benchmark and fuzz harnesses) mint ONE node per harness (`<framework-entry>::test::<id>`, e.g. `cargo-test`, `junit`, `pytest`) with an `entry` edge to each case, the dead-code pass treats them as test roots (`TEST_ROOT_LABEL_PREFIX`), and in dedup a harness entry never evicts a framework entry on the same target (`--no-test-entries` turns them into root marks: no node, same dead-code report); rule `attributes`/`methods` accept case-sensitive `prefix*`/`*suffix` naming patterns (exact method names match case-insensitively, except for the rules `rules::methods_case_sensitive` lists — gtest, gtest-fixture, libfuzzer — which match exactly) (`Test*` does not take `Testing`), and a name-only test rule applies only in a test file or one importing the harness; in Java/Kotlin (`IMPORT_SCOPED_ATTRIBUTES`) an attribute matches only in a file carrying the rule's import evidence or written qualified under a `detect` prefix (`.kts` scripts exempt); JS/TS test-harness sites are arbitrated per file (`HarnessChoice`, `harness_globals_rank`): the imported harness, else the tree's globals harness, else `js-test-globals`, and a built-in rule with no detect inputs whose methods are exact names is detected by a file defining one (`libfuzzer`); the dead-code `is_test_name` needs a word boundary after `Test`, Go follows `go test`'s rule and PowerShell has no name-based tests; `container_attributes_for` roots every member of an attributed Rust `impl` (`#[pymethods]`, `#[Object]`), `markup_kinds_for` enters code-behind handlers bound in sibling markup (one node per event, kind `ui`), and `TEST_CLASS_MEMBERS_ARE_ROOTS` makes the other members of a Python/PHP/Ruby test class test root marks; the dead-code root tables read a `::`-qualified attribute by its last segment, and root coverage counts production roots plus built-in-recognised tests only; 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` and Clojure protocol declarations (by the `protocol:` attribute) and multimethods (`defmulti` → `defmethod`s, by the `multimethod:` attribute); `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`. Go is not in `INHERITANCE_LANGS`: `cgg_resolve::go_iface` reads Go method sets (an interface's declared methods plus those of the interfaces it embeds; a named type's value- and pointer-receiver methods plus those promoted through embedded fields; keyed by package directory, qualified embeds found through the file's imports, standard-library ones in `cgg_core::stdlib::GO_STDLIB_INTERFACES`). `go_interface_fanout` adds a `Via::Dynamic` edge from each interface method declaration to the same-named method of every type whose method set covers the interface by name and, where the signatures show it, parameter count; an interface that only repeats a standard-library one (a local `stringer`) fans out to nothing. Under `--dead-code` only, `go_interface_site_edges` also links an unresolved method call (cap-exceeded or candidates elsewhere; never a call through a package or a value reference) to the declarations of that method name in in-tree interfaces of the calling package or a package it imports, an import resolving to the directory sharing the most trailing segments with its path. A Go method whose type satisfies a standard-library interface carries `implements-external-contract`. 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`) (a Clojure method or `defmethod` whose protocol or multimethod the tree does not declare is a root instead, `clojure:external-contract`, and a top-level Clojure `alias/f` call or `#'alias/f` roots exactly that var, through aliases and re-exports), 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, Java, C#, Ruby, PHP and Go (`MethodByName`/`FieldByName`), plus JS/TS module loads: a reflective lookup by literal name (screen `reflection`) caps every same-named finding at medium and never creates an edge, and a reflective enumeration of a type the code names (`cgg_core::stdlib::MEMBER_ENUMERATION_APIS`: C# `typeof(T).GetMethods()`/`.GetTypeInfo().DeclaredMethods`, Java `T.class.getDeclaredMethods()`, PHP `get_class_methods(T::class)`/`new ReflectionClass(T::class)` `->getMethods()`, Ruby `T.instance_methods`, Python `inspect.getmembers(T)`; recorded as a `DynUse` with `via` `cgg_core::DYN_MEMBERS_OF`) puts every method `T` declares under the same screen; a value's type (`o.GetType()`, `getClass()`) is never read and inherited members are not screened, 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 (a local whose own declaration defines that function, `const f = () => …` or a C# local function, does not, while a parameter named like its own function does; a Zig value must name a declaration of its own file or a file-level alias, or go through `Self`, a type or an `@import` of a `.zig` file of the tree, and a capitalized bare Zig value is kept only when the file defines a function of that name; a bare ObjC value is kept only when it names a C function, never a method; an Erlang value needs a clause of its arity, in its own file when bare, else an in-tree `m:f`; a Clojure `ns/f` is read as `f`, never when the namespace part holds a `:`); an OCaml or Haskell binding with no parameter and no `fun`/lambda value is recorded in `FileFacts::data_members`, so a value naming it is dropped (in Haskell, unless the file itself defines a function of that name), and in OCaml, Haskell and F# (`LOCAL_DEF_LANGS`) a non-parameter local whose scope holds a same-named definition is that definition, not a shadow, before Phase 3, and `tag_reference_edges` tags edges from those sites `Via::Reference` before the drop (at a byte shared with a call, `f.bind(x)`, only edges to the value's own name); it also drops a bare C/C++ or Swift value name some struct, class or type declares as a field or property (`FileFacts::data_members`; Objective-C files also see the C and C++ files' `data_members`; in C only where the file sees it, itself or its `#include` closure (`cross_file::c_include_closures`), and does not define the function itself, or where no function of that name is visible to the file; C/C++/ObjC values that name a type keyword or standard type (`cgg_core::stdlib::C_TYPE_NAMES`), a name followed by another identifier, or a `typedef`/tag/alias the file or its include closure declares (`FileFacts::type_names`) are dropped; C++ and ObjC fields stay language-wide), and in Dart and Scala (`MEMBER_DATA_READ_LANGS`) a value through a receiver naming such a member. A Go method value, Kotlin `x::m`, Groovy `obj.&m` or Swift/Dart/Scala/Lua/Zig `x.m` value through an untyped receiver never fans out by name, and a bare Zig value never binds another file's function by name; a qualified Zig value, or one through a file-level alias, binds only what the `@import`/container/alias lookup finds, never a same-named function by fallback. An ObjC selector binds every class's method of that selector (`high` for one, else `medium`, refused over `--fanout-cap`). In the dead-code roots, a Ruby, Swift, Dart, Scala, Lua, Erlang, Clojure, (bare) Zig or Groovy (`this.&m` only) value reference outside any callable credits only its own file's same-named callables (`FILE_SCOPED_VALUE_REF_LANGS`) (not OCaml, F# or Haskell, whose module-level names arrive by `open`/`import`), and a container-level Zig value through an imported file (`.free = mod.free`) credits that file's function; other top-level value sites credit by name. Under `--dead-code` only, `FileFacts::unreachable` regions (`cfg.rs`; never a Rust block holding `#[cfg]` statements, ending before any `goto` label) feed `cgg_resolve::unreachable`: before exit-node synthesis, edges from sites inside them are capped at `medium`, and the dead-code liveness adjacency skips them (evidence `incoming-only-from-unreachable-code`). 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 (a JVM call through an import alias only when the alias names the callee's owner) (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 (one file, one qualified name) narrowed to one by arity, cross-file included (`CertainCtx::path` treats a qualified name carried only within one file as one callee once the count is known; copies in several files are not). A Kotlin class-body property or constructor `val`/`var` parameter is a declaration scoped to its class (`kotlin_property_at`; the innermost declaring class must agree on one type, else it is a guess). A receiver type propagation guessed (`RefRecord::weak_receiver_type`, honoured by intra-file's single-candidate, overload and clause arms and the cross-file module-alias arm too, 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`. `crates/cgg/build.rs` stamps the CLI's `--version` (and the startup log's `build` field) with the build identity: bare `X.Y.Z` only for an official build (`CGG_OFFICIAL_BUILD=1`, set by the release workflow; a clean checkout at tag `vX.Y.Z`; or no checkout, i.e. a packaged crate), otherwise `X.Y.Z (dev g<commit>)`, plus `+d<fingerprint>` of the uncommitted diff, so an interim binary is never mistaken for a release; the version stays the second word, and the library version (`cgg_core::CGG_VERSION`, Python, Node, C ABI) is unchanged. `main.rs` 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, filenames, resolver_kind, ts_language, extract; optionally `parse_source`). 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. It never replaces an official build (bare `cgg X.Y.Z`) with an interim one unless `CGG_INSTALL_DEV=1`.
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. Runs `markdownlint-cli2` over every tracked `.md` (except the generated npm platform READMEs) — the same set as release.sh's gate; skipped with a warning when `npx` is missing.
8. 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.