* fix(mcp): report UNKNOWN risk when an upstream impact walk finds no callers `risk: LOW` asserts "safe to change" — a claim ABOUT callers. An upstream walk that resolved none has nothing to base it on: the symbol may be genuinely unused, or reached only through a reference class the index does not record (a property access on a plain object, a bare-identifier read of a module-scope const). Seeding LOW from an empty result is the false-safe signal `anyKnownRisk` already refuses to emit on the ambiguous-candidate path, and that #2687 removed by making an undetermined impactedCount `null` rather than `0`. Zero-caller upstream results now report risk UNKNOWN with a riskNote saying absence of edges is not evidence of disuse. Downstream is untouched: an empty downstream walk reports resolved callees, not safety. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default parameter value, `return LIMIT` — minted no reference site at all, because JS captured only `@reference.read.member`, which requires a receiver a bare identifier does not have. So "who uses this constant?", the question behind every dead-code trim and constants refactor, answered with a confident zero in both directions. The rest of the machinery was already in place: `FIELD_KINDS` accepts `Const`, the scope query already declares it via `@declaration.const`, and `read` maps to ACCESSES for any resolved target. This adds the missing capture in VALUE POSITIONS ONLY (call arguments, default-parameter values, return statements) — a blanket `(identifier)` rule would mint a site for every token in the file, which is unaffordable at repo scale and would keep alive the block-local symbols `pruneLocalSymbols` exists to drop. Cross-file readers are NOT yet covered: the site exists and a call through the same import statement resolves, but a value-kind def does not link across the import edge. Recorded as a todo with the investigation. PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so a warm cache replays the pre-change capture set and the new edges never appear — observed directly, a full `analyze --force` produced a byte-identical graph until the cache was cleared by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(javascript): pin A1/A5 plain-object property acceptance criteria Fixture plus todo specs for the four shapes plain-object property access has to answer: object-literal keys indexed as Property nodes, a read through the holding variable, a property WRITE, and a read through an untyped param. Records the investigation so the work is resumable: the parse-query pattern scoped to literals bound to a variable matches correctly (verified against the raw JAVASCRIPT_QUERIES), but no Property node reaches the graph and local-symbol-pruner is not the cause — it drops only Const/Variable/Static. The remaining gate is in the parse worker's node-creation path. No production code — specs only, so the suite stays green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(javascript): index object-literal keys of a named object as Property nodes Idiomatic JS models configuration as an object literal, not a class, but Property definition nodes existed only for DECLARED CLASS FIELDS. A config field therefore had no symbol at all: `context({name: 'exitMinAtrMult'})` answered "not found" for a field read and written throughout a live code path, and ACCESSES had no target to point at. Both halves are added for keys of a literal BOUND TO A VARIABLE — the parse query mints the graph node, the scope query mints the def the resolver can aim at. Unbound literals are deliberately excluded: an inline call argument or a JSX prop bag is call-site data, not a named surface other code references, so a node per key there would add volume without adding an answerable question. This lands the definition-node half only. The ACCESSES edges still require receiver resolution — typing the const that holds the literal to the literal's scope for the precise case, and name-based matching at reduced confidence for the untyped-param (option bag) case. Both are recorded as todos with the mechanism each needs. Also records a trap that cost a wrong conclusion: under vitest the parse worker runs the BUILT dist code (parse-impl resolves parse-worker.js, absent under src/, and falls back to dist), so parse-query changes are invisible to tests until `npm run build`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cache): move the SCHEMA_BUMP pin to 45 The pin is the guard that makes two branches claiming one cache-schema number fail loudly instead of silently serving each other's entries, so a bump is only half-done until the pin moves with it. The bump itself landed with the JavaScript bare-identifier captures; this is the other half. Caught by the guard working exactly as designed — the suite failed with "expected 45 to be 44" rather than letting a mismatched pair through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): resolve plain-object property access by unique name Idiomatic JS reads configuration off an object whose receiver cannot be typed — an options bag passed as a parameter, a destructured handle, an imported literal. No precise pass resolves those, so a field read and written across a live code path produced no ACCESSES edge at all and "who reads this setting?" answered a confident zero. A last-resort pass runs after every precise pass and sees only what they left behind. For each still-unresolved read/write site it asks whether exactly ONE Property in the workspace carries that name. If so the read almost certainly means it. If two or more do, nothing is emitted and the site is COUNTED as ambiguous — a guess between them would be a coin flip, and a wrong edge in the pre-edit safety gate is worse than a missing one. Uniqueness is the right gate because it recovers exactly the names worth recovering: distinctive domain fields (exitMinAtrMult, bookNotionalUsdt) are unique in a repo and resolve, while generic keys (id, name, data) are not and are skipped — which is where name matching would over-connect. Bounded four ways: - Confidence 0.5, the global tier, with the inference named in the reason, so a consumer can filter inferences without losing scope-resolved edges. - Never second-guesses a precise result: sites already resolved are excluded, because first-write-wins stops a duplicate but NOT a second edge to a different target. - Honors `fieldFallbackOnMethodLookup`. A statically-typed language opts out of name matching precisely because it over-connects; inferring an ACCESSES edge by name is the same claim and must obey the same opt-out. - Requires an explicit receiver — a bare identifier is not a property access, and matching one by name would link a local to an unrelated key. Indexes graph nodes rather than scope defs because an object-literal key mints a Property NODE but no scope-resolution DEF: `localDefs` and `scope.bindings` are both empty for exactly the population this serves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(analyze): record a collapsed graph write instead of reporting fresh The dangerous half of a broken refresh: metadata IS written, so the index reads as fresh, hooks re-arm, and every tool answers from a graph missing most of its edges — indistinguishable from a codebase that genuinely has no such relationships. Reported in the field as edges collapsing 23009 -> 2170 and as a CodeRelation table that never materialized. `analyze` now compares the relationship count the pipeline PRODUCED against what the DB hands back after the write. Both numbers are already in scope at the same point, so the shortfall is provable rather than inferred — no comparison against the previous index, which cannot distinguish a failed write from a repo that legitimately shrank. A missing relation table needs no special case: it reads back as a persisted count of zero. On a collapse the run records `graphWriteCollapsed` in metadata, which `getIndexIncompleteReasons` turns into `graph-write-collapsed` so status and the MCP resources report the index INCOMPLETE rather than fresh. A ratio, not equality: some relationship types do not round-trip one-for-one and `--pdg` writes MORE rows into the same table, so demanding equality would fire on healthy runs. Only a collapse is a defect. Fail-safe when the expected count is unavailable — an implementation that offloads relationships out of memory may not be able to report a total, and a false "your index is broken" is worse than a missed one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(ingestion): qualify object-literal Property ids by their owning object Two config objects in one file that share a key name generated the same `Property:<file>:<key>` id and COLLAPSED INTO ONE node, so two distinct settings became a single symbol. Worse, the merged name then looked workspace-unique to name inference, which happily resolved reads of it to a node representing both — a wrong edge in the pre-edit safety gate, which is precisely what the unique-name pass is bounded to avoid. `objectLiteralOwnerInfo` already existed for exactly this ("so two constructors in one file that both define `bar` stay distinct nodes") but was gated to `Method`. `Property` now opts in. `findObjectLiteralBindingInfo` returns `ownerName` only when asked. Its `Method` ids must stay byte-identical — qualifying them would rewrite every object-literal method id in every indexed repo — while object-literal KEYS, indexed only since A1/A5, have no such history to preserve. Found by a test written for the ambiguity path rather than by review: the suite reported one node where two were expected, and an edge where none should exist. Both are now pinned, along with the detection boundaries of the B2 collapse check, which was previously an untestable inline expression and is now a pure function. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(typescript): index type aliases and shape members as symbols A TS frontend models its API contracts as `type X = { … }` and `interface`, so a field on one is exactly what "who breaks if I remove this?" is asked about. Three gaps made that unanswerable, all in the TypeScript queries: 1. No `type_alias_declaration` -> `@definition.type`, so an alias minted NO NODE AT ALL and a context() lookup on an exported contract type answered "Symbol not found". TypeScript was the ONLY language missing this — Rust (type_item), Kotlin (type_alias), Swift (typealias_declaration) and Dart all emit it. The alias was declared for scope resolution but never became a graph symbol. 2. No `property_signature` in the parse query, so INTERFACE members minted no Property nodes either — the upstream report's "class/interface index fine" holds only for the type, not its fields. 3. No `property_signature` in the scope query, so even with nodes present the resolver had no member declaration to aim at. Its sibling `method_signature` -> `@declaration.method` already existed; only properties were missing. Interface bodies and object-type aliases both spell members as property_signature, so one pattern per query covers both shapes. Lands the SYMBOLS, not yet the ACCESSES edges: the shape is already a class-like scope and now has member declarations, but no edge forms — the remaining link is owner/type-binding, recorded as todos with the diagnosis. Note TypeScript sets fieldFallbackOnMethodLookup:false, so unlike JavaScript there is deliberately no name-based fallback here; the precise path is the only route by design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(golden): accept interface members in the mini-repo snapshot Drift is entirely the new TypeScript shape-member indexing: the fixture's three interfaces (ValidationResult 2, DbRecord 3, LogEntry 3) contribute exactly 8 Property nodes, each with exactly one HAS_PROPERTY owner edge. Verified before regenerating rather than after: every pre-existing count is untouched (CALLS 9, IMPORTS 12, DEFINES 16, HAS_METHOD 1, MEMBER_OF 12, STEP_IN_PROCESS 12), so nothing was rewired — the digest moved only because 8 edges were added. The fixture's inline `return { valid: false, … }` literals correctly produced nothing, confirming the object-literal rule stays scoped to variable-bound literals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(analyze): never report a collapse from a non-numeric count The B2 check reported healthy runs as total graph-write collapses. A non-numeric `expected` (a graph implementation reporting no total, a lightweight pipeline result) does not skip the guards — it INVERTS them: `undefined < 100` is false, so the small-repo exemption never fires, and `0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check "passes" as well. Both bounds silently evaporate and every such run is flagged. That is precisely the failure this check was written to catch, reproduced inside the check itself: an unmeasurable quantity treated as a measured zero. Both sides are now validated as finite numbers before any comparison. `persisted` is also passed as UNKNOWN rather than zero when the DB was not demonstrably readable: `getLbugStats` flattens "no connection", "query threw" and "empty table" all into `edges: 0`, so `stats.nodes > 0` is used as independent evidence the read happened at all. Caught by the existing run-analyze suites, not by the new unit tests — those exercised the pure function with well-formed numbers and were blind to the integration's actual inputs. Both cases are now pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(typescript): make object-type aliases own their members A TS object-type alias declares the same `property_signature` members as the interface beside it and answers the same question, but was not a member owner: its fields were minted with bare ids and no owner edge, so two aliases in one file sharing a field name collapsed onto one node, while the identical interface resolved normally. `type_alias_declaration` joins CLASS_CONTAINER_TYPES (and CONTAINER_TYPE_TO_LABEL, as that set's invariant requires — a container missing there gets orphaned member edges or a wrong owner label). Aliases with no object type (`type Id = string`) declare no members, so they own nothing and are unaffected. This also lands the INTERFACE field -> consumer edges, verified on the mini-repo fixture rather than only on a purpose-built one: `saveToDb` now links to `ValidationResult.value`, and `formatLogEntry` to `LogEntry.level` and `LogEntry.message` — three real contract-field reads that previously had no graph path at all. Golden updated: +3 ACCESSES, no node changes. The ALIAS field -> consumer edge is still not linked and is recorded as a todo with the exact blocker: resolving a receiver typed as the alias needs the NAME to resolve to a class-like def, and `isClassLike` is Class|Interface|Struct|Record|Enum|Trait. That predicate is read from ~12 sites including MRO and heritage, and every language mints TypeAlias, so widening it would enrol aliases in linearizations where they do not belong. Widening only the scope index was tried and reverted — the type-name walkers gate on it independently, so it fixed nothing and left dead code. That needs a deliberate "shape-like" concept, not more call-site widening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(test): record the traced diagnosis for the unlinked alias field edge Traced to the end rather than left as "needs investigation", so the next attempt starts from facts: 1. Graph side is COMPLETE and symmetric with the interface — Property:...:LiveModeConfig.bookSlots is owner-qualified and carries HAS_PROPERTY. 2. Resolution DOES reach resolveClassBindingForName('LiveModeConfig') (instrumented) and misses. 3. It misses because the module scope binds LiveModeIface:Interface, renderAlias, renderIface — and not LiveModeConfig. The alias has no binding on the receiver's scope chain at all. 4. The TS scope query tags aliases @declaration.type, but normalizeNodeLabel accepts only typealias / type_alias and has no "type" case, so it returns undefined. Kotlin and Dart use @declaration.type_alias; TypeScript is alone on the dead tag. 5. Retagging is NECESSARY BUT NOT SUFFICIENT — tried, and the binding still does not appear, so a second gate exists in how a declaration anchored on a node that is ALSO a @scope.class anchor is attached: the alias appears to bind inside its own scope rather than hoisting to Module, where interface_declaration evidently does hoist. An isShapeLike predicate (the nominal-vs-structural split: shapes declare members, nominal types participate in MRO) plus a mirrored findShapeBindingInScope were built and REVERTED along with the retag. With no binding on the chain they never fire, and shipping inert widening is worse than shipping none — the same standard applied to the earlier scope-index attempt. The design is recorded here; it is worth doing once step 5 is fixed, and it also unblocks Rust's parked union_item, which the MEMBER_OWNER_NODE_TYPES comment documents as the same gap in another language. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): resolve cross-file value references, skip block-locals Two halves of the same question, "who uses this constant?". CROSS-FILE. `resolveReferenceSites` runs against the registries and, as its own comment says, "imports live in finalized bindings the registries can't see" — which is why free CALLS need `emitFreeCallFallback`. Reads had no counterpart, so `import { LIMIT }` followed by a bare use resolved to nothing while a CALL through the very same import statement resolved fine. This adds the read/write counterpart, reusing `findValueBindingInScope` (which walks the FINALIZED chain) rather than inventing a lookup. Confidence 0.9: the import names the def, so this is precise resolution, not inference. BLOCK-LOCALS. Bare-identifier capture also matches a read of a block-local `const`, and an edge to one keeps alive exactly the inert locals `pruneLocalSymbols` exists to drop — a pruned node becomes a retained node plus an edge, in every function of every indexed repo. Emission now takes the set of value defs bound at MODULE scope and drops ACCESSES to Const/Variable/Static outside it. The cross-file pass carries the same guarantee structurally: a def in another file cannot be a block-local of this one, so it skips same-file hits entirely. The block-local leak was already shipped in the intra-file A2 commit and was found only because a test was written for the guard rather than the feature — the same way the object-literal id collision surfaced. Verified on the full resolver matrix: 3172 tests, golden unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(lbug): diagnose a vanished staging CSV instead of surfacing a Binder error A forced rebuild could fail with "COPY failed for File: Binder exception: No file found that matches the pattern .gitnexus/csv/file.csv" and then an ENOENT on .gitnexus/csv/rel_Folder_File.csv — two engine-level messages that name neither a cause nor a remedy, which is where several field reports end. Only tables with rows > 0 enter the COPY manifest (csv-generator.ts), so an absent file was WRITTEN during this run and removed since. Both COPY loops now preflight and say exactly that, with the row count, both causes the reports point at (a second `gitnexus analyze` on the same repo — they share .gitnexus/csv — or an external cleanup of .gitnexus/), and the action to take. Scope note, deliberately narrow: this does not attempt to fix WAL corruption or checkpoint rotation. Those already have detection and recovery hints (isWalCorruptionError, WAL_RECOVERY_SUGGESTION, the configurable wal-checkpoint-threshold), and the ~6000 lines added to lbug/ + storage/ since v1.6.9 — index-lock.ts most of all, which serializes writers and plausibly closes the concurrent-run class outright — postdate every report in the window. Guessing at unreproducible durability faults would be speculation; making the one failure with NO handling legible is not. An existing overlap test induced this exact scenario (a manifest entry pointing at a missing csv) and asserted on the engine's wording. Its intent — that a node-COPY failure is rethrown at the FK barrier rather than swallowed — is unchanged and still asserted; only the message it matches moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): split shape-like from class-like, linking alias fields Completes A4: a field on a TypeScript object-type alias now links to the code that reads it, the last unanswerable half of "who breaks if I remove this?" for a TS frontend that models contracts as `type X = { … }`. `isClassLike` answered two questions that only coincide for classes: 1. does this declare MEMBERS I can look up? — a SHAPE (structural) 2. does this participate in inheritance / MRO? — a NOMINAL TYPE An object-type alias is (1) and emphatically not (2) — it has no supertypes and no place in a linearization. Widening `isClassLike` to buy (1) would have enrolled every language's aliases (Rust type_item, Kotlin/Swift/Dart typealias, C typedef) into MRO and heritage, so the two questions now get two predicates. Call sites split by which they ask, and their names already said which: `resolveInheritanceBaseInScope` and `resolveQualifiedInheritanceBase` keep `isClassLike`; receiver typing and member OWNERSHIP take `isShapeLike`. Three parts, each necessary and none sufficient alone: - `findShapeBindingInScope`, mirroring `findValueBindingInScope`'s established relationship to `findClassBindingInScope` (same walker, different accepted def-type), consulted only AFTER the class lookup misses so a class of the same name always wins. - `populateClassOwnedMembers` uses it, so alias members get an `ownerId` and are registered under the alias. Without this the receiver resolved to the alias and then found no members under it. - The TS scope query tags aliases `@declaration.type_alias`, not `@declaration.type`: `normalizeNodeLabel` accepts typealias / type_alias and has no "type" case, so the old tag mapped to NO label and TypeScript aliases produced no scope-resolution def at all. Kotlin and Dart already spelled it this way; TypeScript alone was on the dead tag. An earlier attempt concluded a further "scope-attachment gate" existed. That was wrong and is worth recording: scope extraction runs in the parse WORKER, which loads built `dist`, so the retag was never executed. Rebuilt, the alias hoists to Module scope exactly as the interface does. Same trap as the parse query — `src` edits to anything the worker runs are invisible until `npm run build`. Typedef and Union stay out of `isShapeLike` deliberately: they belong conceptually (the union_item note on MEMBER_OWNER_NODE_TYPES records the same gap) but neither is wired as a member container, so including them would widen a predicate nothing exercises. Verified on the full resolver matrix: 3173 tests, golden unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(typescript): pin the type-alias capture to a tag that maps to a label The capture test asserted `@declaration.type`, the tag that `normalizeNodeLabel` does not recognize (it accepts typealias / type_alias and has no "type" case). So the test passed for as long as the tag was broken: it checked only that the capture FIRED, never that it resolved to anything, while TypeScript aliases produced no scope-resolution def at all. Updated to the working tag and given a second assertion that the derived kind string is one the label mapper accepts — the property that actually matters, and the one whose absence let a dead tag sit pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(lbug): declare TypeAlias member pairs so analyze does not abort Making object-type aliases member owners emits HAS_PROPERTY from a `TypeAlias`, and the relation schema declared no such pair. The emit therefore threw `UndeclaredRelationPairError` and the ENTIRE analyze died on any repo containing `type X = { ... }` — a hard stop, not a dropped edge. Found by running the analyzer over a real 16k-node TypeScript repo, not by a test. `Method` is declared alongside `Property`: a member written `type Handler = { onClick(): void }` is a method_signature and would fail in exactly the same way. Why every existing test missed it: the resolver suites build an in-memory graph via `runPipelineFromRepo` and never write to LadybugDB, so the schema constraint was never exercised. `structural-pair-coverage.test.ts` is the one suite that does run the emitters against the declared pairs — and its own docstring names the gap: coverage is bounded by NON_BRIDGE_CORPUS, "a new structural emitter should land with an entry here". This adds that entry, pinning TypeAlias|Property and Interface|Property as sentinels. Verified the guard is not vacuous: removing the pair again makes the suite fail with undeclaredPairs: ["TypeAlias|Property"]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(processes): trace depth-first so multi-hop flows are detected D1 ("query ranks frontend components above the backend module that owns the concept") and D2 ("processes is dominated by trivial mechanical chains") are the same defect, and neither is about ranking or selection. The walk stops after a fixed NUMBER of traces, so traversal order decides which traces those are. Breadth-first reaches every shallow terminal before any deep one, so the quota filled with the shortest paths in the graph and the walk stopped — `maxTraceDepth: 10` was never approached. Measured on a real repo before the fix: of 300 processes NONE exceeded 7 steps and 90% were 3-4. A multi-hop business flow (signal → order → exit) therefore had no process that could represent it, and `query` could only rank the mechanical pairs that did exist. Step 4 of the caller already sorts by length and dedupes by endpoint — it was always asking for the deepest traces this walk could give it. Depth-first descends to a terminal first, so the same quota is spent on paths worth keeping. Cost is unchanged: same budget, same cycle guard, same depth ceiling — only the order differs. Measured on the same 16k-node repo, same build and flags, BFS vs DFS (an earlier comparison was discarded as confounded — it crossed builds and --pdg): steps 6-8: 50 → 168 (3.4x) totals: 844 → 806 and the reported query moved from `LiveSetupView → Cn` (a React component) to `ReconcilePositions → IsTpInProfit / WithHeld / ShouldNotify` — server-side exit management, which is what was asked for. `traceFromEntryPoint` is exported for the test. Traversal order is unobservable through `processProcesses`: `findEntryPoints` supplies several starting points, so a deep chain is traced from inside it whatever the order does. A test at that level passes under BOTH traversals — the first version of this test did exactly that and guarded nothing. Driving the walk directly, it fails under breadth-first with "expected 3 to be greater than 3". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(test): correct a stale status note left behind by a later fix The A1/A5 header still said "edge resolution REMAINING ... neither is implemented". Both shapes resolve — the typeable receiver precisely, the untyped one by workspace-unique name — and the tests below assert exactly that, so the note contradicted the file it sat on. It was accurate when written and went stale when the work continued past it. Left as-is it would tell a reviewer that a landed feature is missing. The TRAP note is kept: the parse worker still runs built dist under vitest, and that is still the trap it describes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): index literals behind identity-preserving wrappers `export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no `Property` node for any of its keys. The object-literal rule matches `variable_declarator > value: (object)` as a DIRECT child, and freezing puts a call expression in between — so the shape whose fields are most worth querying was the one shape the rule could not see. Freezing a config object is how JS publishes an immutable contract, which is why this reads as a confident zero on exactly the fields a reader cares about. The allowlist is three functions, not "any call". `Object.freeze`, `seal` and `preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes the literal's keys members of the bound name. For `const x = compute({ a: 1 })` the literal is an argument and `x` holds compute's return value, so attributing `a` to `x` would be a fabrication. Two negative controls, because the obvious one is vacuous: a bare-identifier callee is rejected structurally and would pass with no allowlist at all, so the assertion that actually pins the predicate uses `Object.entries` — identical shape, differing only by name. Verified load-bearing by adding `entries` to the allowlist and watching that test alone fail. SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix capture set. Observed as a false negative first — `analyze --force` returned the old node set until the on-disk cache was removed by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): narrow multi-candidate property names by scope Workspace uniqueness was the wrong denominator. Measured on the reporting repo: `exitMinAtrMult` has 26 `Property` definitions — 16 in one-off `scripts/`, 7 in the frontend, one in a test, and exactly ONE in the backend that reads it. Every backend read was refused because of competitors the reader cannot see. The gate was not too permissive or too strict, it was scope-blind. A name with several definitions is now narrowed before being abandoned: same-file first, then files the reading file directly imports, using the finalized import graph rather than a path-shape heuristic. Exactly one survivor at the first non-empty tier resolves; anything else stays refused. A tier holding several candidates stops the walk instead of falling through — local evidence that is itself ambiguous still contradicts reaching further out. Confidence stays 0.5 at every tier. Narrowing changes which candidate is chosen, not the kind of claim: it is still a name match, and the round-1 contract is that filtering on confidence drops all name inference at once. The reason string now names the tier that fired. Ambiguity reporting goes from a count to the actual names (capped), because a count says a gap exists while the names say which fields are unanswerable. Measured on that repo, backend readers of `exitMinAtrMult` go 0 -> 24 and total readers 9 -> 45, including the two call sites in `oppositeSignalExitManager.js` the report singled out. Both narrowing tests were mutation-checked by dropping the import evidence and confirming they, and only they, fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): capture destructured parameter keys as property reads `function exit({ exitMinAtrMult = 0 })` reads that property off whatever the caller passes, exactly as `cfg.exitMinAtrMult` would. It never appears in a member_expression, so it had no reference site at all — and this is the shape the function that IMPLEMENTS a behaviour uses, so the most relevant reader was the one systematically missing from "who reads this setting?". Uses a distinct `@reference.read.destructured` anchor rather than `@reference.read.member`. The latter is filtered emit-side to matches with a member_expression ancestor, because calls and writes share its shape, and a destructuring pattern has none — reusing the tag would have been silently dropped by that filter. The `read.` head already maps to a read kind, so no mapping change is needed. Scoped to formal_parameters. A destructuring binding elsewhere (`const { x } = require('m')`) is frequently an import rather than a field read, and minting a property read there would attribute module bindings to unrelated same-named keys. All three cases (default value, bare shorthand, renamed key) mutation-checked by removing the patterns and confirming those three tests, and only those, fail. The renamed case also asserts the edge points at the KEY and that the local alias mints nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): link type consumers to the type they name An exported contract type owned its members after round 1 and still answered `incoming: {}`, so "what breaks if I remove this field?" — the question a contract type exists to answer — had no edge to walk. Measured on the reporting repo: all 324 TypeAlias nodes AND every Interface node had DEFINES as their only incoming edge. Two independent causes, and the second is why the first was not enough. TypeScript captured no type references at all — only cpp and csharp did — so an annotation naming a declared type minted no reference site. Added for annotations, generic arguments and `as` assertions, anchored to those contexts rather than a bare `(type_identifier)`, which would also match the name in `type X = …` and make every declaration a consumer of itself. That alone fixed interfaces and left aliases still empty. `TypeAlias` was missing from `LINKABLE_LABELS`, so alias graph nodes were never indexed in `nodeLookup` and `resolveDefGraphId` could not bridge a def to its node — the edge was dropped AFTER a successful lookup. `CLASS_KINDS` has always listed TypeAlias and the ClassRegistry returned the def correctly, which is what made this read as a resolution failure; instrumenting the lookup showed it returning the right def all along and moved the search one table over. Exactly the bug already documented two entries above it for Trait. Fixes every language that spells an alias this way — TypeScript, Kotlin, Dart and Rust all emit `@declaration.type_alias`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): capture record construction as property writes The read side answered well after the narrowing work while "who SETS this field?" still missed the code that stamps the value. A record built inline — `return { exitContract: { exitMinAtrMult: settings.x } }` — is bound to no variable, so it minted no definition and its keys referenced nothing. Modelled as WRITE REFERENCES, deliberately not definitions. The round-1 rule already mints Property nodes for literals bound to a variable; minting more for anonymous records would add same-named competitors to the very name-narrowing that makes these fields resolvable — measured at 26 competing definitions for one field on the reporting repo, which is what made every backend read unanswerable in the first place. A construction site is a USE of a field, not another declaration of it. Two positions only: nested under a key, and returned. Both are records with a name attached (the key, or the function). An inline call argument (`doThing({ id: 1 })`) stays excluded for the same reason round 1 excluded it from definitions — it is call-site data, not a named surface — and is asserted as such. The enclosing literal is the receiver and it is anonymous, so these route through the same narrowing and the same refusal-to-guess as every other untyped receiver. Verified on the reporting repo: `entryPlan.js` went from no rows to `selectExitEnvelope` as a writer of `exitMinAtrMult`. Both captures mutation-checked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(processes): select round-robin by terminal so the list is not one flow repeated Ranking was `sort by length` alone, so the top of the list was one behaviour described many ways: eleven of the top fourteen processes on the reporting repo were four entry points crossed with three terminals of the SAME date-window utility cluster. Genuine call chains, but a reader learns one thing from fourteen entries, and the repo's own domain flows sat below them. Selection now round-robins across TERMINALS, deepest first. Depth still orders within a terminal and still leads the list; what changes is that no terminal takes a second slot until every other has had a first. Keying on the entry point was tried first and made it worse — many files declare a `main`, so each was a distinct entry that round-robin then awarded its own slot, and `Main -> AlignWindowEnd` went from one row to eight. The repetition was never in where a flow starts. Measured on that repo: distinct terminals in the top 20 went 3 -> 20, and its domain flows (`ReconcilePositions -> ...`) moved into the top 4%. Two things this deliberately does not claim. The reported cause — ranking rewarding fan-in, promoting chains ending in widely-called helpers — measured FALSE: those terminals have one caller each (`alignWindowStart` 1, `validateSymbol` 1). A fan-in discount was implemented against that hypothesis, measured, and reverted for moving nothing. And a business flow still cannot be a process in its own right: the walk only emits at a leaf, at max depth, or on a cycle, so a flow whose meaningful endpoint calls onward survives only as whatever leaf it bottoms out in. Both are recorded in the code so neither reads as settled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(structural-pairs): pin the type-annotation USES pair R2-2 emits USES INTO a `TypeAlias`, so the pair is `Function|TypeAlias` — a different table from the `TypeAlias|Property` entry added in round 1, and one that entry stays green without. `TypeAlias` is on the eleven-table list this suite exists for, and an undeclared pair does not degrade: it throws `UndeclaredRelationPairError` and kills the entire analyze on any repo containing an annotated type. Every resolver suite still passes, because they build an in-memory graph and never write to the DB. That exact failure shipped once in this PR already. Two emitters into the same label, each with its own way to reach a released build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): build the module-level set before the out-of-core seal Review blocker. Under `GITNEXUS_DISK_SCOPE_INDEX=1` the seal replaces every ParsedFile with a scope-STRIPPED copy, and the block-local filter's set was built after it — so it walked `scopes: []` for every file, came out empty, and the filter read that as "no def is module-level" and dropped EVERY `Const`/`Variable`/`Static` ACCESSES edge in the repo. All languages, all files, including the module-scope-const edges this PR exists to add. Nothing threw and nothing logged, on the path the largest repos take: the exact confident-empty answer the PR is about. Built above the seal now, from `parsedFiles`, and passed as `undefined` rather than an empty set when no scope was inspectable — an empty set is a legitimate answer ("this repo has no module-level value defs") and must not be indistinguishable from "could not look". Fails open; the block-local exclusion is still asserted under the seal, since that is correctness rather than optimization. Also widens module level past `kind === 'Module'`. A `Namespace` scope (TS `namespace`, Rust `mod`, C++/C# `namespace`) holds importable values too, and treating its consts as function-locals dropped their reads. Included only when the whole chain to the root is Module/Namespace, so a namespace declared inside a function body stays local — asserted both ways. That fixture then failed for a third reason: `@reference.read.identifier` existed ONLY in the JavaScript query, so A2 did not work for TypeScript at all. Added there, and both languages widened to `variable_declarator value:` and `binary_expression` operands — the gaps review named between what A2 claimed and what it matched. Nothing covered `GITNEXUS_DISK_SCOPE_INDEX`. The new parity test asserts the seal changes no edge, and was verified against an emulation of the original bug: same-file readers vanish and only the cross-file reader survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(typescript): anchor property_signature to declared shapes Review blocker, and it reproduces end to end. `property_signature` occurs in EVERY object_type in the TS grammar, not only in an interface body or an alias's object type, so inline parameter types, inline return types and nested object types all matched — and the enclosing-container walk hung each one off the nearest class, interface or alias. Measured against the unanchored rule, all four appeared as members of shapes that do not have them: Property:contracts.ts:Svc.inlineParamOnlyKey Property:contracts.ts:Repo.inlineQueryOnlyKey Property:contracts.ts:NestedConfig.nestedOnlyKey Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33 When the inline member shares a name with a real one — `run(opts: { retries: number })` inside a class that declares `retries` — `addNode` is first-write-wins and the two distinct symbols merge onto one node, so every context()/impact()/rename() answer about that field describes the merge. The sibling JS object-literal rule in this same PR is anchored for exactly this reason; this is the TypeScript half of the same fix. `(A (B))` matches DIRECT children, so nested object types are excluded by the same anchor rather than by a second rule. The first version of these tests was VACUOUS and is recorded here because the reason generalizes: a collision and a correct exclusion both leave exactly one node behind, so counting ids cannot distinguish them. Every inline member in the fixture is now uniquely named, which is the only thing that discriminates — verified by restoring the unanchored rule and watching exactly those four assertions fail. A fifth test asserts anchoring costs no real member. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(analyze): correct the numbers feeding the graph-write-collapse guard Review blocker. The predicate itself held under adversarial probing; every defect was in what it was handed and what happened after it fired. (a) `expected` was wrong twice. Under `GraphEmitSink` streaming the bulk types leave the heap at parse time and never enter `relationshipCount`, so the count understated the real volume by most of it and the ratio passed trivially — on `force === true` runs, which include crash recovery AND the `analyze --force` retry this check's own warning tells the operator to run. Adds the manifest totals, the same correction the buffer-pool hint in this file already makes for the same reason. Separately, an incremental run persists only the changed subgraph while both counts are whole-scope: a 10,000-edge index that lost 200 replacements reads 9,800 and is certified complete. The check is skipped on that path rather than answered wrongly. (b) A throwing edge count became a measured zero. `getLbugStats` initialised its total to 0 and ran the query in a swallowing catch, so WAL/lock contention during finalize — documented on this exact call — reported a healthy index as a total collapse. It now returns `number | undefined`, and the caller requires both a readable node count and a defined edge count. (c) A total loss was exempted for being small. The min-edges rule tested `expected` before looking at `persisted` at all, so `expected = 99, persisted = 0` — every edge gone — stayed fresh and reported success. Total loss is now decided first. The existing test asserted the defect; it now asserts a PARTIAL shortfall, which is the case the exemption was written for. (d) A detected collapse reported success and exited 0. It is different in kind from the other incomplete reasons: those describe a run that did what it said and left work for later, this one means most of your edges are gone and every query answers a confident empty. The CLI now prints INCOMPLETE with the counts and sets a non-zero exit code, and the flag crosses IPC so the worker cannot send a clean `complete` either. Nothing exercised this wiring — only the pure helper. Adds tests for all four, each written so the pre-fix arithmetic fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): keep unique-name property inference inside one language The pass indexed `Property` nodes from the whole shared graph. Per-language gating decides whether it RUNS for a language; it never restricted which nodes could be TARGETS. So the only carrier of a name could be in another language entirely, and a read here resolved to it on name uniqueness alone — no owner, no file, no call path. Reproduced: a Java class declaring `private int loyaltyPointsBalance` and a JS `cfg.loyaltyPointsBalance` on an untyped parameter produced an ACCESSES edge from the JS function to the Java private field. Confidence does not mitigate it, because `minConfidence` defaults to 0 — the tier is only a filter for consumers who ask for one. Candidates are now restricted to files in the language's own `parsedFiles`, which is a precise restriction rather than a heuristic and needs no new node property. Every other fixture in the suite is single-language, so this could not be caught anywhere by construction. The new fixture is deliberately polyglot and asserts both halves: no cross-language edge, and a same-language unique name still resolves. Known and not addressed here: the index is still O(total graph nodes) and is rebuilt once per qualifying language, the per-language whole-graph-scan pattern `phase.ts` hoisted out for `sharedNodeLookup`. Hoisting it belongs with that machinery rather than in this fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(processes): explore siblings in source order, log the exhausted budget `slice(0, maxBranching)` selected the FIRST N callees while `pop()` explored them LAST-first, so the trace budget went to the last-declared branch. For `main() { init(); loadConfig(); run(); shutdown(); }` the walk spends itself on `shutdown` and can drop `init` — the earliest steps of a flow, which is the opposite of what a process describes. Selecting first-N and exploring last-first was simply inconsistent; pushing in reverse makes the stack pop in source order. Measured on the reporting repo, this costs depth: 6-8 step processes go 168 -> 146 of 816. Still roughly three times the pre-PR baseline of 50, and the right trade — a deep branch is no longer reached by accident of being declared last. The remaining limit is the BUDGET, not the traversal: with a fixed quota a deep branch declared after enough shallow ones is not reached at all. That is now asserted in both directions rather than left implicit, and the walk logs when it stops with branches unexplored — a silently truncating cap reads as "this is everything", the same confident-empty answer this work is about, and the repo already sets that precedent for `dispatchFanoutSkipped`. Removes the second depth test, which was vacuous: the note twelve lines above it already said a `processProcesses`-level depth assertion passes under BOTH traversals, and measured it does — breadth-first yields the same deepest stepCount of 8, so it passed with the production change reverted. Traversal order is asserted against `traceFromEntryPoint` directly; what is observable at the pipeline level is which traces survive selection, which the diversity tests cover. Also renames `queue` to `stack` and corrects the BFS references in the module docstring and the function's own JSDoc, which is what an IDE hover shows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(impact): carry riskNote onto ambiguous candidates and separate UNKNOWN's two meanings Two problems on the ambiguous fan-out, which builds its own candidate object rather than returning the single-symbol shape. The narrowed type had no `riskNote` field and never read one, so a candidate that resolved and found no callers reported `risk: UNKNOWN` with nothing attached — losing the entire point of the change on the path where the reader has the least context, since the name is ambiguous there by definition. And `UNKNOWN` used to mean exactly one thing on this path: the probe threw. The zero-caller branch gives it a second meaning, so an all-UNKNOWN fan-out could no longer be told apart from a broken one. Candidates now carry `probeFailed`, and the comment asserting the old reading is corrected. Also aligns `gitnexus-web`, which review flagged as giving a different verdict for the same symbol. That surface answers in prose rather than an enum, and its message said the symbol "appears to be unused (not called by anything)" — the identical false certainty in words. It now carries the same MEANING rather than the same field. Downstream wording is unchanged: no outgoing dependencies really is a fact about the symbol itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: replace assertions that cannot fail Four from review, each satisfied by the defect it was meant to catch. `new Set(props).size === 2` over two different literal strings can only ever be 2, so it could not detect the node merge its title promises — that is a difference in COUNT, now asserted on the raw array. The ambiguity test asserted only an empty edge set, which is satisfied equally by "the gate fired" and "the name was never looked up". It now also requires the ambiguity counter to have moved. `Interface|Property` was listed as a structural-pair sentinel beside `TypeAlias|Property`, but both its labels are in the SCOPE_BRIDGE cross-product so the pair is generated by construction and the sentinel cannot fail. Dropped rather than left reading as coverage; `TypeAlias|Property` is the load-bearing one. `TypeAlias|Method` was declared in the schema with no fixture emitting it — a declared pair no emitter exercises is indistinguishable from a missing one until an analyze aborts on a real repo. Adds a method-shaped alias member, and the suite requires sentinels to actually appear, so it is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: document the new incomplete reason, the UNKNOWN verdict and the id churn Review found the code changes landed without the guidance around them, and an agent following this repo's own rules would have been told the wrong thing. `graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block and no recovery section, while the precedent it cites (`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a new string naming silent wrong answers with nothing explaining trigger or remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also fails the exit code. `AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and the shipped impact skill's risk table had no UNKNOWN row and still implied few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN` and proceeds, which negates the change the verdict exists to make. Both copies of both skills updated. `MIGRATION.md` now records that process ids do not survive this release — positional ids plus depth-first tracing, source-order siblings and round-robin selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly: nothing in-repo joins on a raw process id, so it is index churn, not a broken consumer. `ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages. The guide skill's node list gains `Property` and `TypeAlias` — the two node types this work most prominently creates. Also, on the pair-CSV preflight review asked to confirm: the hard abort IS deliberate, because a fallback recovering zero rows is the confident-empty failure this work targets. But the transient the message itself names — a second concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now re-looks three times over ~150ms before declaring the file gone. Long enough to ride out a rename, far too short to mask a file that is genuinely missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop redundant TypeAlias pairs and keep bare identifiers off class members Two regressions the full suite caught after the review fixes, both real. `schema-pair-coverage` failed with eleven hand-declared pairs that a rule now generates. Adding `TypeAlias` to `LINKABLE_LABELS` — needed so `resolveDefGraphId` can bridge an alias def to its node — also makes it a SCOPE_BRIDGE source and target, so the cross-product produces `File|TypeAlias`, `TypeAlias|Property` and nine others that round 1 had declared by hand. Removed; the invariant is that no pair is both generated and hand-declared. This also changes what the structural-pair sentinel means, and the comment is corrected rather than left overstating it: `TypeAlias|Property` is no longer load-bearing because the label is off the generated grid — it is load-bearing because it now depends on `TypeAlias` being IN `LINKABLE_LABELS`. Remove it and the pair stops being generated while the hand declaration is gone, which is the same state that silently breaks alias consumer edges. `block-scope-shadowing` failed because a bare identifier resolved to a class `Property`. `class Box { baseUrl = '...'; pick() { const baseUrl = ...; return baseUrl; } }` linked the block-local read to `Box.baseUrl`, duplicating the legitimate `this.baseUrl` edge. A bare identifier is not a member access: with no receiver there is no object whose property it could be, and in JS/TS a field read needs `this.`. Receiver-less read/write sites no longer accept `Property` hits; callables stay reachable, so `cb = save` naming a top-level function is unaffected. That defect PREDATES this branch's TypeScript captures — JavaScript has emitted bare-identifier reads since A2 and no class fixture exercised the shadow. The TS parity added here is what surfaced it. Golden snapshot regenerated after verifying the drift line by line: exactly +5 USES from type annotations in the mini-repo, every pre-existing count unchanged, so nothing was rewired. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(scope-resolution): share the property-name index across language passes Review follow-up. `indexPropertyNodesByName` scanned every node in the graph and was rebuilt inside each qualifying language pass, reintroducing exactly the pattern `phase.ts` hoisted out for `sharedNodeLookup` — whose comment records why it matters: "the previous per-language rebuild burned that CPU+heap N times and, on a huge repo, a tiny language's full-graph copy overlapped the next language's — a real contributor to the scope-resolution memory peak." Built once in `phase.ts` beside `sharedNodeLookup` and `sharedFnNodeIndex`, and threaded through the same `prebuilt*` seam, so tests and isolated calls still build their own. Sharing is only safe because the per-language restriction MOVED rather than disappeared: the shared index is whole-graph, and candidates are filtered to the language's own files at lookup time. That also fixes a subtlety the per-language build had backwards — the cap now applies to the FILTERED set, so a name carried by forty properties across a polyglot monorepo but only two in the language being resolved is still answerable, where a global cap would have refused it. The tri-state at the lookup boundary is deliberate and the three outcomes are not interchangeable: no property of this name in this language (nothing to say, and NOT an ambiguity), too many to choose between (reportable), or a list to narrow. Caught mid-change by the polyglot fixture: an intermediate state shared the index without moving the filter, and the cross-language edge came straight back. That test earning its keep twice is the reason it exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): report when a field's only anchor is another language Round 3, found OUT-OF-SAMPLE — six field names appearing in no prior report, so nothing here was tuned against them. All six answered 0 backend ACCESSES while their definitions sat in `apps/research-dashboard/**`: TypeScript only. The in-sample set scored 5/5 and the out-of-sample set 0/6, and the gap is entirely this. Per-language inference (`3c5eadc7`) is right and stays. What was wrong is that declining is INVISIBLE: an empty result for a field anchored only in TypeScript is byte-identical to an empty result for a field nobody reads. One says "look in the other language or grep"; the other says "delete it". That is the same confident-empty failure this series exists to remove, one surface over — and this time the missing fact is about the ANALYZER's reach rather than the code. Declines are now counted and named, with the languages the anchors actually live in, kept SEPARATE from ambiguity because the remedies differ: ambiguity wants better receiver typing, this wants an anchor in the reading language. Collapsing them would tell a reader the wrong thing to do. A non-zero count warns at analyze time regardless of dev mode. The facts are published as `PipelineResult.propertyInference`, which they had to be for any of this to be testable — and that exposed a second defect. The round-2 ambiguity assertion, which I told the reviewer of #2856 I had strengthened, read its stat off a `scopeResolution` field that does not exist on PipelineResult: the `if (undefined) return` guard swallowed it and the test passed with the production code deleted. Both that assertion and the new ones now read the published field, and the guard is an assertion rather than an escape. Verified by deleting the counter and watching them fail. Reported by the same round-3 method note that caught it: verifying a fix against the cases it was written for only proves those cases pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(context): explain an empty property result caused by a cross-language anchor The other half of R3-1. The analyze pass now knows which fields it declined to link because every definition of the name lives in another language; this puts that fact where it is actually read. `context()` on such a field previously returned an incoming list byte-identical to a genuinely unread field. The two demand opposite actions — "look in the other language, or grep" versus "delete it" — so the difference has to travel with the answer: unresolved: property reads of this name were NOT linked: every definition of it is typescript, and name inference does not cross languages. An empty or short incoming list here is not evidence the field is unused — confirm with a text search, or give it an anchor in the reading language. anchorLanguages: ['typescript'] Carried through repo meta because the graph cannot answer it: the unlinked reads mint no edge and no node, so the only record is the pass that declined them. Keyed on the NAME, not on the resolved label. Gating on `=== 'Property'` was tried first and is wrong — the label reads `''` on this path for a plain Property node, so the gate silently suppressed the entire feature while every test still passed. Caught by asserting the field is DEFINED rather than guarding on it, which is the same anti-pattern that made two earlier assertions vacuous. The meta list only ever contains property names, so matching the name is itself the type check. Cached per (index, indexedAt): `ensureInitialized` deliberately avoids a per-call `loadMeta` because every tool routes through it, so this re-reads exactly when a re-analyze could have changed the answer and never otherwise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): report declined property reads for opt-out languages too Generalizing R3-1 rather than waiting for it to be re-reported in the other direction. The reported case was a JavaScript read whose only anchor was TypeScript; the mirror — a TypeScript read anchored only in JavaScript — was still silent, because a language that sets `fieldFallbackOnMethodLookup: false` had the whole pass skipped, and skipping emission also skipped REPORTING. Detection is not inference. Counting what could not be linked asserts nothing about what it means, so `reportOnly` runs the pass for its facts while emitting no edge, and the opt-out keeps protecting exactly what it protected before. Two things this turned up that a single-instance fix would have missed: The cross-language fixture could NOT prove `reportOnly` is load-bearing — the per-language candidate filter already blocks those edges, so the assertion passed with the flag forced off. The case that discriminates is a SAME-language TypeScript read that name inference could legitimately link and the opt-out forbids; forcing the flag off there emits `readsTsOnly -> tsOnlyBudget`, which is the violation. Getting to that case surfaced a sibling gap, recorded but NOT fixed here: the object-literal `Property` rule is JavaScript-only, so `const CONFIG = { ... }` in a `.ts` file mints no node and its keys are invisible. The first draft of this fixture used exactly that shape and could not discriminate for that reason. It is the TypeScript half of R2-1a and wants its own change, not a rider on this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(typescript): index object-literal keys, as JavaScript already did The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived only in JAVASCRIPT_QUERIES, so the single most common config idiom in TypeScript — export const tsRuntimeConfig = { tsConfigRetries: 3 }; — minted no node for any key. `context()` answered "Symbol not found" and a precise read through the holding variable had nothing to resolve to. TypeScript sets `fieldFallbackOnMethodLookup: false`, so these gain no name-based inference. What they gain is the PRECISE path, which is the route TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable receiver and now resolves. A read through an untyped receiver stays unresolved and, since `0c5a4f64`, is reported as such rather than answering an empty set. Scoped exactly as the JavaScript rules are — bound to a variable, and for the wrapper only the three functions that return the argument they were given — with the same `Object.entries` negative control pinning the allowlist. Found by fixture, not by report: the first draft of the `reportOnly` test used a TS `const CONFIG = { ... }` as its discriminator and could not discriminate, because the shape mints nothing. That is the whole argument for sweeping a class instead of waiting for each instance to be filed. SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying none of these matches and the keys stay invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): anchor anonymous returned object literals to their function The last gap round 3 named, and the dominant shape in idiomatic JS: 437 `return {` sites in a single backend directory of the reporting repo, including the ~25-field payload of its entire signal pipeline. The literal binds to nothing, so its keys could not even be named — "who reads wickRatio?" had no symbol to ask about. The enclosing FUNCTION is the owner: the literal is that function's return shape, a contract its callers consume. Keys qualify as `<function>.<key>`, so two functions returning the same name stay two shapes rather than one merged symbol, and multiple returns in one function stay distinct by position. RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES to avoid adding same-named competitors to narrowing. These are definitions, but narrowing now ranks DECLARED anchors — named literals, class fields, interface and alias members — strictly above return shapes. A name that already resolved keeps resolving to what it resolved to before, so the competitor problem R2-1b was avoiding cannot come back. Mutation-checked: dropping that ranking breaks five pre-existing R2 resolutions. That also required an R2-1b assertion to change, and the change is a strengthening rather than a concession. It asserted `toHaveLength(1)` — no new definition — as a proxy for "adding definitions must not move an existing answer". The proxy is now false while the property still holds, so the property itself is asserted directly. No `HAS_PROPERTY` edge from the function: that would be a `Function|Property` relation pair the schema does not declare, and an undeclared pair does not degrade — it throws and kills the whole analyze. That already shipped once in this PR. Two things found by dumping rather than assuming, both fixed here: SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is the commonest spelling and the reporting repo's own payload is mostly this form, but tree-sitter models it as `shorthand_property_identifier`, which `(pair)` does not match. Caught by dumping the golden fixture and seeing a literal returning `{ level, message, timestamp: Date.now() }` had indexed only `timestamp`. Now covered in return position AND in the variable-bound rule, which had the same gap. Provenance was flagged by owner-presence, which mislabelled the anonymous case: a callback's return shape yields no name to qualify by, so it looked like a DECLARED anchor and would have outranked real declarations. Flagged by position now — a different question from whether a name could be derived. SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's, but a build stamped 48 was installed and used to analyze before these captures existed, so caches stamped 48 carry none of them — the intermediate-build hazard this ledger already records for 33/34. Golden regenerated after verifying the drift: exactly +10 Property and +10 DEFINES, every pre-existing count unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): rank production anchors above test fixtures Found by testing R3-4 on the reporting repo instead of on its fixtures. Anchoring returned literals took `wickRatio` from 6 definitions to 13 — and backend reads still resolved to nothing, because SEVEN of the new JavaScript anchors compete and four of them are in `tests/`. A test constructs throwaway shapes carrying production field names; a read in shipped code cannot mean one of them. Applied before the declared/return-shape split, because "is this the shipped program" is the stronger signal — a declaration inside a test fixture is still a test fixture. Skipped when the READER is itself a test, since a read there legitimately means the test's own shape. The first version of this test was vacuous and the mutation check caught it: the reader sat in the same file as the production anchor, so the same-file tier resolved it whether or not this tier existed. The reader now lives in a file that imports neither anchor, which leaves production-vs-test as the only thing that can decide. Honest about what this does NOT do: it narrows `wickRatio` from seven candidates to three, and three functions in different files each returning that field is GENUINELY ambiguous — refusing is correct, and the ambiguity is now counted and named rather than silent. The reported question ("who reads wickRatio?") is answerable only where one producer exists; where several do, the honest answer is the list of producers, which R3-4 made nameable for the first time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(scope-resolution): resolve members through a call result's return shape The question three rounds of reports could not answer, and the one narrowing must refuse by design: a field produced by SEVERAL functions. A read of `spike.wickRatio` could mean any producer, so name inference correctly declines and no amount of tier-tuning changes that. It needs evidence, not inference. The evidence existed in two halves that had never been joined. The call-result type binding (`const alert = formatSpikeAlert(row)` binds `alert` to a TypeRef whose rawName is the callee) predates all of this work; it simply had nothing to resolve to when the callee returned an anonymous literal, because an anonymous literal named nothing. R3-4 gave it a name. Joining them: const alert = formatSpikeAlert(row); alert.wickRatio -> Property:...:formatSpikeAlert.wickRatio Precise, at ordinary emission confidence, and it works EXACTLY where narrowing cannot: several producers sharing a field name stop being competitors because the receiver says which one. Runs before the name fallback and claims its sites, so a precise answer is never second-guessed by a name match. Measured on the reporting repo: 1,410 precise edges, and all six fields round 3 verified OUT-OF-SAMPLE go from 0 backend readers to 7, 11, 10, 7, 6 and 14. Round 3 scored 0/6 on that set; this is 6/6. The bound is asserted, not just documented: a read off a BARE PARAMETER has no binding here, because typing it needs the caller's type to flow in — that is inter-procedural and genuinely larger. Those reads still fall through to name inference and are still reported when it declines. The fixture has two producers sharing a field name precisely so the test cannot pass by name matching, and mutation-checking the owner lookup fails it. No SCHEMA_BUMP: this is scope resolution, not parse-time capture, so a warm cache already carries everything it reads. Noted in the ledger because the reflex on this branch has been to bump, and an unnecessary bump costs every user a full re-parse. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "return-shape anchoring" (R3-4/R3-5): it degrades query Reverts |
||
|---|---|---|
| .. | ||
| .claude | ||
| bench | ||
| hooks | ||
| scripts | ||
| skills | ||
| src | ||
| test | ||
| vendor | ||
| .env.example | ||
| .npmignore | ||
| CHANGELOG.md | ||
| Dockerfile.test | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| tsconfig.test.json | ||
| vitest.config.ts | ||
GitNexus
Graph-powered code intelligence for AI agents. Index any codebase into a knowledge graph, then query it via MCP or CLI.
Works with Cursor, Claude Code, Antigravity (Google), Codex, Windsurf, Cline, OpenCode, CodeBuddy (Tencent), Qoder (Alibaba), and any MCP-compatible tool.
Why?
AI coding tools don't understand your codebase structure. They edit a function without knowing 47 other functions depend on it. GitNexus fixes this by precomputing every dependency, call chain, and relationship into a queryable graph.
Three commands to give your AI agent full codebase awareness.
Quick Start
# Index your repo (run from repo root)
npx gitnexus analyze
That's it. This indexes the codebase, installs agent skills, registers Claude Code hooks, and creates AGENTS.md / CLAUDE.md context files — all in one command.
On npm 11.x?
npxcan crash during install (Cannot destructure property 'package' of 'node.target'). Use the pnpm form instead:pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyzeSee Troubleshooting →
npx gitnexuscrashes withnode.target is null(npm 11) for the full matrix (global install, npm downgrade).
To configure MCP for your editor, run npx gitnexus setup once — or set it up manually below.
gitnexus setup auto-detects your editors and writes the correct global MCP config. You only need to run it once. To configure only selected integrations, pass --coding-agent/-c with a comma-separated list or repeat the option, for example gitnexus setup -c cursor,codex.
Editor Support
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|---|---|---|---|---|
| Claude Code | Yes | Yes | Yes (PreToolUse + PostToolUse) | Full |
| Cursor | Yes | Yes | Yes (postToolUse, manual install) | Full |
| Antigravity (Google) | Yes | Yes | Yes (AfterTool, Gemini CLI hooks schema) | Full |
| Codex | Yes | Yes | Yes (PreToolUse + PostToolUse, Codex hooks) | Full |
| OpenCode | Yes | Yes | — | MCP + Skills |
| CodeBuddy (Tencent) | Yes | Yes | — | MCP + Skills |
| Qoder (Alibaba) | Yes | Yes | — | MCP + Skills |
| Windsurf | Yes | — | — | MCP |
Claude Code and Codex get the deepest integration: MCP tools + agent skills + PreToolUse hooks that automatically enrich grep/glob/bash calls with knowledge graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
Community Integrations
| Agent | Install | Source |
|---|---|---|
| pi | pi install npm:pi-gitnexus |
pi-gitnexus |
MCP Setup (manual)
If you prefer to configure manually instead of using gitnexus setup:
Claude Code (full support — MCP + skills + hooks)
# macOS / Linux
claude mcp add gitnexus -- npx -y gitnexus@latest mcp
# Windows
claude mcp add gitnexus -- cmd /c npx -y gitnexus@latest mcp
Codex (full support — MCP + skills + hooks)
codex mcp add gitnexus -- npx -y gitnexus@latest mcp
Codex hooks (PreToolUse graph enrichment + PostToolUse stale-index detection in ~/.codex/hooks.json, same schema as Claude Code) need the bundled adapter script, so they are installed by gitnexus setup -c codex rather than manually.
Alternatively, install everything as a Codex plugin (MCP + skills + hooks in one step):
codex plugin marketplace add abhigyanpatwari/GitNexus
# then inside Codex: /plugins → install "GitNexus"
Codex notes: SessionStart is intentionally not registered — Codex reads AGENTS.md natively, which already carries the GitNexus context block. Newly installed hooks need a one-time approval in Codex via
/hooksbefore they run. Pick one install route (gitnexus setup -c codexor the plugin): plugin hooks load alongside~/.codex/hooks.json, so installing both can fire duplicate hooks per tool call.
Cursor / Windsurf
Add to ~/.cursor/mcp.json (global — works for all projects):
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
OpenCode
Add to ~/.config/opencode/config.json:
{
"mcp": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
CodeBuddy
CodeBuddy reads only the first existing file in its config priority chain: ~/.codebuddy/.mcp.json (recommended) → ~/.codebuddy/mcp.json (deprecated) → ~/.codebuddy.json (legacy). Edit the first non-empty file that exists — creating a higher-priority file would hide the servers in the ones below it. If none exist, create ~/.codebuddy/.mcp.json:
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
Qoder
Add to ~/.qoder.json:
{
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
}
}
}
How It Works
GitNexus builds a complete knowledge graph of your codebase through a multi-phase indexing pipeline:
- Structure — Walks the file tree and maps folder/file relationships
- Parsing — Extracts functions, classes, methods, and interfaces using Tree-sitter ASTs
- Resolution — Resolves imports and function calls across files with language-aware logic
- Field & Property Type Resolution — Tracks field types across classes and interfaces for deep chain resolution (e.g.,
user.address.city.getName()) - Return-Type-Aware Variable Binding — Infers variable types from function return types, enabling accurate call-result binding
- Field & Property Type Resolution — Tracks field types across classes and interfaces for deep chain resolution (e.g.,
- Clustering — Groups related symbols into functional communities
- Processes — Traces execution flows from entry points through call chains
- Search — Builds hybrid search indexes for fast retrieval
The result is a LadybugDB graph database stored locally in .gitnexus/ with full-text search and semantic embeddings.
Experimental community detection engine
Experimental — not supported for production indexes. The Icebug engine is a research path for #2337. It carries no stability guarantee, may change or be removed without a major version, and partitions differently from the default, so switching engines changes community IDs and any generated context keyed on them. Reindex with
graphologybefore relying on the output.
Community detection uses the bundled Graphology Leiden implementation by default. To try the #2337 Icebug path without changing default analyze behavior, install the optional native package alongside GitNexus and set the engine:
npm i @ladybugmem/icebug
GITNEXUS_COMMUNITY_ENGINE=icebug npx gitnexus analyze
Supported values are graphology, icebug, and auto. Today auto is behaviorally identical to icebug: both try Icebug and fall back to Graphology, while graphology skips Icebug entirely.
Icebug is not a declared dependency — its prebuilds link against system Arrow 24 (libarrow.so.2400), OpenMP, and glibc ≥ 2.38, none of which GitNexus can assume. Analyze falls back to Graphology and reports the reason in progress output when the module is missing, fails to load, or predates the setNumberOfThreads / setSeed controls that reproducible community IDs require (present at icebug-nodejs HEAD, absent from the published 12.8.0 tarball — so the fallback is what you will see today). The engine is pinned to threads: 1, randomize: false for determinism.
Note that the bundled Graphology path is no longer the slow option it once was: #2337 removed an accidental O(communities × N) copy in the vendored Leiden. On a synthetic 200k-node / 800k-edge benchmark graph it went from exceeding the 60s timeout to finishing in ~15s. Real projections vary with their degree distribution, so treat that as a direction, not a guarantee.
MCP Tools
Your AI agent gets 17 tools (15 per-repo + 2 group) automatically:
| Tool | What It Does |
|---|---|
list_repos |
Discover all indexed repositories (paginated — limit/offset) |
query |
Process-grouped hybrid search (BM25 + semantic + RRF) |
context |
360-degree symbol view — categorized refs, process participation |
impact |
Blast radius analysis with depth grouping and confidence |
trace |
Shortest directed path between two symbols (call + class-member edges) |
detect_changes |
Git-diff impact — maps changed lines to affected processes |
check |
Read-only structural checks against the indexed graph |
rename |
Multi-file coordinated rename with graph + text search |
cypher |
Raw Cypher graph queries |
route_map |
API route map — which components fetch which endpoints, and handlers |
tool_map |
MCP/RPC tool definitions — where they're defined and handled |
shape_check |
Validate API response shapes against consumers' property accesses |
api_impact |
Pre-change impact report for an API route handler |
explain |
Explain persisted taint findings (source→sink flows, --pdg indexes) |
pdg_query |
Query control/data dependence at statement level (--pdg indexes) |
group_list |
List configured repository groups |
group_sync |
Rebuild a group's Contract Registry and cross-repo links |
With one indexed repo, the
repoparam is optional. With multiple, specify which:query({search_query: "auth", repo: "my-app"}). Per-repo tools also take an optionalbranchfor indexes pinned withgitnexus analyze --branch; omitting it queries the workspace index, which follows your checked-out working tree.explainandpdg_queryneed an index built withgitnexus analyze --pdg.
MCP Resources
| Resource | Purpose |
|---|---|
gitnexus://repos |
List all indexed repositories (read first) |
gitnexus://setup |
Setup and usage guidance for agents |
gitnexus://repo/{name}/context |
Codebase stats, staleness check, and available tools |
gitnexus://repo/{name}/clusters |
All functional clusters with cohesion scores |
gitnexus://repo/{name}/cluster/{name} |
Cluster members and details |
gitnexus://repo/{name}/processes |
All execution flows |
gitnexus://repo/{name}/process/{name} |
Full process trace with steps |
gitnexus://repo/{name}/schema |
Graph schema for Cypher queries |
gitnexus://group/{name}/contracts |
A group's extracted contracts and cross-links |
gitnexus://group/{name}/status |
Staleness of repos in a group |
MCP Prompts
| Prompt | What It Does |
|---|---|
detect_impact |
Pre-commit change analysis — scope, affected processes, risk level |
generate_map |
Architecture documentation from the knowledge graph with mermaid diagrams |
CLI Commands
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
gitnexus analyze --skip-git # Index folders that are not Git repositories
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
gitnexus list # List all indexed repositories
gitnexus status # Show index status for current repo
gitnexus clean # Delete index for current repo
gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
gitnexus doctor # Show runtime platform capabilities and embedding configuration
# Direct graph queries — the same tools the MCP server exposes, no MCP daemon needed
gitnexus query "<concept>" # Process-grouped hybrid search
gitnexus context <symbol> [--uid <uid> | --file <path>] # 360° symbol view; flags disambiguate a shared name
gitnexus impact <symbol> [--uid <uid> | --file <path> | --kind <kind>] # Blast radius; flags disambiguate a shared name
gitnexus trace <from> <to> # Shortest directed path between two symbols
gitnexus detect-changes # Map the working-tree diff to affected symbols and execution flows
gitnexus check # Read-only structural checks against the indexed graph
gitnexus cypher "<query>" # Run a raw Cypher query against the knowledge graph
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <group> <groupPath> <registryName> # Add a repo to a group. <groupPath> is a hierarchy path (e.g. hr/hiring/backend); <registryName> is the repo's name from the registry (see `gitnexus list`)
gitnexus group remove <group> <groupPath> # Remove a repo from a group by its hierarchy path
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
gitnexus uninstallreversesgitnexus setup— it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified by bundled gitnexus skill name (e.g.gitnexus-cli/), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass--forceto apply. Per-repo indexes (gitnexus clean --all) and the global npm package (npm uninstall -g gitnexus) are left for you to remove.
Remote Embeddings
Set these env vars to use a remote OpenAI-compatible /v1/embeddings endpoint instead of the local model:
export GITNEXUS_EMBEDDING_URL=http://your-server:8080/v1
export GITNEXUS_EMBEDDING_MODEL=BAAI/bge-large-en-v1.5
export GITNEXUS_EMBEDDING_DIMS=1024 # optional, default 384
export GITNEXUS_EMBEDDING_REQUEST_DIMS=omit # optional: omit "dimensions", or an integer to override it
export GITNEXUS_EMBEDDING_API_KEY=your-key # optional, default: "unused"
export GITNEXUS_EMBEDDING_MAX_ATTEMPTS=3 # optional, total attempts (1-20)
export GITNEXUS_EMBEDDING_RETRY_CAP_MS=5000 # optional, maximum retry delay
export GITNEXUS_EMBEDDING_MIN_INTERVAL_MS=0 # optional, minimum request spacing
export GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS=180000 # optional, per-request timeout (max 300000)
gitnexus analyze . --embeddings
GITNEXUS_EMBEDDING_REQUEST_DIMS controls only the dimensions field sent in
the request body, independently of GITNEXUS_EMBEDDING_DIMS (which still
validates the returned vector's length):
omit(ornone,off,false,0) — do not senddimensionsat all, for strict backends that return the right vector size but reject the field.- a positive integer — send that value instead of
GITNEXUS_EMBEDDING_DIMS. - unset — send
GITNEXUS_EMBEDDING_DIMS(the previous behavior).
Works with Infinity, vLLM, TEI, llama.cpp, Ollama, LM Studio, or OpenAI. Retry and pacing settings are provider-neutral; provider-specific limits should be supplied through configuration. When unset, local embeddings are used unchanged.
JVM Package Sibling Injection
Java and Kotlin files in the same package receive implicit sibling class bindings
to resolve same-package references. By default, GitNexus injects at most 200
siblings per module scope, nearest first by path. Set
GITNEXUS_MAX_INJECTED_SIBLINGS=0 to remove that per-file limit; this can
substantially increase indexing work for large packages.
export GITNEXUS_MAX_INJECTED_SIBLINGS=200
gitnexus analyze .
When the limit truncates a file's sibling set, that file is marked
visibility-incomplete: same-package references still resolve through the
injected siblings, but wildcard-import attribution (used by the Spring
bean/DI/config passes) is disabled for it rather than resolved against a
partial view. Analyze logs a sibling injection truncated warning naming how
many files were affected.
Packages with more than 500 files are a separate, fixed limit: they are skipped
entirely (logged as skipping package with N files) and every file in them is
marked visibility-incomplete. GITNEXUS_MAX_INJECTED_SIBLINGS does not lift
that skip — including at 0.
Multi-Repo Support
GitNexus supports indexing multiple repositories. Each gitnexus analyze registers the repo in a global registry (~/.gitnexus/registry.json). The MCP server serves all indexed repos automatically.
Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
Language Feature Matrix
| Language | Imports | Named Bindings | Exports | Heritage | Type Annotations | Constructor Inference | Config | Frameworks | Entry Points |
|---|---|---|---|---|---|---|---|---|---|
| TypeScript | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| JavaScript | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ |
| Python | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ |
| Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ |
| Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
Imports — cross-file import resolution · Named Bindings — import { X as Y } / re-export tracking · Exports — public/exported symbol detection · Heritage — class inheritance, interfaces, mixins · Type Annotations — explicit type extraction for receiver resolution · Constructor Inference — infer receiver type from constructor calls (self/this resolution included for all languages) · Config — language toolchain config parsing (tsconfig, go.mod, etc.) · Frameworks — AST-based framework pattern detection · Entry Points — entry point scoring heuristics
Agent Skills
GitNexus ships with skill files that teach AI agents how to use the tools effectively:
- Exploring — Navigate unfamiliar code using the knowledge graph
- Debugging — Trace bugs through call chains
- Impact Analysis — Analyze blast radius before changes
- Refactoring — Plan safe refactors using dependency mapping
- Guide — GitNexus tool/resource/schema reference for the agent
- CLI — Run analyze/status/clean/wiki commands on request
- PDG Query — Statement-level control/data dependence queries (
--pdgindex) - Taint Analysis — Source→sink data-flow findings (
--pdgindex) - Plan / Work / Review / LFG — The engineering family: implementation-ready plans, gated plan execution, graph-backed change review with taint + expert lenses, and the end-to-end pipeline
Installed automatically by both gitnexus analyze (per-repo) and gitnexus setup (global). Run gitnexus analyze --skills to additionally generate each detected functional area as a direct project skill under .claude/skills/gitnexus-area-<name>/.
Requirements
- Node.js >= 22
- Git repository (uses git for commit tracking)
- Linux: glibc 2.34 or newer (Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+). The
LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot
load and reinstalling does not help — see
Linux:
GLIBC_2.34' not found. - Windows, for full-text search: the Microsoft Visual C++ 2015-2022 Redistributable (x64) and
OpenSSL 3 (
libssl-3-x64.dll,libcrypto-3-x64.dll) resolvable onPATH— see Windows: full-text search unavailable.
Release candidates
Stable releases publish to the default latest dist-tag. When a pull request
with non-documentation changes merges into main, an automated workflow also
publishes a prerelease build under the rc dist-tag, so early adopters can
try in-flight fixes without waiting for the next stable cut. (Docs-only
merges are skipped.)
# Try the latest release candidate (pre-stable — may change at any time)
npm install -g gitnexus@rc
# — or —
npx gitnexus@rc analyze
Release-candidate versions follow the standard semver prerelease format
X.Y.Z-rc.N, where X.Y.Z is the next stable target (bumped from the
current latest by patch by default; minor or major when kicking off a
bigger cycle) and N increments per published rc. Example sequence:
1.6.2-rc.1, 1.6.2-rc.2, …, then once 1.6.2 ships stable,
1.6.3-rc.1. See the Releases page
for the full list; stable latest is unaffected.
Troubleshooting
Cannot destructure property 'package' of 'node.target' as it is null
This error comes from npm 11.x's arborist while installing gitnexus (often via npx), before gitnexus code runs. It is triggered by platform-filtered optionalDependencies in native packages such as onnxruntime-node / @huggingface/transformers (used when indexing with --embeddings). GitNexus cannot catch it at runtime — use one of these workarounds:
pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze # auto-selected when pnpm + npm 11+
npm install -g gitnexus@latest # global install avoids per-run npx reify
gitnexus analyze # if already installed globally
On pnpm 10+, lifecycle scripts are blocked unless explicitly allowed — the resolver adds --allow-build for @ladybugdb/core, gitnexus, and tree-sitter automatically when it picks pnpm dlx.
If you must stay on npm 11.x without pnpm, downgrade npm toolchain-wide (last resort):
npm install -g npm@10.9.0
See #1939 and the original #819 thread. An older variant of this crash (tree-sitter-dart tarball URL) was fixed in gitnexus v1.6.2+ (#820); if you still see install failures after upgrading, clear cache:
npm cache clean --force
npx gitnexus@latest analyze
ERR_DLOPEN_FAILED / lbugjs.node missing (pnpm dlx, pnpx)
GitNexus depends on @ladybugdb/core, whose native database addon
(lbugjs.node) is placed by a postinstall script. pnpm dlx, pnpx, and any
install run with --ignore-scripts skip lifecycle scripts, so the addon is
never put in place and the runtime crashes with ERR_DLOPEN_FAILED:
Error: dlopen(.../@ladybugdb/core/lbugjs.node, ...): tried: '...' (no such file)
code: 'ERR_DLOPEN_FAILED'
Options that run install scripts:
# pnpm dlx with explicit build permission (one-off, no global install required)
pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter \
dlx gitnexus@latest serve
# npm: global install (recommended on npm 11+; bare npx may crash — see section above)
npm install -g gitnexus@latest
gitnexus serve
# npx (npm < 11, or after upgrading npm)
npx gitnexus@latest serve
# pnpm: global install with build scripts allowed (pnpm 10.2+; no approve-builds -g on pnpm 11+)
pnpm add -g --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter gitnexus
gitnexus serve
Linux: GLIBC_2.34' not found
LadybugDB native binary (lbugjs.node) exists but failed to load:
/lib64/libc.so.6: version `GLIBC_2.34' not found (required by .../lbugjs.node)
The LadybugDB addon ships as a prebuilt binary compiled against glibc 2.34. If your distribution is older (CentOS/RHEL 8 has 2.28, Ubuntu 20.04 has 2.31, Debian 11 has 2.31), the dynamic loader cannot resolve its symbols.
Reinstalling does not help — every download delivers the same prebuilt binary. The fix is a newer C library:
- Run GitNexus on a distribution with glibc 2.34 or newer — Ubuntu 22.04+, RHEL/Rocky/Alma 9+, Debian 12+, Fedora 35+.
- Or run it in the container image, which bundles a current glibc (see Docker).
gitnexus doctor reports the required and detected glibc versions when this happens
(#2672).
Windows: full-text search unavailable
analyze completes, but keyword search is degraded and doctor shows the FTS extension failing
with Windows error 126 (The specified module could not be found). The extension needs two
runtime dependencies Windows does not ship by default:
- Microsoft Visual C++ 2015-2022 Redistributable (x64) — https://aka.ms/vs/17/release/vc_redist.x64.exe
- OpenSSL 3 —
libssl-3-x64.dllandlibcrypto-3-x64.dll, resolvable onPATH
The redistributable alone is not sufficient. If Git for Windows is installed you already have
the OpenSSL DLLs — run gitnexus from Git Bash, or prepend the directory to PATH in the
shell you use:
$env:PATH = "C:\Program Files\Git\mingw64\bin;$env:PATH"
gitnexus analyze --repair-fts
Without them the index is still built, but without search tables, so query returns empty keyword
results until you re-run gitnexus analyze --repair-fts from a shell where the DLLs resolve
(#2669).
Installation fails with native module errors
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 before installing.
If npm install -g gitnexus fails on native modules:
# Ensure build tools are available (Linux/macOS)
# Ubuntu/Debian: sudo apt install python3 make g++
# macOS: xcode-select --install
# Retry installation
npm install -g gitnexus
Installation fails behind an HTTP proxy (onnxruntime-node postinstall)
onnxruntime-node's postinstall downloads optional CUDA GPU binaries from api.nuget.org — outside the npm registry, so registry mirrors don't cover it, and its proxy layer (global-agent) ignores the standard HTTP_PROXY/HTTPS_PROXY variables and rejects 302 redirects (#2370).
Since the packages are optional dependencies, a failed download no longer breaks npm install -g gitnexus — npm skips the embedding stack and everything else works. The stack then self-heals on demand: the first gitnexus analyze --embeddings (or an explicit gitnexus embeddings install) fetches it through your configured npm registry — mirrors and proxies apply, no NuGet download involved — into ~/.gitnexus/embedding-runtime.
# heal a proxy-degraded install manually (CPU embeddings; registry-only)
gitnexus embeddings install
# reinstall into the prefix even when the stack already resolves
gitnexus embeddings install --force
# CUDA GPU hosts: also fetch GPU binaries (NuGet; set the proxy global-agent reads)
GLOBAL_AGENT_HTTPS_PROXY=<proxy-url> gitnexus embeddings install --cuda
The prefix defaults to ~/.gitnexus/embedding-runtime; set GITNEXUS_EMBEDDING_RUNTIME_DIR to install it elsewhere (e.g. a writable path in a container).
Node requirement for the on-demand prefix: the self-heal loads the prefixed packages via
module.registerHooks, available on Node ≥ 22.15 (on the 22.x line) or ≥ 23.5 (on the 23.x line). On an older Node the packages install but can't be loaded from the prefix — reinstall them into the install itself instead (works on every supported Node):ONNXRUNTIME_NODE_INSTALL=skip npm install -g gitnexus(Windows:set ONNXRUNTIME_NODE_INSTALL=skip && npm install -g gitnexus). Skipping only the CUDA download keeps full CPU embeddings (CPU embeddings don't need it). Check the result any time withgitnexus doctor(Embeddings → Support line).
Analyze warns about unavailable FTS or VECTOR extensions
GitNexus uses optional DuckDB extensions for BM25 and vector search. The gitnexus serve and MCP read paths only ever try to LOAD the extensions — they never block on a network install. The analyze command, by default, attempts one bounded out-of-process install if LOAD fails (a plain INSTALL to download a missing extension, escalating to FORCE INSTALL only when the LOAD error shows the existing file is broken or truncated, so a permanent non-file failure does not re-download on every run) and proceeds even when that install times out, so the index is always written to disk; BM25/vector search degrade gracefully until the extensions become available.
Configure the behavior with these environment variables:
| Variable | Values | Default | Effect |
|---|---|---|---|
GITNEXUS_LBUG_EXTENSION_INSTALL |
auto, load-only, never |
auto |
auto runs one bounded install if LOAD fails — a plain INSTALL, escalating to FORCE INSTALL only when the LOAD error shows the present extension file is broken. load-only only uses already-installed extensions (recommended for offline / firewalled environments). never skips optional extensions entirely. |
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS |
positive integer | 15000 |
Wall-clock budget for the out-of-process extension-install child before it is killed. |
GITNEXUS_FTS_STEMMER |
supported LadybugDB stemmer | porter |
Stemmer used when rebuilding BM25/FTS indexes. Use none for CJK-heavy repositories, or a language stemmer such as german, french, or spanish when that better matches repository comments and identifiers. Re-run gitnexus analyze --repair-fts after changing it. |
GITNEXUS_FTS_CJK_SEGMENTATION |
none, bigram |
none |
bigram inserts overlapping character-bigram boundaries into Chinese/Japanese Han-ideograph spans in content/description before FTS indexing, so LadybugDB's space-only tokenizer can see sub-phrase word boundaries. Scoped to CJK Unified Ideographs only — Japanese Hiragana/Katakana and Korean Hangul are not currently segmented. Unlike GITNEXUS_FTS_STEMMER, this rewrites stored text — enabling it on an already-indexed repo requires a full gitnexus analyze --force; neither --repair-fts nor a plain incremental analyze applies it to previously-indexed files. Set the same value wherever analyze and search-serving processes (CLI query, MCP server, web server) run. |
GITNEXUS_STREAM_GRAPH_EMIT |
0, 1 |
1 (on) |
On by default on a full rebuild (--force); incremental runs ignore it. Holds structural relationships (CALLS, IMPORTS, ACCESSES, CONTAINS, ...) as CSV-on-disk plus compact in-memory columns instead of as objects in three overlapping indexes, cutting peak in-memory graph heap by ~1.4x at no measurable CPU cost (measured A/B on a synthetic 400k-node / 1.08M-edge graph: 819 MB -> 584 MB, iteration at parity, scaling verified linear from 100k to 800k nodes, with every edge still visible through the graph interface; no end-to-end measurement on a real repository yet). Nothing is traded away — community detection, process extraction, PDG taint summaries and the local-symbol pruner all read a complete relationship set and behave identically. Set to 0 only to bisect a suspected streaming-related fault. |
GITNEXUS_COMMUNITY_ENGINE |
graphology, icebug, auto |
graphology |
Community-detection engine used during analyze. graphology is the supported default. icebug and auto are experimental and currently behave identically: both try the optional @ladybugmem/icebug native Leiden over a CSR export and fall back to Graphology if it is not installed, cannot load, or lacks the deterministic thread/seed controls. Experimental engines partition differently, so community IDs are not comparable across engines. |
GITNEXUS_WAL_CHECKPOINT_THRESHOLD |
integer >= -1 |
67108864 (64 MiB) |
LadybugDB WAL auto-checkpoint threshold during analyze (bytes). Auto-checkpoint remains enabled; -1 keeps Ladybug's stock ~16 MiB. Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. |
GITNEXUS_LBUG_BUFFER_POOL_SIZE |
integer >= 0 (bytes) |
min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling for every GitNexus database (analyze, MCP server, serve, group bridges). Bounded so a long-lived gitnexus mcp process or a large incremental analyze cannot grow toward LadybugDB's native 80%-of-RAM default and OOM the host (#2557). 0 restores that native unbounded default; invalid values warn and fall back to the default. During analyze the pool is right-sized to the graph and, on non-4 KiB-page hosts (Apple Silicon 16 KiB, Ascend/aarch64 64 KiB), scaled by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. |
GITNEXUS_LBUG_MAX_DB_SIZE |
positive integer (bytes) | 17179869184 (16 GiB) |
Upper bound for a single LadybugDB database file. This is an mmap/disk-address-space ceiling, not a memory limit — it does not constrain the buffer pool (use GITNEXUS_LBUG_BUFFER_POOL_SIZE for that). Raise it when indexing genuinely huge monorepos; invalid values silently fall back to the default. |
# Offline/airgapped: never reach the network for extensions
GITNEXUS_LBUG_EXTENSION_INSTALL=load-only npx gitnexus analyze
# Slow network: give extension downloads more time
GITNEXUS_LBUG_EXTENSION_INSTALL_TIMEOUT_MS=30000 npx gitnexus analyze
# CJK-heavy codebase: rebuild keyword indexes without English stemming
GITNEXUS_FTS_STEMMER=none npx gitnexus analyze --repair-fts
# CJK-heavy codebase: enable sub-phrase search over Chinese/Japanese Han text.
# On an already-indexed repo, the first run after enabling this MUST be --force —
# --repair-fts and plain incremental `analyze` both leave old files un-segmented.
GITNEXUS_FTS_CJK_SEGMENTATION=bigram npx gitnexus analyze --force
Analysis runs out of memory
Memory management is automatic: analyze sizes its heap to the machine
(always below physical RAM), caps each parse worker, and — rather than
grinding into a GC death spiral or crash — stops early with a message telling
you the one thing to do. Repeated
Replacement worker did not report ready within 5000ms warnings on a large
repository are part of the same picture: memory pressure starving healthy
workers, not a worker bug (#2649).
If analyze says the repository doesn't fit, do what the message says:
- The machine has more memory to give (a
NODE_OPTIONS--max-old-space-sizepin from your environment is holding analyze back): re-run without the pin — no flags needed. - The machine is the ceiling: shrink the scope (exclude generated or vendored directories, below) or use a machine with more RAM.
Escape hatches (GITNEXUS_MEMORY=off to decline the autopilot,
GITNEXUS_WORKER_HEAP_MB to size workers yourself) are listed in the
environment-variable table below —
most users never need them.
For very large repositories:
# Increase Node.js heap size
NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze
# Exclude large directories (this repo only)
echo "vendor/" >> .gitnexusignore
echo "dist/" >> .gitnexusignore
# Exclude a directory across every repo you index, without touching each
# repo's own .gitnexusignore or needing push/commit access to it. GitNexus
# reads the same sources `git` itself does: core.excludesFile (all repos)
# and $GIT_DIR/info/exclude (this repo only, untracked). A repo's own
# .gitignore/.gitnexusignore can still override either with a `!pattern`
# negation. Skip both entirely with GITNEXUS_NO_GLOBAL_IGNORE=1.
git config --global core.excludesFile ~/.gitignore_global # applies to every repo
echo "docs/" >> ~/.gitignore_global
echo "build/" >> .git/info/exclude # this repo only, untracked
Large files are being skipped
By default the walker skips files larger than 512 KB (see log line Skipped N large files (>512KB)). Raise the threshold via either the CLI flag or the environment variable — both accept a value in KB:
# CLI flag (takes precedence over the env var)
npx gitnexus analyze --max-file-size 2048 # skip only files > 2 MB
# Environment variable (persists across commands)
export GITNEXUS_MAX_FILE_SIZE=2048
npx gitnexus analyze
Values above 32768 KB (32 MB) are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, analyze prints the effective threshold in its startup banner (e.g. GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)).
Analyze reports a worker timeout
Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either:
# CLI flag, in seconds
npx gitnexus analyze --worker-timeout 60
# Environment variable, in milliseconds
export GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000
npx gitnexus analyze
For repositories with very large source files, GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES controls the worker job byte budget. The default is 8388608 bytes (8 MB).
Worker pool resilience tuning
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
| Variable | Default | Effect |
|---|---|---|
GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT |
3 |
Max replacement spawns per slot before the slot is dropped from the active rotation. |
GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS |
5 × subBatchTimeoutMs |
Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD |
max(3, poolSize) |
Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS |
30000 |
Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (Napi::Error, #2432). |
GITNEXUS_WORKER_READY_TIMEOUT_MS |
5000 |
Startup budget for a parse worker to load its grammar bindings and report {type:'ready'}. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
GITNEXUS_MEMORY |
off |
unset (autopilot on) |
GITNEXUS_WORKER_HEAP_MB |
clamp(512, RAM/2/poolSize, 4096) |
Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
GITNEXUS_SERVER_ANALYZE_HEAP_MB |
min(8192, auto cap) |
Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
GITNEXUS_CPP_CAPTURE_BUDGET_MS |
20000 |
Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). 0 expires immediately. |
Graph cleanup tuning
After scope resolution, analyze prunes inert block-local value symbols (a function-local const/let/var that ends up with only its structural File→DEFINES edge) to keep the graph focused on cross-symbol relationships. Module/file-scope symbols, class members, and any local with a real edge are always kept.
| Variable | Default | Effect |
|---|---|---|
GITNEXUS_KEEP_LOCAL_VALUE_SYMBOLS |
unset | Set to 1/true to keep inert block-local value symbols instead of pruning them. |
Programmatic callers can pass keepLocalValueSymbols: true in PipelineOptions instead of setting the env var.
Scope-resolution property-key dispatch cap
During scope resolution GitNexus synthesizes CALLS edges through property-key
dispatch — call sites like hooks.emitScopeCaptures() where a property key is
registered by multiple definitions across the codebase. To keep this fan-in
bounded, each property key is capped at 32 registrations: a key registered
by more than 32 distinct functions is skipped entirely (no CALLS are synthesized
through it), and the dropped key names are surfaced in the analyze log for
operator visibility. The cap is calibrated at 2× this repo's own provider table
(16 legitimate registrations, one per language provider).
| Variable | Default | Effect |
|---|---|---|
GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT |
32 |
Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or < 1 values fall back to 32. Lowering it tightens the overflow budget. |
# A property key registered by 40 functions overflows the default 32 and drops
# all CALLS through it — raise the cap for that repo and rebuild so scope
# resolution reruns.
export GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT=64
npx gitnexus analyze --force
Scope-resolution dispatch-target cap
During scope resolution GitNexus resolves calls that flow through callable
values — function/method references bound to variables, passed as arguments,
or stored in maps/tables. To keep that inclusion-based resolution finite, each
callable site is capped at 32 dispatch targets. When a site gathers more
candidates than the cap it is treated as overflowed and all of its call
edges are dropped — a cliff, not a tail, so a repository with a legitimately
wide dispatch table (a single callable site resolving to 33+ targets) loses
that site's whole call chain. In that case analyze logs
callable-value-flow: candidate set exceeded the cap; no partial CALLS emitted
alongside a warning carrying the language, the overflowing context, the
candidate count, and the cap (32).
Raise the cap for such repositories:
| Variable | Default | Effect |
|---|---|---|
GITNEXUS_MAX_CALLABLE_VALUE_TARGETS |
32 |
Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or < 1 values fall back to 32. Lowering it tightens the overflow budget. |
# A callable site resolving to 48 targets overflows the default 32 and drops
# the chain — raise the cap for that repo and rebuild so scope resolution reruns.
export GITNEXUS_MAX_CALLABLE_VALUE_TARGETS=64
npx gitnexus analyze --force
Hook augmentation and skip diagnostics
The Claude Code / Antigravity hooks keep their stderr silent on normal skip
paths so strict hook runners (e.g. Codex PreToolUse) never see unexpected
diagnostic output.
When a GitNexus process holds the repo DB write lock (the common case — the MCP
server is running, or the DB-lock probe timed out and failed closed), the local
CLI augment can't run (LadybugDB is single-writer). Rather than drop the
augmentation, the hook hands the agent a short, conditional MCP-query hint on
stdout (the sanctioned additionalContext channel) — "if the GitNexus MCP tools
are live in this session, call query …" — so an agent that has the tools can
still fetch graph-ranked context. The hint is throttled to at most once per repo
per window (GITNEXUS_MCP_HINT_THROTTLE_MS, default 10 min; 0 disables), so an
owner-locked session isn't nudged on every search. A stale-index reminder, or an
already-current index, stays silent.
To see why a hook skipped the CLI augment, set GITNEXUS_DEBUG=1 and re-run the
action — the hook writes the reason (e.g. [GitNexus] augment skipped: MCP server owns DB) and the stale-index hint to its stderr:
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
Only GITNEXUS_DEBUG=1 and GITNEXUS_DEBUG=true enable diagnostics; every other
value (including 0 and false) is treated as off. Diagnostics go to stderr
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
Privacy
- All processing happens locally on your machine
- No code is sent to any server
- Index stored in
.gitnexus/inside your repo (gitignored) - Global registry at
~/.gitnexus/stores only paths and metadata
Web UI
GitNexus also has a browser-based UI at gitnexus.vercel.app — 100% client-side, your code never leaves the browser.
Local Backend Mode: Run gitnexus serve and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
License
Free for non-commercial use. Contact for commercial licensing.