mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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 Revertsaf5eec5c,c764847aand 4f93f32e. The capability was real and measured — all six fields round 3 verified OUT-OF-SAMPLE went from 0 backend readers to 7/11/10/7/6/14, 0/6 to 6/6, via 1,410 precise return-shape edges. It is reverted anyway, because it costs more than it buys in its current form. `cli-limit-e2e` caught it. Bisected toaf5eec5c: on the mini-repo fixture, `query('message')` returned two processes before and NONE after. The mechanism is not window displacement — that hypothesis was tested with a partition that kept function-local property keys from taking window slots, and it changed nothing. Indexing the keys of every returned literal adds many nodes whose names are ordinary words, which moves the BM25 CORPUS statistics: "message" gets less discriminating, and `createLogEntry` — the callable that actually carries the processes — stops ranking at all. A corpus-level effect is not repairable by a tie-break. Trading a regression in `query`, one of the core tools, for coverage in `context` is the wrong trade, and shipping it because the number was good would be the same mistake this PR spent three rounds removing: a confident answer that is worse than the honest one. What the work established, and what re-landing needs: - The mechanism is right. Joining the existing call-result type binding to a named return shape resolves `alert.wickRatio` by EVIDENCE, which is why it succeeds exactly where name inference must refuse. - The cost is search dilution, and it needs to be measured on BM25 ranking BEFORE the capture lands — not discovered by a downstream e2e test. - The likely shape of the fix is keeping return-shape keys out of the text search corpus while keeping them in the graph, which needs persisted provenance rather than the in-memory flag used here. Kept: everything through8972d223, which is verified green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5 Reverts the revert. The return-shape work was correct and measured — 1,410 precise edges, and all six fields round 3 verified out-of-sample going 0/6 to 6/6 — and it was dropped for a regression that was really a MISSING LAYER: the search index had no way to say "this symbol is queryable but is not a concept a text search should surface on its own". Indexing the keys of anonymous returned literals adds many nodes whose names are ordinary words (`message`, `value`, `timestamp`). Without that notion they compete on equal terms in FTS, push the CALLABLES named after the same concept past the search's row cap, and `query('message')` returned two processes before and none after. The layer, rather than a workaround: - `Property.isDetail`, persisted. A Property-only column, which that table already precedents with `declaredType`, set where the key is minted. - `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row cap. That placement is the whole point: rows crowded out never reach the caller, so no downstream re-ranking can recover them. Two downstream fixes were tried first — a tie-break and a partition of the merge window — and recovered nothing, which is what located the real seam. - `IS NULL`-tolerant, so an index written before the column existed still answers instead of returning nothing. Verified by the A/B that found the regression: the query's result order is now byte-identical to the pre-R3-4 baseline — `proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry, Property:LogEntry.message` — with the return-shape coverage retained. The determinism guard then caught prose in the new DDL comment containing the token this repo scans for, which would have read as an unordered query. Reworded; that suite is doing exactly its job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(processes): let a flow end where the program reaches outward The item three rounds kept circling. A trace was only emitted at a node with NO outgoing calls, so a real flow — scan, score, arm, PLACE THE ORDER — is always a PREFIX of some longer chain that runs on into date helpers, and could never be a process in its own right. Ranking could not fix that; the flow was never a candidate to rank. What blocked it was signal granularity, and the fix is the layer that was missing rather than a heuristic. GitNexus already knew where the program reaches outward: the parse phase collects fetch calls and ORM queries carrying `filePath` + `lineNumber`. Those facts only ever produced FILE-level edges (`File -[FETCHES]-> Route`), which cannot end a trace — every function in a file containing one would qualify. Attributing each site to the function whose range CONTAINS it turns the same facts into the function-level signal the walk needs: no new extraction, no new relation pair, no schema change. Innermost wins, so a closure that performs the call is the sink rather than the function spanning it. Three touch points, and the second is the one that makes or breaks it: - the walk emits at a sink AND CONTINUES, so `placeOrder` is an endpoint while `placeOrder -> formatDate` still exists separately; - subset-removal PRESERVES sink-terminated traces. A sink flow is by definition a prefix of the chain that runs past it, so emitting one at the walk and deleting it one step later would have been a no-op. Mutation- checked: removing this preservation fails all three sink tests, including the one asserting the sink is reached at all; - selection ranks sink-terminated above leaf-terminated, then by depth. `processes` now declares `parse` as a dependency. It historically avoided that on the grounds the dependency was spurious for a progress counter — it is no longer spurious, so it is declared rather than reached for implicitly, and the read fails open so a pipeline without that output detects no sinks instead of losing every process. Bounded honestly: this fires where fetch/ORM extraction fires. On the reporting repo it will do nothing until route detection handles hand-rolled dispatchers, since that codebase routes with `pathname === '/api/...'` on raw node:http and produces zero Route nodes — a separate gap, and the next one worth closing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(processes): the comment above the sink ranking still described it as unreachable R3-6 taught the walk what a sink is, but the block explaining the ranking still carried the paragraph written when that was out of reach — "a business flow still cannot be a process in its own right ... fixing that means teaching the walk what a sink is" — sitting directly above the code that does exactly that. A reader arriving at `rankedByInterest` would take the limitation as current. The measured-false fan-in finding stays; it is still true and still worth not re-deriving. What replaces the stale half is the bound that IS current: sinks fire where fetch/ORM extraction fires, so a codebase whose outward calls are not detected as such still sees leaf-terminated traces only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(routes): read a route that is declared by a comparison, not by a framework `route_map` on the reporting repo returned {"routes": [], "total": 0, "message": "No routes found in this project."} for a codebase with SEVENTEEN route modules, an `apiRouteTable.js`, and 113 path comparisons. Not a partial answer — a statement about the code, and a false one. Same confident-empty class as the rest of this branch, except here it takes out a whole tool. Four route-discovery paths existed — filesystem convention, single-file framework route, cross-file framework route, decorator — and every one of them needs a FRAMEWORK to declare the route. A raw `node:http` server declares it the only way the language offers: if (req.method === 'GET' && pathname === '/api/live/portfolio') { … } A path, a verb, and a handler. Nothing in the pipeline could read it. The failure modes are not symmetric, so the rules are weighted accordingly: a route this misses is a coverage limit, a route it invents is `route_map` asserting something false. A comparison therefore qualifies only against a demonstrable request path (`pathname`, `*.pathname`, `req.url`; `path` is excluded — in Node it is overwhelmingly `node:path` or a file location), and anything untranslatable is dropped rather than approximated: - `pathname.startsWith('/api/')` is a namespace test; minting `/api` would claim a route nobody serves; - a bare `pathname === '/'` with no verb is more often the static-file normalisation branch (`pathname === '/' ? '/index.html' : pathname`) than a route — WITH a verb the intent is unambiguous, so that form IS taken; - an anchored regex converts only when its body is a literal path plus single-segment wildcards, so `/^\/api\/research-runs\/[^/]+$/` becomes `/api/research-runs/{param1}` while an optional group or an alternation bails. Three things went in that nobody reported, each found by measuring rather than by a second report. `switch (pathname) { case '/api/x': }` is the same dispatch in different syntax, and waiting for a bug report per shape is how a graph stays permanently one idiom behind the code it indexes. The reconciliation had to move up a level. The reporting repo keeps its path table (`isKnownApiPath`) in one module and its handlers in sixteen others, so a per-file rule sees each half separately and lists every route twice — once verb-less with the table as its "handler", once properly. Measured: 22 of the first 94 routes were that shadow. Only the whole registry can tell them apart, so the rule lives in the routes phase and touches dispatch-guard routes only — a framework route without a verb is method-agnostic BY DECLARATION (a Django function view, a Laravel resource), a fact rather than a weaker observation. And a path composed from a constant needed folding. One of those seventeen modules writes every one of its routes as `` `${autoTradeBasePath}/rules` ``, where the base is an alias of a module-level literal. Refusing that lost the whole file — and lost it INVISIBLY, since a module with unfoldable paths and a module with no routes are the same empty answer. Same-file only, literals only, one alias hop, and it refuses on ambiguity: a name declared twice with different values is dropped rather than guessed, because a partially-folded path is a wrong route and a wrong route is the failure this module exists to avoid. Wiring is a LanguageProvider hook, not a language check in shared code. `extractDecoratorRoutes` was already the general "route from this file's own AST" channel rather than a decorator-only one — express routes have flowed through it as `decorator-express.get` for a while — so the transport, the `(method, url)` dedup and the handler-symbol resolution all apply unchanged. `ExtractedDecoratorRoute.source` carries the one thing that genuinely differs: a decorator route is DECLARED, a dispatch-guard route is INFERRED. The walk is gated behind a substring pre-filter so it costs nothing on files that cannot produce a route, and the gate is sound by construction — every rule reaches a route only through `isPathExpression`, which needs one of exactly those tokens. SCHEMA_BUMP 49 -> 51, two entries. Decorator routes are worker output carried in the parse cache, so a warm cache replays results predating the extractor and `route_map` stays empty — the symptom this fixes, wearing the mask of "the extractor does not work". The second bump is the v34 hazard tripping again: a build stamped 50 had already been used to analyze before folding existed, so caches stamped 50 carry the unfolded route set. Caught by measuring — the post-folding run came back suspiciously fast and would have reported the pre-folding number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): ask whether a value def is FUNCTION-LOCAL, not whether it is module-level The locality filter for value references was written as an ALLOWLIST of module-scope defs, and that shape cannot express a class member. A value def has three homes, not two: module level, a function body, and a CLASS body. Java and C# fields and Python class attributes live in the third, so an allowlist keyed on "module level" excludes every one of them by construction. The guard written to make that safe could not fire either. The set arms whenever a Module scope is FOUND, and Java has module scopes while having no module-level values at all — so for Java it armed permanently empty, which is exactly the state the guard exists to distinguish from "there genuinely are none". Inverting it removes the class. A blocklist of defs positively identified as function-local fails safe: a Java field, a Python class attribute, or a language whose scopes could not be inspected is emitted rather than dropped. That also retires the arming flag — an empty blocklist and an uninspected one mean the same thing, and both mean "emit". The failure mode moves from "silently deletes an edge class" to "retains an inert local", which is the right direction for a tool whose stated principle is that a confident empty answer is the worst outcome. MEASURED, because the review that prompted this reported it as a P0 deleting every Java/C#/Python field ACCESSES edge, and that half does not reproduce. Instrumenting the bridge over `java-write-access` shows ZERO value-ACCESSES candidates reaching the filter: Java field references resolve to a `Property` target and `isValueDefinitionLabel` covers only Const/Static/Variable, so the filter is never consulted there. Pipeline-level edge sets are byte-identical with the filter forced on and forced off, across four shapes — Java cross-file field writes, Java cross-file constant reads, Java bare same-class constant reads, and a Python module-constant/class-attribute mix. The defect is real and latent; the blast radius is not. Fixed anyway, because the predicate asks the wrong question and the next change that makes the bridge the sole emitter would ship the deletion for real. New `value-ref-locality.test.ts` pins the invariant triple — local dropped, module-scope kept, class member kept — by TARGET rather than by `reason`. The per-language suites filter on `rel.reason === 'read'|'write'` while the bridge stamps `scope-resolution: read|write`, so they are blind to bridge-side change in both directions. The file states plainly which half gates the mechanism (JS, mutation-verified) and which gates only the outcome (Java, because the mechanism is unreachable there), so it cannot be mistaken for a stronger gate than it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(docs): restore the agent guidance a generated-block refresh deleted Commit 8f8261021's message is entirely about cross-language anchor reporting; it also regenerated the `gitnexus:start` block in AGENTS.md and CLAUDE.md against a LOCAL, non-PDG index and swept six documentation/config files along with it. The review caught this and it is correct. Restored: - the index stats, which regressed 248612 symbols / 565510 relationships / 918 flows -> 29969 / 118986 / 762 — my machine's index described as the project's; - the whole `pdg_query` bullet and the PDG half of the impact bullet, while both capabilities remain live in `mcp/tools.ts` and `local-backend.ts`; - the "Inline staleness signal" section in the guide skill, content that never left `origin/main` and that this branch had no reason to touch; - `.mcp.json`, which had moved from `npx -y gitnexus@latest mcp` to a bare `gitnexus` — a fresh clone with no global install gets a dead MCP server. The worst of it is self-inflicted in a specific way worth naming: commit411cac9b9, four hours earlier on this same branch, ADDED the instruction telling agents not to read `risk: UNKNOWN` as an all-clear. The refresh deleted it. So the branch shipped a new UNKNOWN verdict and simultaneously removed the guidance for reading it — the exact false-safe this PR exists to remove, reintroduced one layer up in the docs. Re-applied that guidance, and found the drift is wider than reported. The review noted the `.claude/` copy contradicting the plugin mirror; in fact the UNKNOWN block was present in ONE of five shipped distributions. `gitnexus/skills/` (the npm package), `gitnexus-cursor-integration/`, and `.agents/` were missing it too, so every non-Claude consumer of this skill had the old table. `shipped-skills-sync.test.ts` passed 54/54 through all of that. Its byte-identical check covers only the plan/work/review/lfg family, and the standard skills are guarded solely by per-skill fragment lists — so a fragment nobody listed is a fragment nothing protects. Added the UNKNOWN fragments to that list, plus a `copies.length > 1` assertion so an empty copy list cannot make the loop vacuous. Verified it fails against the pre-fix tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): require the return-shape producer to RESOLVE, not merely to name-match Review finding 2, reached independently by three Claude lanes and two Codex legs, and reproduced here. `emitReturnShapeMemberAccesses` took the receiver's type binding, then filtered a WHOLE-GRAPH property index with `idNamesMember` — a textual match on the node id. Any node whose id happened to read `<producer>.<member>` qualified, in any file and any language, and it emitted at the 0.9 PRECISE tier where a `minConfidence` floor cannot filter it out. The sibling unique-name pass was given a per-language restriction for exactly this hazard; this pass consumed the same shared index with none. Three guards, catching different shapes: - the producer must RESOLVE to a definition (`findCallableBindingInScope` — a CALLABLE lookup: the producer is the function whose return shape owns the member, and it resolves through finalized import bindings so a producer in another file still yields its own file); - the member must live in that definition's file; - that file must belong to the language being resolved. The third is not redundant with the second, which is the part worth recording. A receiver typed by CONSTRUCTION (`const bound = new Loyalty()`) resolves through the shared class registry, which is polyglot — so the producer resolves into `Loyalty.java`, its members legitimately live in that same file, and file equality waves the cross-language edge straight through. Also fixes the sibling P2: a site where the receiver IS typed to a producer that owns no such member now claims the site. That branch is the strongest negative evidence the pipeline can produce, and letting it fall through meant the 0.5 name fallback answered a question the precise pass had just DISPROVED — measured, linking a read to an unrelated same-named key in another file. `polyglot-property-isolation` gains the bound-receiver arm the review asked for, and it is the right arm: the pre-existing case has an untyped receiver and so only ever exercised the unique-name pass, while one extra token routes an identical read through this one. Mutation-verified — restoring the pre-fix matching makes exactly the new leak assertion fail. The first version of that arm was silently vacuous (it introduced a JS key of the same name, which destroyed the fixture's Java-only premise), which is why it now asserts on the TARGET FILE rather than on the absence of a name. KNOWN LIMIT, stated rather than papered over: a member-call producer (`const r = svc.make()`) binds `svc.make`, which resolves to no callable, so this pass now declines it. Codex B3 raised that converse case and it is real. Fixing it means typing `svc` and then finding `make` on that type — a larger piece of work, queued for the follow-up PR. Declining is the correct interim behaviour: the alternative is matching `make.<member>` by name across the graph, which is the fabrication this commit removes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): resolve the import map by point lookup so the seal cannot empty it Review finding 4, reproduced end-to-end by two lanes: the same commit and the same repo produced a DIFFERENT graph depending on `GITNEXUS_DISK_SCOPE_INDEX`. `buildDirectImportMap` built `scopeToFile` by walking `parsed.scopes`. The out-of-core seal replaces `emitParsedFiles` with a scope-STRIPPED copy — that is its documented contract, scopes are reachable only via `scopeTree.getScope` afterwards — so under the seal the map came out empty, every `directImports` lookup returned undefined, and tier-2 narrowing died repo-wide. The reporting is the worse half. The loss surfaced as `ambiguous`, which means "several candidates and the pass refused to choose". The truth was "the evidence was discarded one function earlier". A reader acting on that would go looking for better receiver typing to fix a problem that was not there. This is the SECOND consumer of `parsed.scopes` on this branch to hit the seal. The first was hoisted above it. This one is converted to the point lookup instead, which is the stronger fix: a point lookup survives the seal by contract, so there is no ordering left for a future edit to get wrong. The parity assertion that would have caught it now exists. The sealed harness in `javascript-const-references` already ran the fixture both ways, but every assertion in it pinned ONE field's readers — which is exactly how a second instance slipped in, since no assertion happened to cover a narrowed name. It now also compares the WHOLE ACCESSES edge set between the two runs, as a sorted diff so a failure names the edges that moved, with a non-empty guard so two empty sets cannot compare equal and assert nothing. Mutation-verified: forcing the map empty fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scope-resolution): bind a producer's own returned key to itself, and stop claiming uniqueness for a ranked answer Review finding 3, accepting the two defects it demonstrates and declining the remedy it proposes. Both halves are mutation-verified. 1. A SITE INSIDE ITS OWN RETURN SHAPE NOW BINDS TO ITS OWN KEY. `export function buildB(row) { return { tickIntervalMs: row.b } }` writes the key that IS `buildB.tickIntervalMs`. Ranking declared anchors above return shapes is correct for a READ through a receiver, but applied to this site it handed the write to a same-named module const that `buildB` never touches — a wrong edge — while the node the key actually defines was left with no writer at all. Both halves wrong from one rule applied to the wrong shape. Checked before every other rule, because it is evidence rather than ranking: the owner qualifier on the candidate id and the enclosing callable are the same symbol. Nothing outranks that. 2. THE TIER NO LONGER LIES. `workspace-unique` is a claim that exactly one node in the workspace carries the name — a fact about the graph, and the label a reader trusts most. An answer reached by FILTERING (tests down-ranked, return shapes down-ranked) is a weaker claim, and it was reported under the same label. The edge is unchanged; what it is allowed to say about itself is not. `narrowed` now counts these correctly too, since it keys off the tier. WHAT I AM NOT DOING, and why. The review proposes dropping the same-file and imported-file tiers "and keeping only genuine workspace-uniqueness". That would revert the measured R2 result taking backend readers of `exitMinAtrMult` from 0 to 24. Workspace uniqueness was already measured too strict on that repo: the field carries 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. Strict uniqueness declines all 24. The alternative suggestion — require the receiver to bind to the owning object — has the same effect by another route: the population this pass exists for is the untyped option bag, whose receiver binds to nothing. Requiring a binding turns the pass off for its own use case. So the two demonstrated defects are fixed and the capability around them is kept, at half confidence, naming its inference in the reason string, and honoured only where `fieldFallbackOnMethodLookup` allows. The R3-5 precision test needed rescoping rather than relaxing: it asserted that EVERY edge to the contested field is a precise return-shape edge, which the producer's own (correct, name-tier) write now violates. It asserts the reader edges are precise and the producer's write binds to its own key — two different claims reached two different ways, which is what the code now models. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(bench): re-baseline the JS/TS scope-capture fingerprints for this branch's capture additions The `Cross-language scope-capture fingerprint + scaling guards` CI step was failing on TypeScript and JavaScript, and it had been failing for the whole PR — the branch changed both SCOPE queries without ever updating the guard's baseline. It only surfaced now because a merge conflict had prevented CI from running at all, so nothing reported it. Re-baselined per the file's own instruction ("re-baseline intentionally on a legitimate capture change"), and verified first rather than rubber-stamped. The capture-name sets in both scope queries, diffed against `origin/main`: TypeScript + @reference.read.identifier (A2, bare-identifier reads) + @reference.type (R2-2, type references) JavaScript + @reference.read.identifier (A2) + @reference.read.destructured (R2-1c) + @reference.write.property-key (R2-1b) Nothing removed on either side. A pure superset is the check that no EXISTING capture moved — which is the failure mode a fingerprint guard exists to catch, and the reason to look before regenerating. Consistent everywhere else too: `capture_groups_small`/`_large` are unchanged (4503/14403) because those measure the SYNTHETIC scaling source this branch does not touch, so only the fixture-corpus number moves — 2097 -> 2338 across 21 new lang-resolution fixtures, 146 -> 151 files. Scaling stayed linear and inside budget (typescript 1.116, javascript 1.010, both < 1.5), so the added rules cost no super-linear time. Prior and new hashes are recorded in the baseline note, as every previous entry in that file does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(bench): re-baseline the receiver-resolution drop guard for the new WRITE site kind Second of the two bench guards that had been failing for the whole PR without anyone seeing it — CI could not run while the branch was conflicted, so both went unreported until the merge cleared. The drift is a new site KIND, not a movement in an existing one: totalDropsAllKinds 129 -> 140 bySiteKind {call: 102, read: 27} -> {call: 102, read: 27, write: 11} `call` and `read` are byte-identical, which is the check that matters. This branch added write-site captures the corpus never had — `@reference.write. property-key` (R2-1b record construction) and the destructured-read rules — so write sites reach receiver resolution for the first time, and 11 of them have a receiver that does not resolve. A drop is the honest outcome for those; the alternative is the name-inferred guess this series spent three rounds bounding. Verified it is NOT caused by this session's review fixes before re-baselining: removing the `memberNotOnShape` site-claim added in69047086and re-running gives the identical 129 -> 140 / write: 11 drift, so the movement predates today and belongs to the capture work, exactly as the arithmetic above says. The sibling `scope-emission` guard still PASSES untouched, and the fingerprint guard passes after20a937f4— so all three arms of the benchmarks job are green locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(routes): track boolean polarity in dispatch guards, so a negated condition cannot invent a route Reproduced exactly as reported. `dispatch-guard.ts` refuses to inherit a verb from an `if` whose `else` branch holds the comparison — the module's own doc comment explains why: that branch runs precisely when the condition did NOT hold, so attributing it is backwards. `!` is the same fact written as an operator, and it was not handled. A stated invariant with half an implementation, which is worse than an absent one, because the comment reads as though it were covered. Measured against the real extractor before fixing: if (!(pathname === '/api/admin')) -> '' /api/admin INVENTED if (!(req.method === 'GET') && pathname === '/x') -> GET /x INVERTED if (!(req.method === 'POST' && pathname === '/w')) -> POST /w BOTH And the review is right that this is not additive-only. Driven through the real pipeline with a policy module that serves nothing plus a one-line route table, the invented `GET /api/report` collected into `verbedUrls` and `reconcileDispatchGuardRoutes` then EVICTED the true verb-less route for that path. A false route deleted a real one. After the fix that repo yields exactly one route, verb-less, path intact. Parity, not presence: `!!x` is `x`, so counting negations and testing the parity is the only rule that keeps a doubly-negated guard working. A negated VERB drops to verb-less rather than dropping the route — `!(method === 'GET')` means every method except GET, which no single value expresses, while the path evidence is untouched. Applies to the regex arm too; `!/^\/api\/x$/.test(pathname)` had the identical hole. Deliberately NOT keeping the `statement_block` break from the suggested patch. It is unreachable — the `!` in `if (!cond) { … }` lives in the condition, a SIBLING of the block, never an ancestor of anything inside it, and the only shape that puts a `!` above a block is an IIFE, which the function-boundary stop catches first. Unreachable in the UNSAFE direction, too: breaking early under-counts negations, and an under-count reads a negated guard as positive and invents the route. Verified by mutation — with the break present, deleting it fails nothing; the other three guards each fail a test when removed. Six new cases, all previously absent (`grep -c '!(' ` over both test files was 0, and the only negation covered was `!==`, the form that already worked). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(bench): re-baseline the emit-persistence byte-identity fingerprint for the isDetail column The third bench guard this branch left red, and the one the earlier rebaseline pass missed: the `benchmarks (GITNEXUS_BENCH)` job has never succeeded once in eleven attempts, and since step 11 aborts the job, the two steps after it — the streaming PDG-emit guard and the cross-language pipeline benchmarks — have never executed at all. [emit-persistence --check] FAIL: byte-identity fingerprint drift (got 4ee15e74…, expected 69e9182a…) Cause is this branch's own `isDetail` BOOLEAN on the Property table (PROPERTY_SCHEMA), which `streamAllCSVsToDisk` writes as one more header field and one more cell per Property row. Verified header-only rather than regenerated on faith. Dumping every CSV the bench emits on both `origin/main` and this branch and diffing them per file (name, byte length, sha256): the file set is identical at 35 CSVs, 34 of the 35 are byte-identical, and the sole difference is property.csv growing 68 -> 77 bytes as the header gains `,isDetail`. The synthetic graph mints no Property nodes, so not one data row moved — which is the thing this fingerprint exists to catch. Both timing gates were green throughout (scaling_ratio 0.783 against a 1.8 budget, elapsed_ms_large 229ms against the 1000ms backstop), so no throughput claim is being rebaselined away. Justification recorded in a `_rebaselined_<reason>` key, the convention bench/scope-capture/baselines.json already sets, and the note now says so explicitly so the next regeneration records its reasoning too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj * perf(processes): build each trace key once, not once per comparison `deduplicateTraces` held its `join('->')` inside the `some()` callback, so every already-kept trace had its key rebuilt from scratch against every candidate: O(T*U) joins of O(depth * id-length) characters. The allocation, not the substring scan, is what the pass spends its time on. Nothing about breadth-first search made that safe. It only hid the cost by keeping traces short — measured on this repo the walk averaged 4.3 steps before D1 and 9.4 after, which roughly doubles both the number of surviving traces and the length of every key, so the same quadratic that was affordable under BFS is about six times the work under DFS. That is the whole of the slowdown D1 was carrying; the depth-first walk itself is cheaper than the queue it replaced (`pop()` against an O(frontier) `shift()`), and its frontier is bounded by depth rather than by breadth. Hoisting the join into a `uniqueKeys` array removes the multiplication. Measured back to back on one host, 5 reps, 25k callables, production sink path (main -> this branch before -> this branch after): deep_chain 876.8ms -> 1233.1ms -> 101.9ms mixed_cycles 731.4ms -> 1130.6ms -> 132.8ms shallow_wide 572.5ms -> 531.8ms -> 49.6ms and on the real gitnexus/src corpus (11,490 symbols) process detection goes 204ms -> 89ms against main, having been slower than main before. Output is unchanged, which is the property that matters here: swapping the file back and forth and diffing every non-timing field across all sixteen shape x scale x sink-variant configurations gives no difference, and the real corpus returns the same 936 processes / 4,648 steps either way. Sink keys are pushed alongside the traces they belong to, so the comparison set is the same set it always was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj * fix(processes): type the parse-output read as ParseOutput The R3-6 sink read declared its own structural shape for the parse output instead of naming `ParseOutput`, which made it the only one of the five parse consumers in the repo not bound to the real type — cross-file.ts, orm.ts, routes.ts and tools.ts all pass the type argument. `getPhaseOutput` is a raw `as T` cast, so a local shape checks nothing at runtime and only severs the compile-time link: renaming `allFetchCalls` on `ParseOutput` would still compile here and silently detect zero sinks forever. Verified with a real `tsc --noEmit --strict` run over exactly that rename — the typed consumers error, this one did not. The runtime `.filter` stays, since it is the only thing actually guarding the cast. Also brings the phase docblock back in line with the deps array, which was missing `structure` (pre-existing) and `parse` (added by this branch), and records the two parse fields the phase now reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCptYZWRgnnJ821rzebQyj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2038 lines
86 KiB
TypeScript
2038 lines
86 KiB
TypeScript
/**
|
|
* Analyze Command
|
|
*
|
|
* Indexes a repository and stores the knowledge graph in .gitnexus/
|
|
*
|
|
* Delegates core analysis to the shared runFullAnalysis orchestrator.
|
|
* This CLI wrapper handles: heap management, progress bar, SIGINT,
|
|
* skill generation (--skills), summary output, and process.exit().
|
|
*/
|
|
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { spawn } from 'child_process';
|
|
import v8 from 'v8';
|
|
import cliProgress from 'cli-progress';
|
|
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
|
|
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
|
|
import { findUndeclaredRelationPairError } from '../core/lbug/rel-pair-routing.js';
|
|
import { causeChain } from '../lib/utils.js';
|
|
import {
|
|
getOsPageSize,
|
|
isLbugCheckpointIoError,
|
|
isLbugCheckpointBusyError,
|
|
isLbugPageSizeFrameError,
|
|
isPageSizeAwareLadybug,
|
|
isWalCorruptionError,
|
|
parseWalCheckpointThreshold,
|
|
WAL_RECOVERY_SUGGESTION,
|
|
} from '../core/lbug/lbug-config.js';
|
|
import {
|
|
getStoragePaths,
|
|
getGlobalRegistryPath,
|
|
RegistryNameCollisionError,
|
|
AnalysisNotFinalizedError,
|
|
assertAnalysisFinalized,
|
|
type AnalyzerRunnerIdentity,
|
|
} from '../storage/repo-manager.js';
|
|
import {
|
|
getGitRoot,
|
|
hasGitDir,
|
|
getDefaultBranch,
|
|
selfCommitContextFiles,
|
|
snapshotSelfCommitSafety,
|
|
} from '../storage/git.js';
|
|
import { IndexLockTimeoutError } from '../storage/index-lock.js';
|
|
import {
|
|
loadAnalyzeConfig,
|
|
mergeAnalyzeOptions,
|
|
resolveDefaultBranch,
|
|
validateBranchName,
|
|
GitNexusRcError,
|
|
} from './analyze-config.js';
|
|
import { runFullAnalysis } from '../core/run-analyze.js';
|
|
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
|
|
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
|
import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js';
|
|
import { glob } from 'glob';
|
|
import fs from 'fs/promises';
|
|
import { cliError, cliWarn } from './cli-message.js';
|
|
import { heapCapMbFor, memoryAutopilotDisabled } from '../core/ingestion/utils/effective-ram.js';
|
|
import { EMBEDDING_DIMS_ERROR, normalizeEmbeddingDims } from './embedding-dims.js';
|
|
import { formatElapsed } from './format-elapsed.js';
|
|
import { isHfDownloadFailure } from '../core/embeddings/hf-env.js';
|
|
import {
|
|
isHttpEmbeddingDimsError,
|
|
isHttpEmbeddingError,
|
|
isHttpMode,
|
|
safeUrl,
|
|
} from '../core/embeddings/http-client.js';
|
|
import {
|
|
isLocalEmbeddingRuntimeBlockerMessage,
|
|
isMissingLocalEmbeddingStackMessage,
|
|
localEmbeddingPrefixUnloadableMessage,
|
|
localEmbeddingStackMissingMessage,
|
|
} from '../core/embeddings/runtime-support.js';
|
|
import {
|
|
ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS,
|
|
getEmbeddingInstallTimeoutMs,
|
|
getEmbeddingRuntimeDir,
|
|
installEmbeddingRuntime,
|
|
isPrefixRuntimeLoadable,
|
|
resolveEmbeddingRuntime,
|
|
} from '../core/embeddings/runtime-install.js';
|
|
import { warnIfNpm11NpxRisk } from './resolve-invocation.js';
|
|
|
|
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
|
// init, progress bar, console redirection) can monkey-patch it. The
|
|
// fatal handlers below MUST reach the user even when the analyze path
|
|
// has redirected console.* through the progress bar's bar.log() — the
|
|
// previous behaviour silently swallowed stack traces and made #1169
|
|
// indistinguishable from a no-op success on Windows.
|
|
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
|
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
|
|
const writeFatalToStderr = (label: string, err: unknown): void => {
|
|
const isErr = err instanceof Error;
|
|
const message = isErr ? err.message : String(err);
|
|
realStderrWrite(`\n ${label}: ${message}\n`);
|
|
if (isErr && err.stack) realStderrWrite(`${err.stack}\n`);
|
|
// Walk and print the `cause` chain. The phase runner wraps the underlying
|
|
// failure as `new Error("Phase 'X' failed: …", { cause })`, so the original
|
|
// error (e.g. a WorkerPoolDispatchError carrying the worker-side stack from
|
|
// #2068) is only reachable via `.cause`. Without this the user sees the
|
|
// wrapper's main-thread stack and never the real frame. `cause.stack` already
|
|
// begins with the cause's message, so we print the stack alone (not message +
|
|
// stack) to avoid repeating it. `causeChain` owns the traversal and the depth
|
|
// bound that stops a cyclic `cause` looping — this used to be one of four
|
|
// hand-rolled copies that had already drifted apart on both. Uses
|
|
// realStderrWrite so the redirected console.error's ANSI clear-line wrapping
|
|
// can't erase it (#1169). The head is skipped: it was just printed above.
|
|
for (const cause of causeChain(isErr ? (err as { cause?: unknown }).cause : undefined)) {
|
|
realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`);
|
|
}
|
|
};
|
|
|
|
let fatalHandlersInstalled = false;
|
|
|
|
/**
|
|
* Install one-shot `unhandledRejection` / `uncaughtException` handlers
|
|
* that surface the failure to the real stderr (bypassing any console
|
|
* redirection installed by the progress bar) and force a non-zero exit
|
|
* code. Without these, an async error escaping {@link analyzeCommand}'s
|
|
* try/catch was reported as exit 0 with no diagnostic — the silent
|
|
* failure mode tracked in #1169.
|
|
*/
|
|
const installFatalHandlers = (): void => {
|
|
if (fatalHandlersInstalled) return;
|
|
fatalHandlersInstalled = true;
|
|
process.on('unhandledRejection', (err) => {
|
|
writeFatalToStderr('Analysis failed (unhandled rejection)', err);
|
|
process.exit(1);
|
|
});
|
|
process.on('uncaughtException', (err) => {
|
|
writeFatalToStderr('Analysis failed (uncaught exception)', err);
|
|
process.exit(1);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* RAM-aware re-exec heap cap (MB) — the formula itself is single-sourced in
|
|
* `core/ingestion/utils/effective-ram.ts` (`heapCapMbFor`), shared with the
|
|
* server's analyze fork. `constrainedBytes` is the cgroup limit or `null`;
|
|
* it is honored only as a real, smaller-than-physical cap, because
|
|
* `process.constrainedMemory()` returns a huge sentinel when UNCONSTRAINED.
|
|
* (Observed rationale: a cap ≥ RAM made V8 collect lazily and swap-thrash —
|
|
* the #2649 worker-timeout cascade on 16 GB boxes.)
|
|
*/
|
|
export function computeHeapCapMb(totalBytes: number, constrainedBytes: number | null): number {
|
|
const effectiveBytes =
|
|
constrainedBytes !== null && constrainedBytes > 0 && constrainedBytes < totalBytes
|
|
? constrainedBytes
|
|
: totalBytes;
|
|
return heapCapMbFor(effectiveBytes);
|
|
}
|
|
|
|
function readConstrainedBytes(): number | null {
|
|
if (typeof process.constrainedMemory !== 'function') return null;
|
|
const c = process.constrainedMemory();
|
|
return typeof c === 'number' && c > 0 ? c : null;
|
|
}
|
|
|
|
const HEAP_MB = computeHeapCapMb(os.totalmem(), readConstrainedBytes());
|
|
const TEST_RESPAWN_HEAP_MB = Number(process.env.GITNEXUS_TEST_RESPAWN_HEAP_MB);
|
|
const RESPAWN_HEAP_MB =
|
|
Number.isFinite(TEST_RESPAWN_HEAP_MB) && TEST_RESPAWN_HEAP_MB > 0
|
|
? Math.floor(TEST_RESPAWN_HEAP_MB)
|
|
: HEAP_MB;
|
|
const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`;
|
|
/** Larger semi-space (young-gen) cuts minor-GC frequency + promotion churn during
|
|
* the multi-million-node graph build/emit. Allowed in NODE_OPTIONS (unlike
|
|
* --stack-size), so it propagates to the re-exec env cleanly. */
|
|
const SEMI_SPACE_MB = 128;
|
|
const SEMI_FLAG = `--max-semi-space-size=${SEMI_SPACE_MB}`;
|
|
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
|
|
const STACK_KB = 4096;
|
|
const STACK_FLAG = `--stack-size=${STACK_KB}`;
|
|
const RESPAWN_OUTPUT_TAIL_CHARS = 1024 * 1024;
|
|
const RESPAWN_PROGRESS_ENV = 'GITNEXUS_RESPAWN_PROGRESS_TTY';
|
|
|
|
interface CliProgressTerminal {
|
|
cursorSave(): void;
|
|
cursorRestore(): void;
|
|
cursor(enabled: boolean): void;
|
|
lineWrapping(enabled: boolean): void;
|
|
cursorTo(x?: number | null, y?: number | null): void;
|
|
cursorRelative(dx?: number | null, dy?: number | null): void;
|
|
cursorRelativeReset(): void;
|
|
clearRight(): void;
|
|
clearLine(): void;
|
|
clearBottom(): void;
|
|
newline(): void;
|
|
write(s: string, rawWrite?: boolean): void;
|
|
isTTY(): boolean;
|
|
getWidth(): number;
|
|
}
|
|
|
|
const terminalColumns = (): number => {
|
|
const parsed = Number(process.env.COLUMNS);
|
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
|
|
};
|
|
|
|
const ANSI_ESCAPE_PATTERN =
|
|
/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[PX^_][\s\S]*?\x1B\\|[78]|[@-Z\\-_])/y;
|
|
|
|
interface IntlSegmenterLike {
|
|
segment(input: string): Iterable<{ segment: string }>;
|
|
}
|
|
|
|
type IntlWithOptionalSegmenter = typeof Intl & {
|
|
Segmenter?: new (
|
|
locales?: string | string[],
|
|
options?: { granularity?: 'grapheme' },
|
|
) => IntlSegmenterLike;
|
|
};
|
|
|
|
const splitGraphemes = (text: string): string[] => {
|
|
const Segmenter = (Intl as IntlWithOptionalSegmenter).Segmenter;
|
|
if (Segmenter) {
|
|
return Array.from(
|
|
new Segmenter(undefined, { granularity: 'grapheme' }).segment(text),
|
|
(s) => s.segment,
|
|
);
|
|
}
|
|
return Array.from(text);
|
|
};
|
|
|
|
const isZeroWidthCodePoint = (codePoint: number): boolean =>
|
|
codePoint === 0x200d ||
|
|
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
|
|
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
|
|
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
|
|
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
|
|
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
(codePoint >= 0xfe20 && codePoint <= 0xfe2f);
|
|
|
|
const isWideCodePoint = (codePoint: number): boolean =>
|
|
codePoint >= 0x1100 &&
|
|
(codePoint <= 0x115f ||
|
|
codePoint === 0x2329 ||
|
|
codePoint === 0x232a ||
|
|
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
|
|
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
|
|
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
(codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
|
|
(codePoint >= 0x20000 && codePoint <= 0x3fffd));
|
|
|
|
const visibleColumns = (text: string): number => {
|
|
let columns = 0;
|
|
for (const char of Array.from(text)) {
|
|
const codePoint = char.codePointAt(0);
|
|
if (codePoint === undefined || isZeroWidthCodePoint(codePoint)) continue;
|
|
columns += isWideCodePoint(codePoint) ? 2 : 1;
|
|
}
|
|
return columns;
|
|
};
|
|
|
|
const readAnsiEscapeAt = (text: string, index: number): string | undefined => {
|
|
ANSI_ESCAPE_PATTERN.lastIndex = index;
|
|
return ANSI_ESCAPE_PATTERN.exec(text)?.[0];
|
|
};
|
|
|
|
const truncateAnsiToColumns = (text: string, maxColumns: number): string => {
|
|
if (!Number.isFinite(maxColumns) || maxColumns <= 0) return '';
|
|
|
|
let output = '';
|
|
let columns = 0;
|
|
let index = 0;
|
|
|
|
while (index < text.length) {
|
|
const escape = readAnsiEscapeAt(text, index);
|
|
if (escape) {
|
|
output += escape;
|
|
index += escape.length;
|
|
continue;
|
|
}
|
|
|
|
const nextEscapeIndex = text.indexOf('\x1B', index);
|
|
const plainEnd = nextEscapeIndex === -1 ? text.length : nextEscapeIndex;
|
|
const plainText = text.slice(index, plainEnd);
|
|
|
|
for (const segment of splitGraphemes(plainText)) {
|
|
const width = visibleColumns(segment);
|
|
if (width > 0 && columns + width > maxColumns) return output;
|
|
output += segment;
|
|
columns += width;
|
|
}
|
|
|
|
index = plainEnd;
|
|
}
|
|
|
|
return output;
|
|
};
|
|
|
|
const createAnsiPipeTerminal = (stream: NodeJS.WriteStream): CliProgressTerminal => {
|
|
let linewrap = true;
|
|
let dy = 0;
|
|
const write = (s: string): void => {
|
|
stream.write(s);
|
|
};
|
|
const moveVertical = (delta: number): void => {
|
|
if (delta > 0) write(`\x1B[${delta}B`);
|
|
else if (delta < 0) write(`\x1B[${Math.abs(delta)}A`);
|
|
};
|
|
|
|
return {
|
|
cursorSave: () => write('\x1B7'),
|
|
cursorRestore: () => write('\x1B8'),
|
|
cursor: (enabled) => write(enabled ? '\x1B[?25h' : '\x1B[?25l'),
|
|
lineWrapping: (enabled) => {
|
|
linewrap = enabled;
|
|
write(enabled ? '\x1B[?7h' : '\x1B[?7l');
|
|
},
|
|
cursorTo: (x = null, y = null) => {
|
|
if (typeof y === 'number' && typeof x === 'number') {
|
|
write(`\x1B[${y + 1};${x + 1}H`);
|
|
return;
|
|
}
|
|
if (typeof x === 'number') {
|
|
write(x === 0 ? '\r' : `\x1B[${x + 1}G`);
|
|
}
|
|
},
|
|
cursorRelative: (dx = null, nextDy = null) => {
|
|
if (typeof dx === 'number' && dx !== 0) {
|
|
write(dx > 0 ? `\x1B[${dx}C` : `\x1B[${Math.abs(dx)}D`);
|
|
}
|
|
if (typeof nextDy === 'number' && nextDy !== 0) {
|
|
dy += nextDy;
|
|
moveVertical(nextDy);
|
|
}
|
|
},
|
|
cursorRelativeReset: () => {
|
|
moveVertical(-dy);
|
|
write('\r');
|
|
dy = 0;
|
|
},
|
|
clearRight: () => write('\x1B[0K'),
|
|
clearLine: () => write('\x1B[2K'),
|
|
clearBottom: () => write('\x1B[0J'),
|
|
newline: () => {
|
|
write('\n');
|
|
dy++;
|
|
},
|
|
write: (s, rawWrite = false) => {
|
|
const width = terminalColumns();
|
|
write(linewrap && rawWrite === false ? truncateAnsiToColumns(s, width) : s);
|
|
},
|
|
isTTY: () => true,
|
|
getWidth: terminalColumns,
|
|
};
|
|
};
|
|
|
|
const shouldBridgeRespawnProgressTty = (): boolean =>
|
|
process.stderr.isTTY === true || process.stdout.isTTY === true;
|
|
|
|
interface RespawnExit {
|
|
status?: number | null;
|
|
signal?: NodeJS.Signals | null;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
message?: string;
|
|
}
|
|
|
|
const appendOutputTail = (tail: string, chunk: unknown): string => {
|
|
const text = Buffer.isBuffer(chunk)
|
|
? chunk.toString('utf8')
|
|
: typeof chunk === 'string'
|
|
? chunk
|
|
: String(chunk ?? '');
|
|
if (!text) return tail;
|
|
const next = tail + text;
|
|
return next.length > RESPAWN_OUTPUT_TAIL_CHARS ? next.slice(-RESPAWN_OUTPUT_TAIL_CHARS) : next;
|
|
};
|
|
|
|
/**
|
|
* Run the respawned analyzer while teeing child output through to the parent
|
|
* and keeping a bounded tail for crash classification.
|
|
*
|
|
* `execFileSync(..., { stdio: 'inherit' })` preserved live progress but hid
|
|
* stderr/stdout from the parent on abnormal exits. That made every
|
|
* SIGABRT/status-134 child look like an output-less V8 heap OOM, even when the
|
|
* terminal had already shown a native crash such as
|
|
* `libc++abi: ... Napi::Error`. Piped streams plus an explicit tee keeps the UX
|
|
* and gives `childProcessLikelyOom` the evidence it needs.
|
|
*/
|
|
const runRespawnedAnalyze = (
|
|
args: readonly string[],
|
|
env: NodeJS.ProcessEnv,
|
|
): Promise<RespawnExit> =>
|
|
new Promise((resolve) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let settled = false;
|
|
const finish = (exit: RespawnExit): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(exit);
|
|
};
|
|
|
|
const child = spawn(process.execPath, [...args], {
|
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
env,
|
|
});
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout = appendOutputTail(stdout, chunk);
|
|
realStdoutWrite(chunk);
|
|
});
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr = appendOutputTail(stderr, chunk);
|
|
realStderrWrite(chunk);
|
|
});
|
|
child.on('error', (err) => {
|
|
finish({
|
|
status: 1,
|
|
signal: null,
|
|
stdout,
|
|
stderr,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
});
|
|
});
|
|
child.on('close', (status, signal) => {
|
|
finish({
|
|
status,
|
|
signal,
|
|
stdout,
|
|
stderr,
|
|
message: `Command failed: ${process.execPath} ${args.join(' ')}`,
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Heuristic for "child re-exec likely died from V8 OOM".
|
|
*
|
|
* Platform-independent detection is best-effort: V8/Node usually emit stable
|
|
* heap-exhaustion phrases in stderr/message across Linux/macOS/Windows (for
|
|
* example "JavaScript heap out of memory" or "Reached heap limit"). When the
|
|
* child produced no output at all, we still treat status 134/SIGABRT as likely
|
|
* heap OOM. If stderr/stdout contains a native crash diagnostic, the output
|
|
* evidence wins and we do not print heap guidance.
|
|
*/
|
|
const childProcessLikelyOom = (err: unknown): boolean => {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as {
|
|
status?: unknown;
|
|
signal?: unknown;
|
|
stderr?: unknown;
|
|
stdout?: unknown;
|
|
message?: unknown;
|
|
};
|
|
|
|
const hasHeapOomSignature = (v: unknown): boolean => {
|
|
const text = (
|
|
Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : ''
|
|
).toLowerCase();
|
|
if (!text) return false;
|
|
return (
|
|
text.includes('javascript heap out of memory') ||
|
|
text.includes('reached heap limit') ||
|
|
text.includes('allocation failed - javascript heap out of memory') ||
|
|
text.includes('fatalprocessoutofmemory')
|
|
);
|
|
};
|
|
|
|
const fields = [e.message, e.stderr, e.stdout];
|
|
if (fields.some((v) => hasHeapOomSignature(v))) return true;
|
|
|
|
const hasAnyChildOutput = [e.stderr, e.stdout].some(
|
|
(v) => (Buffer.isBuffer(v) && v.length > 0) || (typeof v === 'string' && v.length > 0),
|
|
);
|
|
if (hasAnyChildOutput) return false;
|
|
|
|
return e.status === 134 || e.signal === 'SIGABRT';
|
|
};
|
|
|
|
const childProcessLikelyNativeAbort = (err: unknown): boolean => {
|
|
if (!err || typeof err !== 'object') return false;
|
|
const e = err as {
|
|
stderr?: unknown;
|
|
stdout?: unknown;
|
|
message?: unknown;
|
|
};
|
|
const hasNativeAbortSignature = (v: unknown): boolean => {
|
|
const text = (
|
|
Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : ''
|
|
).toLowerCase();
|
|
if (!text) return false;
|
|
return (
|
|
text.includes('napi::error') ||
|
|
text.includes('libc++abi: terminating') ||
|
|
text.includes('abort trap') ||
|
|
text.includes('native stack') ||
|
|
text.includes('native worker') ||
|
|
text.includes('native binding')
|
|
);
|
|
};
|
|
|
|
return [e.message, e.stderr, e.stdout].some((v) => hasNativeAbortSignature(v));
|
|
};
|
|
|
|
const forceHeapOOMForTestIfEnabled = (): void => {
|
|
if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1') return;
|
|
// Allocate JS strings (not Buffers) so pressure lands on V8 heap itself.
|
|
// Buffers can allocate off-heap, which makes OOM triggering less reliable.
|
|
const chunks: string[] = [];
|
|
for (;;) chunks.push('x'.repeat(1024 * 1024));
|
|
};
|
|
|
|
// 64 MiB keeps auto-checkpoint enabled but triggers less frequently than
|
|
// Ladybug's stock ~16 MiB threshold, reducing rename/remove churn on large
|
|
// runs. Also matches the GitNexus default in `lbug-config.ts`.
|
|
//
|
|
// IMPORTANT: keep README examples (`README.md`, `gitnexus/README.md`) and
|
|
// the `DEFAULT_WAL_CHECKPOINT_THRESHOLD` constant in
|
|
// `gitnexus/src/core/lbug/lbug-config.ts` in sync with this value.
|
|
const RECOMMENDED_WAL_CHECKPOINT_THRESHOLD = 64 * 1024 * 1024;
|
|
|
|
/**
|
|
* Last `--max-old-space-size` value (MB) in a NODE_OPTIONS string, or `null`
|
|
* when absent/unparseable. Last occurrence wins, matching V8's own
|
|
* later-flag-wins semantics when NODE_OPTIONS repeats a flag.
|
|
*/
|
|
export function parseMaxOldSpaceMb(nodeOptions: string): number | null {
|
|
// V8 accepts `-` and `_` interchangeably in flag names, and Node accepts a
|
|
// space-separated value in NODE_OPTIONS — honor every spelling of the pin
|
|
// instead of silently overriding it (#2649 review).
|
|
const matches = [...nodeOptions.matchAll(/--max[-_]old[-_]space[-_]size(?:=|\s+)(\d+)/g)];
|
|
if (matches.length === 0) return null;
|
|
const mb = Number(matches[matches.length - 1][1]);
|
|
return Number.isFinite(mb) && mb > 0 ? mb : null;
|
|
}
|
|
|
|
/** Re-exec the process with the RAM-aware auto heap cap + larger semi-space/stack
|
|
* if we're currently below that.
|
|
*
|
|
* Heap-source precedence (#2649):
|
|
* - an explicit per-invocation `--max-old-space-size` (execArgv) always wins;
|
|
* - `GITNEXUS_MEMORY=off` declines the memory autopilot entirely;
|
|
* - an ambient NODE_OPTIONS heap >= the auto cap is honored as-is;
|
|
* - an ambient NODE_OPTIONS heap BELOW the auto cap is treated as an
|
|
* inherited environment default (devcontainers/CI export one for other
|
|
* tooling), not a deliberate per-run choice: warn and respawn with the
|
|
* auto cap. Pre-#2649 this returned early and large repos then OOM'd on
|
|
* whatever heap the environment happened to specify. */
|
|
async function ensureHeap(): Promise<boolean> {
|
|
// Explicit opt-out disables auto-sizing ENTIRELY — both the ambient-pin
|
|
// override and the default v8-limit respawn — and is honored SILENTLY:
|
|
// the operator already made the call, and stderr-sensitive consumers
|
|
// (test harnesses, scripts, supervisors that track a single PID) rely on
|
|
// a quiet, single-process run.
|
|
if (memoryAutopilotDisabled()) return false;
|
|
const nodeOpts = process.env.NODE_OPTIONS || '';
|
|
if (process.execArgv.some((a) => a.startsWith('--max-old-space-size'))) return false;
|
|
|
|
const ambientHeapMb = parseMaxOldSpaceMb(nodeOpts);
|
|
if (ambientHeapMb !== null) {
|
|
if (ambientHeapMb >= RESPAWN_HEAP_MB) return false;
|
|
cliWarn(
|
|
` NODE_OPTIONS pins the heap to ${ambientHeapMb}MB — below the ${RESPAWN_HEAP_MB}MB this machine's RAM supports.\n` +
|
|
` Re-running analyze with the larger auto-sized cap (set GITNEXUS_MEMORY=off to keep the NODE_OPTIONS value).\n`,
|
|
);
|
|
} else {
|
|
const v8Heap = v8.getHeapStatistics().heap_size_limit;
|
|
if (v8Heap >= HEAP_MB * 1024 * 1024 * 0.9) return false;
|
|
}
|
|
|
|
// --stack-size is a V8 flag not allowed in NODE_OPTIONS on Node 24+, so pass it
|
|
// only as a direct CLI argument. --max-semi-space-size IS allowed in NODE_OPTIONS.
|
|
const cliFlags = [HEAP_FLAG, SEMI_FLAG];
|
|
if (!nodeOpts.includes('--stack-size')) cliFlags.push(STACK_FLAG);
|
|
|
|
// Preserve the parent's node flags (execArgv) — dropping them breaks any
|
|
// loader-launched CLI: `node --import tsx src/cli/index.ts` respawned
|
|
// without `--import tsx` cannot execute TypeScript and dies with a
|
|
// swallowed exit 1 (#2649 review). Our heap/semi/stack flags come AFTER
|
|
// execArgv so V8's later-flag-wins semantics resolve duplicates our way.
|
|
// Inspector flags are the one exception: replaying `--inspect[-brk]` makes
|
|
// the child fight the parent for the debug port and die with EADDRINUSE.
|
|
const preservedExecArgv = process.execArgv.filter((a) => !a.startsWith('--inspect'));
|
|
const childArgs = [...preservedExecArgv, ...cliFlags, ...process.argv.slice(1)];
|
|
const childEnv = {
|
|
...process.env,
|
|
NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG} ${SEMI_FLAG}`.trim(),
|
|
};
|
|
if (shouldBridgeRespawnProgressTty()) childEnv[RESPAWN_PROGRESS_ENV] = '1';
|
|
const childExit = await runRespawnedAnalyze(childArgs, childEnv);
|
|
if (childExit.status !== 0 || childExit.signal) {
|
|
if (childProcessLikelyOom(childExit)) {
|
|
cliError(
|
|
` Analysis likely ran out of memory (heap cap auto-sized to ${RESPAWN_HEAP_MB}MB ≈ 0.75x RAM).\n` +
|
|
` This repository's working set exceeds available RAM. Use a machine with more RAM,\n` +
|
|
` or override the cap (a cap above physical RAM causes swap-thrash — use with care):\n` +
|
|
` NODE_OPTIONS="--max-old-space-size=<MB>" gitnexus analyze [your-args]\n` +
|
|
` (Windows: set NODE_OPTIONS=--max-old-space-size=<MB> && gitnexus analyze [your-args])\n` +
|
|
` If this persists, it may be a native crash unrelated to heap size.\n`,
|
|
{ recoveryHint: 'heap-oom-respawn' },
|
|
);
|
|
} else if (childProcessLikelyNativeAbort(childExit)) {
|
|
cliError(
|
|
` Analysis aborted in a native worker or native binding path.\n` +
|
|
` Try one of these recovery paths:\n` +
|
|
` npm uninstall -g gitnexus && npm install -g gitnexus@latest (rebuilds native bindings)\n` +
|
|
` Use Node 22 LTS if you are on a newer non-LTS runtime.\n`,
|
|
{ recoveryHint: 'native-worker-abort' },
|
|
);
|
|
}
|
|
const status =
|
|
typeof childExit.status === 'number' && childExit.status !== 0 ? childExit.status : 1;
|
|
process.exitCode = status;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* GITNEXUS_* env vars that `analyzeCommand` writes for backward-compatible
|
|
* downstream consumption. Snapshotted at function entry and restored in the
|
|
* finally block so that programmatic callers (tests, long-running hosts)
|
|
* don't see leaked state across invocations. `GITNEXUS_WORKER_POOL_SIZE` is
|
|
* NOT in this list: that knob is threaded through `runFullAnalysis` options
|
|
* (see `workerPoolSize` plumbing) so the CLI never has to mutate `process.env`
|
|
* for it in the first place.
|
|
*/
|
|
const ANALYZE_CLI_ENV_KEYS = [
|
|
'GITNEXUS_VERBOSE',
|
|
'GITNEXUS_PROFILE_DEFERRED',
|
|
'GITNEXUS_PROFILE_DEFERRED_SLOW_MS',
|
|
'GITNEXUS_DEBUG_HEAP',
|
|
'GITNEXUS_MAX_FILE_SIZE',
|
|
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
|
|
'GITNEXUS_WAL_CHECKPOINT_THRESHOLD',
|
|
'GITNEXUS_WAL_MANUAL_CHECKPOINT',
|
|
'GITNEXUS_EMBEDDING_THREADS',
|
|
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
|
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
|
'GITNEXUS_EMBEDDING_DEVICE',
|
|
'GITNEXUS_ANALYZE_PROGRESS_ACTIVE',
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_API_KEY',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
] as const;
|
|
|
|
type AnalyzeEnvSnapshot = Record<(typeof ANALYZE_CLI_ENV_KEYS)[number], string | undefined>;
|
|
|
|
const snapshotAnalyzeEnv = (): AnalyzeEnvSnapshot => {
|
|
const snap = {} as AnalyzeEnvSnapshot;
|
|
for (const k of ANALYZE_CLI_ENV_KEYS) snap[k] = process.env[k];
|
|
return snap;
|
|
};
|
|
|
|
const restoreAnalyzeEnv = (snap: AnalyzeEnvSnapshot): void => {
|
|
for (const k of ANALYZE_CLI_ENV_KEYS) {
|
|
const v = snap[k];
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
};
|
|
|
|
export interface AnalyzeOptions {
|
|
force?: boolean;
|
|
repairFts?: boolean;
|
|
/**
|
|
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
|
* - `undefined` when the flag is omitted
|
|
* - `true` when passed without an argument (use default 50K node cap)
|
|
* - a string when passed with an argument (`--embeddings 0` disables the
|
|
* cap, `--embeddings <n>` uses `<n>` as the cap)
|
|
*/
|
|
embeddings?: boolean | string;
|
|
/**
|
|
* Explicitly drop existing embeddings on rebuild instead of preserving
|
|
* them. Without this flag, a routine `analyze` keeps any embeddings
|
|
* already present in the index even when `--embeddings` is omitted.
|
|
*/
|
|
dropEmbeddings?: boolean;
|
|
skills?: boolean;
|
|
verbose?: boolean;
|
|
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
|
|
skipAgentsMd?: boolean;
|
|
/**
|
|
* Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by
|
|
* default. Threaded to both the worker (CFG build) and scope-resolution
|
|
* (BasicBlock/CFG emit).
|
|
*/
|
|
pdg?: boolean;
|
|
/**
|
|
* Stats inclusion in AGENTS.md and CLAUDE.md.
|
|
*
|
|
* Commander.js represents `--no-stats` as `stats: boolean` (default
|
|
* `true`; `false` when the user passes `--no-stats`), NOT as
|
|
* `noStats: boolean`. Reading the negated form would always be
|
|
* `undefined` and the flag would silently no-op (#1477). Consumers
|
|
* that want "did the user request --no-stats?" should compare with
|
|
* `=== false` to distinguish the explicit-off case from the
|
|
* default-on case.
|
|
*/
|
|
stats?: boolean;
|
|
/**
|
|
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
|
|
* makes. Scoped to only those two files (never `git add -A`); no-ops
|
|
* silently if neither exists, neither changed, or the commit step itself
|
|
* fails (e.g. no git identity configured). See #2639.
|
|
*/
|
|
selfCommit?: boolean;
|
|
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
|
|
skipSkills?: boolean;
|
|
/**
|
|
* Default branch for the generated regression-compare example (#243). From
|
|
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
|
|
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
|
|
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
|
|
*/
|
|
defaultBranch?: string;
|
|
/**
|
|
* Index-branch selector (#2106). From `--branch`. Distinct from
|
|
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
|
|
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
|
|
* alias for `defaultBranch` and must not change index placement. Defaults to
|
|
* the checked-out branch inside `runFullAnalysis` when omitted.
|
|
*/
|
|
branch?: string;
|
|
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
|
|
indexOnly?: boolean;
|
|
/** Index the folder even when no .git directory is present. */
|
|
skipGit?: boolean;
|
|
/**
|
|
* Override the default basename-derived registry `name` with a
|
|
* user-supplied alias (#829). Disambiguates repos whose paths share a
|
|
* basename. Persisted — subsequent re-analyses of the same path without
|
|
* `--name` preserve the alias.
|
|
*/
|
|
name?: string;
|
|
/**
|
|
* Allow registration even when another path already uses the same
|
|
* `--name` alias (#829). Intentionally a distinct flag from `--force`
|
|
* because the user may want to coexist under the same name WITHOUT
|
|
* paying the cost of a pipeline re-index. Maps to registerRepo's
|
|
* `allowDuplicateName` option end-to-end.
|
|
*/
|
|
allowDuplicateName?: boolean;
|
|
/**
|
|
* Override the walker's large-file skip threshold (#991). Value in KB;
|
|
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
|
|
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
|
|
*/
|
|
maxFileSize?: string;
|
|
/** Override worker sub-batch idle timeout in seconds. */
|
|
workerTimeout?: string;
|
|
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
|
|
walCheckpointThreshold?: string;
|
|
/** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */
|
|
workers?: string;
|
|
embeddingThreads?: string;
|
|
embeddingBatchSize?: string;
|
|
embeddingSubBatchSize?: string;
|
|
embeddingDevice?: string;
|
|
/**
|
|
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
|
|
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
|
|
* the routes phase, where the cross-file consumer scan unions them with the
|
|
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
|
|
* outside the built-in convention still produces `route_map` consumers.
|
|
*/
|
|
fetchWrappers?: string[];
|
|
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
|
|
embeddingBaseUrl?: string;
|
|
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
|
|
embeddingModel?: string;
|
|
/** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */
|
|
embeddingAuthToken?: string;
|
|
/** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */
|
|
embeddingDims?: string;
|
|
}
|
|
|
|
/**
|
|
* Whether the post-index skill step should run.
|
|
*
|
|
* The gated block does two things in sequence: (1) generates the community
|
|
* skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
|
|
* AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
|
|
* suppressed together — `--index-only` drops the entire step, not just the
|
|
* community-skill write. Name retained for the test contract; see call site
|
|
* in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
|
|
*
|
|
* Kept as a pure helper so the `--index-only --skills` contract is unit-tested
|
|
* without booting the full analyze pipeline (#742 review).
|
|
*/
|
|
export const shouldGenerateCommunitySkillFiles = (
|
|
options: Pick<AnalyzeOptions, 'skills' | 'indexOnly'> | undefined,
|
|
pipelineResult: unknown,
|
|
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
|
|
|
|
export const analyzeCommand = async (
|
|
inputPath?: string,
|
|
options?: AnalyzeOptions,
|
|
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
|
) => {
|
|
if (await ensureHeap()) return;
|
|
forceHeapOOMForTestIfEnabled();
|
|
|
|
// Install fatal handlers immediately after re-exec resolution so any
|
|
// async error that escapes the try/catch below (#1169) surfaces with
|
|
// a stack trace and a non-zero exit code instead of a silent exit 0.
|
|
installFatalHandlers();
|
|
|
|
// npm-11 npx-crash nudge (#1939). Runs here, after the heap re-exec guard,
|
|
// so it fires once in the working process and never on the lazy-startup path
|
|
// of other commands (e.g. `gitnexus mcp`).
|
|
warnIfNpm11NpxRisk();
|
|
|
|
// Snapshot the GITNEXUS_* env vars that the impl writes for downstream
|
|
// consumption, so they don't leak across `analyzeCommand` invocations in
|
|
// programmatic callers (tests, long-running hosts). `process.exit(0)` on
|
|
// the success path bypasses `finally` — intentional: when the process is
|
|
// exiting, restoration is moot. For early-return paths (validation
|
|
// errors) and the alreadyUpToDate fast path the finally restores the
|
|
// pre-call values.
|
|
const envSnap = snapshotAnalyzeEnv();
|
|
try {
|
|
await analyzeCommandImpl(inputPath, options, runnerIdentityAtBootstrap);
|
|
} finally {
|
|
restoreAnalyzeEnv(envSnap);
|
|
}
|
|
// If analyzeCommandImpl returned via a soft `process.exitCode = 1` error path
|
|
// while LadybugDB native handles are still open, the event loop won't drain and
|
|
// the process would HANG (#2264 review P1). The full analyze paths skip-close the
|
|
// DB — handles are left open and reclaimed by process.exit — so a soft return
|
|
// after a real analyze must force the exit. The success path never reaches here
|
|
// (analyzeCommandImpl calls process.exit(0) itself); early-validation errors and
|
|
// unit tests that mock runFullAnalysis never open the DB, so isLbugReady() is
|
|
// false and the soft return is preserved.
|
|
if (isLbugReady()) {
|
|
process.exit(typeof process.exitCode === 'number' ? process.exitCode : 1);
|
|
}
|
|
};
|
|
|
|
/** Commander entrypoint used only by the capture-before-import lazy bootstrap. */
|
|
export const analyzeCommandWithRunnerIdentity = async (
|
|
runnerIdentityAtBootstrap: AnalyzerRunnerIdentity,
|
|
inputPath?: string,
|
|
options?: AnalyzeOptions,
|
|
): Promise<void> => analyzeCommand(inputPath, options, runnerIdentityAtBootstrap);
|
|
|
|
const analyzeCommandImpl = async (
|
|
inputPath?: string,
|
|
cliOptions?: AnalyzeOptions,
|
|
runnerIdentityAtBootstrap?: AnalyzerRunnerIdentity,
|
|
): Promise<void> => {
|
|
console.log('\n GitNexus Analyzer\n');
|
|
|
|
// ── Resolve the target repo root ──────────────────────────────────
|
|
// Resolved FIRST because `.gitnexusrc` is read from the repo root (not the
|
|
// caller's cwd), and config can set defaults that the validation below
|
|
// consumes. `--skip-git` is a CLI-only flag (never a config key), so the raw
|
|
// CLI options are authoritative for repo-root resolution.
|
|
let repoPath: string;
|
|
if (inputPath) {
|
|
repoPath = path.resolve(inputPath);
|
|
} else if (cliOptions?.skipGit) {
|
|
// --skip-git: treat cwd as the index root, do not walk up to a parent git repo.
|
|
repoPath = path.resolve(process.cwd());
|
|
} else {
|
|
const gitRoot = getGitRoot(process.cwd());
|
|
if (!gitRoot) {
|
|
console.log(
|
|
' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
repoPath = gitRoot;
|
|
}
|
|
|
|
const repoHasGit = hasGitDir(repoPath);
|
|
if (!repoHasGit && !cliOptions?.skipGit) {
|
|
console.log(
|
|
' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (!repoHasGit) {
|
|
console.log(
|
|
' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n',
|
|
);
|
|
}
|
|
|
|
// Validate an explicit `--default-branch` up front so its errors are
|
|
// attributed to the flag (with a CLI-specific recovery hint) rather than to
|
|
// `.gitnexusrc`, which the user may not even have (#1996 tri-review).
|
|
if (cliOptions?.defaultBranch !== undefined) {
|
|
try {
|
|
validateBranchName(cliOptions.defaultBranch, '--default-branch');
|
|
} catch (err) {
|
|
cliError(` ${err instanceof Error ? err.message : String(err)}\n`, {
|
|
recoveryHint: 'default-branch-invalid',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Validate the index-branch selector (#2106) the same way, so a malformed
|
|
// `--branch` exits before any expensive analysis starts. Capture the TRIMMED
|
|
// return so a whitespace-padded value (e.g. " feature" from shell completion)
|
|
// normalizes before the checked-out-branch mismatch guard and slug — otherwise
|
|
// it would false-reject on-branch or create a ghost index when detached.
|
|
if (cliOptions?.branch !== undefined) {
|
|
try {
|
|
cliOptions.branch = validateBranchName(cliOptions.branch, '--branch');
|
|
} catch (err) {
|
|
cliError(` ${err instanceof Error ? err.message : String(err)}\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
|
|
// Parse/validate before the progress bar so a malformed config produces an
|
|
// actionable error and exits before any expensive analysis starts.
|
|
let options: AnalyzeOptions;
|
|
let resolvedDefaultBranch: string;
|
|
try {
|
|
const fileConfig = loadAnalyzeConfig(repoPath);
|
|
options = mergeAnalyzeOptions(cliOptions ?? {}, fileConfig);
|
|
|
|
// Resolve the default branch threaded into generated context:
|
|
// CLI --default-branch > .gitnexusrc defaultBranch/branch
|
|
// > auto-detected origin/HEAD > "main".
|
|
// Only shell out to git when no branch was configured AND the generated
|
|
// context will actually use it, keeping the common path free of an extra
|
|
// git call. Detection is best-effort and never blocks analyze.
|
|
const cliBranch = cliOptions?.defaultBranch;
|
|
const configBranch = fileConfig?.defaultBranch;
|
|
const willGenerateContext = !options.indexOnly && !options.skipAgentsMd;
|
|
let detectedBranch: string | null = null;
|
|
if (
|
|
cliBranch === undefined &&
|
|
configBranch === undefined &&
|
|
repoHasGit &&
|
|
!cliOptions?.skipGit &&
|
|
willGenerateContext
|
|
) {
|
|
try {
|
|
detectedBranch = getDefaultBranch(repoPath);
|
|
} catch {
|
|
detectedBranch = null;
|
|
}
|
|
}
|
|
resolvedDefaultBranch = resolveDefaultBranch({ cliBranch, configBranch, detectedBranch });
|
|
} catch (err) {
|
|
const msg =
|
|
err instanceof GitNexusRcError
|
|
? err.message
|
|
: `Invalid .gitnexusrc: ${err instanceof Error ? err.message : String(err)}`;
|
|
cliError(` ${msg}\n`, { recoveryHint: 'gitnexusrc-invalid' });
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (options.verbose) {
|
|
process.env.GITNEXUS_VERBOSE = '1';
|
|
}
|
|
|
|
if (options.maxFileSize) {
|
|
process.env.GITNEXUS_MAX_FILE_SIZE = options.maxFileSize;
|
|
}
|
|
|
|
if (options.workerTimeout) {
|
|
const workerTimeoutSeconds = Number(options.workerTimeout);
|
|
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
|
|
cliError(' --worker-timeout must be at least 1 second.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(
|
|
Math.round(workerTimeoutSeconds * 1000),
|
|
);
|
|
}
|
|
|
|
if (options.walCheckpointThreshold !== undefined) {
|
|
const parsed = parseWalCheckpointThreshold(options.walCheckpointThreshold);
|
|
if (parsed === undefined) {
|
|
cliError(' --wal-checkpoint-threshold must be an integer >= -1.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.env.GITNEXUS_WAL_CHECKPOINT_THRESHOLD = String(parsed);
|
|
}
|
|
|
|
// `--workers` is threaded through `runFullAnalysis` options → PipelineOptions
|
|
// → createWorkerPool, intentionally bypassing the GITNEXUS_WORKER_POOL_SIZE
|
|
// env channel so this CLI surface never mutates `process.env` for pool size.
|
|
// Tests can therefore re-invoke analyzeCommand with different --workers
|
|
// values back-to-back and observe the value they passed, not whatever the
|
|
// previous call leaked.
|
|
let workerPoolSize: number | undefined;
|
|
if (options.workers !== undefined) {
|
|
const parsedWorkers = Number(options.workers);
|
|
if (!Number.isInteger(parsedWorkers) || parsedWorkers < 1) {
|
|
cliError(
|
|
' --workers must be a positive integer (>= 1). ' +
|
|
'GitNexus parses through a worker pool only — there is no sequential ' +
|
|
'mode, so 0 is not allowed. Omit --workers for an auto-sized pool.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
workerPoolSize = parsedWorkers;
|
|
}
|
|
|
|
// Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
|
|
// (0 disables the cap entirely). Validated up here so failures match the
|
|
// sibling-validation pattern (exit before bar.start() — otherwise
|
|
// process.exit() leaves the progress bar's hidden cursor uncleared).
|
|
let embeddingsNodeLimit: number | undefined;
|
|
if (typeof options.embeddings === 'string') {
|
|
const parsed = Number(options.embeddings);
|
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
cliError(
|
|
` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
|
|
`Pass 0 to disable the safety cap, or omit the value to keep the default.\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
embeddingsNodeLimit = parsed;
|
|
}
|
|
const embeddingsEnabled = !!options.embeddings;
|
|
|
|
const setPositiveEnv = (
|
|
optionName: string,
|
|
envName: string,
|
|
value: string | undefined,
|
|
): boolean => {
|
|
if (value === undefined) return true;
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
cliError(` ${optionName} must be a positive integer.\n`);
|
|
process.exitCode = 1;
|
|
return false;
|
|
}
|
|
process.env[envName] = String(parsed);
|
|
return true;
|
|
};
|
|
|
|
if (
|
|
!setPositiveEnv(
|
|
'--embedding-threads',
|
|
'GITNEXUS_EMBEDDING_THREADS',
|
|
options.embeddingThreads,
|
|
) ||
|
|
!setPositiveEnv(
|
|
'--embedding-batch-size',
|
|
'GITNEXUS_EMBEDDING_BATCH_SIZE',
|
|
options.embeddingBatchSize,
|
|
) ||
|
|
!setPositiveEnv(
|
|
'--embedding-sub-batch-size',
|
|
'GITNEXUS_EMBEDDING_SUB_BATCH_SIZE',
|
|
options.embeddingSubBatchSize,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (options.embeddingDevice) {
|
|
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
|
|
if (!allowed.has(options.embeddingDevice)) {
|
|
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.env.GITNEXUS_EMBEDDING_DEVICE = options.embeddingDevice;
|
|
}
|
|
|
|
// --- Custom HTTP embedding endpoint flags (override GITNEXUS_EMBEDDING_* env vars) ---
|
|
const anyHttpEmbedFlag =
|
|
options.embeddingBaseUrl !== undefined ||
|
|
options.embeddingModel !== undefined ||
|
|
options.embeddingAuthToken !== undefined ||
|
|
options.embeddingDims !== undefined;
|
|
|
|
if (options.embeddingBaseUrl !== undefined) {
|
|
const url = options.embeddingBaseUrl.trim();
|
|
if (url.length === 0) {
|
|
cliError(' --embedding-base-url must not be empty.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
cliError(` --embedding-base-url is not a valid URL: "${url}".\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
cliError(' --embedding-base-url must use http:// or https://.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// http-client strips trailing slashes; store as given (trimmed).
|
|
process.env.GITNEXUS_EMBEDDING_URL = url;
|
|
}
|
|
|
|
if (options.embeddingModel !== undefined) {
|
|
const model = options.embeddingModel.trim();
|
|
if (model.length === 0) {
|
|
cliError(' --embedding-model must not be empty.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = model;
|
|
}
|
|
|
|
if (options.embeddingAuthToken !== undefined) {
|
|
const token = options.embeddingAuthToken.trim();
|
|
if (token.length === 0) {
|
|
cliError(' --embedding-auth-token must not be empty.\n');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// Never log the token value.
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = token;
|
|
}
|
|
|
|
// Validate + normalize dims through the same shared helper the preAction
|
|
// hook uses, so the CLI path, this direct/programmatic-call path, schema.ts
|
|
// (parseInt) and http-client (/^\d+$/) all agree on one canonical value.
|
|
if (options.embeddingDims !== undefined) {
|
|
const dims = normalizeEmbeddingDims(options.embeddingDims);
|
|
if (dims === null) {
|
|
cliError(` ${EMBEDDING_DIMS_ERROR}\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = dims;
|
|
}
|
|
|
|
// Custom-endpoint UX, emitting at most ONE message that reflects THIS run's
|
|
// intent (not ambient env). Order matters — the first matching branch wins:
|
|
// 1. flags given but --embeddings absent: the endpoint won't be used, so
|
|
// say only that (no contradictory "Using…" line).
|
|
// 2. embeddings enabled + a complete endpoint (flags or env): confirm it,
|
|
// masking the URL via safeUrl() since a base URL may carry credentials
|
|
// in userinfo (http://user:pass@host) or a query token (?api_key=…)
|
|
// that must not land in stdout/CI logs. The auth token is never printed.
|
|
// 3. embeddings enabled but only one of URL/MODEL supplied via flags:
|
|
// http-client.isHttpMode() needs BOTH, so warn about the fallback.
|
|
// Gating on embeddingsEnabled also stops the old behaviour of printing
|
|
// "Using custom embedding endpoint" on every analyze run whenever the env
|
|
// vars happened to be set.
|
|
if (anyHttpEmbedFlag && !embeddingsEnabled) {
|
|
console.log(
|
|
' Note: --embedding-* flags only apply when --embeddings is also passed; ' +
|
|
'no embeddings will be generated this run.\n',
|
|
);
|
|
} else if (
|
|
embeddingsEnabled &&
|
|
process.env.GITNEXUS_EMBEDDING_URL &&
|
|
process.env.GITNEXUS_EMBEDDING_MODEL
|
|
) {
|
|
console.log(
|
|
` Using custom embedding endpoint: ${safeUrl(process.env.GITNEXUS_EMBEDDING_URL)} ` +
|
|
`(model: ${process.env.GITNEXUS_EMBEDDING_MODEL})\n`,
|
|
);
|
|
} else if (
|
|
embeddingsEnabled &&
|
|
anyHttpEmbedFlag &&
|
|
(process.env.GITNEXUS_EMBEDDING_URL || process.env.GITNEXUS_EMBEDDING_MODEL)
|
|
) {
|
|
console.log(
|
|
' Note: custom HTTP embeddings require BOTH --embedding-base-url and --embedding-model ' +
|
|
'(or the matching env vars). Falling back to local ONNX embeddings.\n',
|
|
);
|
|
}
|
|
|
|
// On-demand embedding runtime (#2370): when the optional stack was pruned at
|
|
// install time (proxy-blocked NuGet download in onnxruntime-node's
|
|
// postinstall), heal it here instead of failing later in the pipeline. The
|
|
// install goes through the user's npm registry config (mirrors/proxies
|
|
// apply) with --ignore-scripts, so no NuGet download is attempted. Runs
|
|
// before bar.start() like the sibling validations above.
|
|
if (embeddingsEnabled && !isHttpMode()) {
|
|
const resolved = resolveEmbeddingRuntime();
|
|
// Resolved-but-unloadable (a populated prefix on a Node with no
|
|
// module.registerHooks), or nothing installed on such a Node: fail fast with
|
|
// capability guidance instead of dying mid-pipeline over an unusable prefix
|
|
// or downloading a runtime the loader can't reach. A package-sourced stack
|
|
// never needs the hook, so it is excluded. --embeddings was explicitly
|
|
// requested and this failure is deterministic, so fail fast rather than
|
|
// silently degrading to BM25 (distinct from a transient install timeout).
|
|
if (!isPrefixRuntimeLoadable() && (resolved === null || resolved.source === 'runtime-prefix')) {
|
|
cliError(` ${localEmbeddingPrefixUnloadableMessage().replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'local-embedding-stack-missing',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
// On-demand embedding runtime (#2370): when the optional stack was pruned at
|
|
// install time (proxy-blocked NuGet download in onnxruntime-node's
|
|
// postinstall), heal it here instead of failing later in the pipeline. The
|
|
// install goes through the user's npm registry config (mirrors/proxies
|
|
// apply) with --ignore-scripts, so no NuGet download is attempted.
|
|
if (resolved === null) {
|
|
console.log(
|
|
` Local embedding runtime is not installed (optional packages were skipped at install time).\n` +
|
|
` Downloading it now from your npm registry into ${getEmbeddingRuntimeDir()} …\n` +
|
|
` (one-time; rerun manually anytime with \`gitnexus embeddings install\`)\n`,
|
|
);
|
|
try {
|
|
// Short deadline (env override still wins): analyze is interactive, so a
|
|
// blackholed proxy must not stall the whole index run for the 10-minute
|
|
// default — fail over to the guidance below instead.
|
|
await installEmbeddingRuntime(
|
|
{},
|
|
getEmbeddingInstallTimeoutMs(ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS),
|
|
);
|
|
console.log(' Embedding runtime installed.\n');
|
|
} catch (err) {
|
|
cliError(
|
|
` Could not install the embedding runtime: ${err instanceof Error ? err.message : String(err)}\n\n` +
|
|
` ${localEmbeddingStackMissingMessage().replace(/\n/g, '\n ')}\n`,
|
|
{ recoveryHint: 'local-embedding-stack-missing' },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.repairFts && options.force) {
|
|
cliError(
|
|
' Cannot combine `--repair-fts` with `--force`. ' +
|
|
'Use `--repair-fts` for fast FTS-only repair, or `--force` for a full rebuild.\n',
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// `--index-only` is the stronger contract — it suppresses every form of file
|
|
// injection, including community skill writes that `--skills` would normally
|
|
// produce. Surface the override explicitly so users don't wonder why a
|
|
// pipeline re-index ran but no skill files appeared. The pipeline still
|
|
// re-runs (see `force: options.force || options.skills` below); the warning
|
|
// is purely about the dropped post-index write step.
|
|
if (options.indexOnly && options.skills) {
|
|
console.log(
|
|
' Note: --index-only overrides --skills; community skill files will not be written.\n',
|
|
);
|
|
}
|
|
|
|
// If the target repo contains files an optional grammar would parse but
|
|
// that grammar's native binding is absent (or disabled via
|
|
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS), warn before analysis so users learn why
|
|
// those files end up unparsed instead of silently getting a degraded index.
|
|
// The extension set is derived from OPTIONAL_GRAMMARS so it can't drift.
|
|
try {
|
|
const optionalGlobs = getOptionalGrammarExtensions().map((e) => `**/*${e}`);
|
|
const matches = await glob(optionalGlobs, {
|
|
cwd: repoPath,
|
|
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
|
dot: false,
|
|
nodir: true,
|
|
absolute: false,
|
|
});
|
|
if (matches.length > 0) {
|
|
const present = new Set<string>();
|
|
for (const m of matches) {
|
|
const ext = path.extname(m).toLowerCase();
|
|
if (ext) present.add(ext);
|
|
}
|
|
warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
|
|
}
|
|
} catch {
|
|
// Best-effort warning \u2014 never block analyze on the precheck.
|
|
}
|
|
|
|
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
|
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
|
|
|
if (process.env.GITNEXUS_NO_GITIGNORE) {
|
|
console.log(
|
|
' GITNEXUS_NO_GITIGNORE is set — skipping .gitignore (still reading .gitnexusignore)\n',
|
|
);
|
|
}
|
|
|
|
const maxFileSizeBanner = getMaxFileSizeBannerMessage();
|
|
if (maxFileSizeBanner) {
|
|
console.log(`${maxFileSizeBanner}\n`);
|
|
}
|
|
|
|
// ── CLI progress bar setup ─────────────────────────────────────────
|
|
const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = {
|
|
format: ' {bar} {percentage}% | {phase}',
|
|
barCompleteChar: '\u2588',
|
|
barIncompleteChar: '\u2591',
|
|
hideCursor: true,
|
|
barGlue: '',
|
|
autopadding: true,
|
|
clearOnComplete: false,
|
|
stopOnComplete: false,
|
|
};
|
|
if (process.env[RESPAWN_PROGRESS_ENV] === '1' && process.stderr.isTTY !== true) {
|
|
// Heap respawn pipes stderr so the parent can classify native/OOM crashes.
|
|
// The parent was a real TTY when it opted into this env var, so forward
|
|
// ANSI cursor controls through the pipe instead of cli-progress' non-TTY
|
|
// newline mode. That keeps one-line redraw UX while retaining stderr tail
|
|
// capture for diagnostics.
|
|
barOptions.terminal = createAnsiPipeTerminal(process.stderr);
|
|
}
|
|
const bar = new cliProgress.SingleBar(barOptions, cliProgress.Presets.shades_grey);
|
|
|
|
bar.start(100, 0, { phase: 'Initializing...' });
|
|
|
|
// Graceful SIGINT handling. Pino's default destination is `sync: false`
|
|
// (buffered) — flush before exit so in-flight records reach stderr.
|
|
// See `gitnexus/src/core/logger.ts:flushLoggerSync`.
|
|
let aborted = false;
|
|
const sigintHandler = () => {
|
|
if (aborted) process.exit(1);
|
|
aborted = true;
|
|
bar.stop();
|
|
console.log('\n Interrupted — cleaning up...');
|
|
// Bounded CHECKPOINT-then-exit (#2264 review P3): skip the native close (the
|
|
// LadybugDB destructor can double-free after --pdg writes), but don't hang
|
|
// behind a long --pdg COPY holding the connection lock — bound it so a single
|
|
// Ctrl-C stays responsive; the WAL replays on the next analyze. A second
|
|
// Ctrl-C (`if (aborted) process.exit(1)` above) remains the escape hatch.
|
|
void boundedCheckpointBeforeExit({
|
|
exitCode: 130,
|
|
beforeExit: async () => {
|
|
const { flushLoggerSync } = await import('../core/logger.js');
|
|
flushLoggerSync();
|
|
},
|
|
});
|
|
};
|
|
process.on('SIGINT', sigintHandler);
|
|
|
|
// Route console output through bar.log() to prevent progress bar corruption.
|
|
// This is a deliberate UI pattern (not a logging concern): analyze runs a
|
|
// long-lived progress bar on stdout; any concurrent console.* write would
|
|
// overwrite the bar mid-render. We capture originals, swap to barLog for
|
|
// the lifetime of the run, and restore on completion/error/SIGINT.
|
|
const origLog = console.log.bind(console);
|
|
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
|
const origWarn = console.warn.bind(console);
|
|
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
|
const origError = console.error.bind(console);
|
|
let barCurrentValue = 0;
|
|
const barLog = (...args: unknown[]) => {
|
|
process.stdout.write('\x1b[2K\r');
|
|
origLog(args.map((a) => (typeof a === 'string' ? a : String(a))).join(' '));
|
|
bar.update(barCurrentValue);
|
|
};
|
|
console.log = barLog;
|
|
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
|
console.warn = barLog;
|
|
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
|
|
console.error = barLog;
|
|
process.env.GITNEXUS_ANALYZE_PROGRESS_ACTIVE = '1';
|
|
|
|
// Track elapsed time per phase
|
|
let lastPhaseLabel = 'Initializing...';
|
|
let phaseStart = Date.now();
|
|
|
|
const updateBar = (value: number, phaseLabel: string) => {
|
|
barCurrentValue = value;
|
|
if (phaseLabel !== lastPhaseLabel) {
|
|
lastPhaseLabel = phaseLabel;
|
|
phaseStart = Date.now();
|
|
}
|
|
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
|
const display = elapsed >= 3 ? `${phaseLabel} (${formatElapsed(elapsed)})` : phaseLabel;
|
|
bar.update(value, { phase: display });
|
|
};
|
|
|
|
const elapsedTimer = setInterval(() => {
|
|
const elapsed = Math.round((Date.now() - phaseStart) / 1000);
|
|
if (elapsed >= 3) {
|
|
bar.update({ phase: `${lastPhaseLabel} (${formatElapsed(elapsed)})` });
|
|
}
|
|
}, 1000);
|
|
|
|
const t0 = Date.now();
|
|
|
|
// ── Run shared analysis orchestrator ───────────────────────────────
|
|
try {
|
|
const skipAll = options.indexOnly;
|
|
const skipAgentsMd = skipAll || options.skipAgentsMd;
|
|
const skipSkills = skipAll || options.skipSkills;
|
|
const runOptions = {
|
|
// Pipeline re-index — OR'd with --skills because skill generation
|
|
// needs a fresh pipelineResult. Has no bearing on the registry
|
|
// collision guard (see allowDuplicateName below).
|
|
force: options.force || options.skills,
|
|
repairFts: options.repairFts,
|
|
embeddings: embeddingsEnabled,
|
|
embeddingsNodeLimit,
|
|
dropEmbeddings: options.dropEmbeddings,
|
|
verbose: options.verbose,
|
|
skipGit: options.skipGit,
|
|
skipAgentsMd,
|
|
skipSkills,
|
|
// CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream.
|
|
pdg: options.pdg === true,
|
|
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
|
|
// threaded into the generated regression-compare example (#243).
|
|
defaultBranch: resolvedDefaultBranch,
|
|
// Index-branch selector (#2106). Read straight from the CLI flag (not
|
|
// the .gitnexusrc-merged options) so the cosmetic defaultBranch config
|
|
// can never change index placement. Undefined → auto-detect in pipeline.
|
|
branch: cliOptions?.branch,
|
|
// commander.js `.option('--no-stats', …)` registers the flag as
|
|
// `options.stats` (boolean, default true; `false` when the user
|
|
// passed --no-stats). Reading `options.noStats` here returns
|
|
// undefined every time, so the flag was a no-op on the markdown
|
|
// rewrite path before this fix. See #1477.
|
|
noStats: options.stats === false,
|
|
registryName: options.name,
|
|
// Registry-collision bypass — its own CLI flag, intentionally NOT
|
|
// overloading --force. A user who hits the collision guard should
|
|
// be able to accept the duplicate name without also paying the
|
|
// cost of a full pipeline re-index. See #829 review round 2.
|
|
allowDuplicateName: options.allowDuplicateName,
|
|
// Worker pool size threaded from --workers, replacing the previous
|
|
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
|
|
// env / auto-formula fallback inside the pipeline.
|
|
workerPoolSize,
|
|
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
|
|
// forwarded to the routes phase consumer scan.
|
|
fetchWrappers: options.fetchWrappers,
|
|
// The CLI always process.exit()s after this returns (success path at the
|
|
// end of analyzeCommandImpl, error/interrupt paths via process.exit too),
|
|
// so the finalize close skips the native conn/db close — it can double-free
|
|
// in LadybugDB's ClientContext destructor after --pdg writes (#2264). The
|
|
// CHECKPOINT keeps the index durable; process exit reclaims the handles.
|
|
skipNativeCloseOnExit: true,
|
|
};
|
|
const runCallbacks = {
|
|
onProgress: (_phase, percent, message) => {
|
|
updateBar(percent, message);
|
|
},
|
|
onLog: barLog,
|
|
};
|
|
const bootstrapArgs: [] | [AnalyzerRunnerIdentity] = runnerIdentityAtBootstrap
|
|
? [runnerIdentityAtBootstrap]
|
|
: [];
|
|
// #2639 review round 2: snapshot which of AGENTS.md/CLAUDE.md are safe to
|
|
// auto-commit BEFORE runFullAnalysis (and the --skills regeneration
|
|
// further down) writes to them, so selfCommitContextFiles can tell a
|
|
// pre-existing unstaged user edit apart from this run's stats refresh
|
|
// and refuse to sweep the former into the latter's commit.
|
|
const selfCommitSafety =
|
|
options.selfCommit === true
|
|
? snapshotSelfCommitSafety(repoPath, ['AGENTS.md', 'CLAUDE.md'])
|
|
: undefined;
|
|
const result = await runFullAnalysis(repoPath, runOptions, runCallbacks, ...bootstrapArgs);
|
|
|
|
if (result.alreadyUpToDate) {
|
|
// Even the fast path must prove the repo is discoverable. A prior
|
|
// run can write meta.json and then fail before registerRepo(); in
|
|
// that half-finalized state, runFullAnalysis returns alreadyUpToDate
|
|
// on the next invocation unless we check the registry here too.
|
|
await assertAnalysisFinalized(repoPath);
|
|
// The fast path skips context regeneration, but a changed `.gitnexusrc`
|
|
// defaultBranch / `--default-branch` must still take effect. Surgically
|
|
// refresh just the `base_ref` line in AGENTS.md/CLAUDE.md in place,
|
|
// preserving the rest of the block (incl. --skills community rows). No-op
|
|
// when the value already matches, so a routine up-to-date run is silent
|
|
// (#1996 tri-review P2).
|
|
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the flat
|
|
// WORKSPACE index (#2106 R2, #2354). A pinned --branch sub-index's
|
|
// up-to-date analyze must not churn the committed AGENTS.md — this
|
|
// mirrors the in-pipeline `if (!placement.branch)` gate around
|
|
// generateAIContextFiles.
|
|
let baseRefRefreshed: string[] = [];
|
|
if (result.isPrimaryBranch !== false) {
|
|
try {
|
|
const { refreshBaseRefLine } = await import('./ai-context.js');
|
|
baseRefRefreshed = (
|
|
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
|
|
).files;
|
|
} catch {
|
|
/* best-effort — never fail the fast path over a context refresh */
|
|
}
|
|
}
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
console.log = origLog;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.warn = origWarn;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.error = origError;
|
|
bar.stop();
|
|
console.log(' Already up to date\n');
|
|
if (baseRefRefreshed.length > 0) {
|
|
console.log(
|
|
` Updated base_ref to "${resolvedDefaultBranch}" in ${baseRefRefreshed.join(', ')}\n`,
|
|
);
|
|
}
|
|
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn from this
|
|
// fast path (e.g. a base_ref refresh above). Best-effort — never throws.
|
|
if (options.selfCommit === true && selfCommitSafety) {
|
|
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
|
}
|
|
// Safe to return without process.exit(0) — the early-return path in
|
|
// runFullAnalysis never opens LadybugDB, so no native handles prevent exit.
|
|
return;
|
|
}
|
|
|
|
if (result.ftsRepairedOnly) {
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
console.log = origLog;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.warn = origWarn;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.error = origError;
|
|
bar.stop();
|
|
console.log(' FTS indexes repaired successfully\n');
|
|
return;
|
|
}
|
|
|
|
// Post-finalize invariant (#1169): runFullAnalysis nominally writes
|
|
// meta.json and registers the repo, but on Windows it has been
|
|
// observed to return successfully with neither artifact present
|
|
// (banner-only output, exit 0). Verify both before declaring
|
|
// success so the silent-finalize state surfaces with a non-zero
|
|
// exit code and an actionable error instead of being mistaken for
|
|
// a healthy index.
|
|
await assertAnalysisFinalized(repoPath);
|
|
|
|
// Skill generation (CLI-only, uses pipeline result from analysis).
|
|
// Gated so `--index-only --skills` skips community skill writes too
|
|
// (`shouldGenerateCommunitySkillFiles` — see unit test).
|
|
if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
|
|
updateBar(99, 'Generating skill files...');
|
|
try {
|
|
const { generateSkillFiles } = await import('./skill-gen.js');
|
|
const { generateAIContextFiles } = await import('./ai-context.js');
|
|
const skillResult = await generateSkillFiles(
|
|
repoPath,
|
|
result.repoName,
|
|
result.pipelineResult,
|
|
);
|
|
if (skillResult.skills.length > 0) {
|
|
barLog(` Generated ${skillResult.skills.length} skill files`);
|
|
// Re-generate AI context files now that we have skill info
|
|
const s = result.stats;
|
|
const communityResult = result.pipelineResult?.communityResult;
|
|
let aggregatedClusterCount = 0;
|
|
if (communityResult?.communities) {
|
|
const groups = new Map<string, number>();
|
|
for (const c of communityResult.communities) {
|
|
const label = c.heuristicLabel || c.label || 'Unknown';
|
|
groups.set(label, (groups.get(label) || 0) + c.symbolCount);
|
|
}
|
|
aggregatedClusterCount = Array.from(groups.values()).filter(
|
|
(count: number) => count >= 5,
|
|
).length;
|
|
}
|
|
const { storagePath: sp } = getStoragePaths(repoPath);
|
|
await generateAIContextFiles(
|
|
repoPath,
|
|
sp,
|
|
result.repoName,
|
|
{
|
|
files: s.files ?? 0,
|
|
nodes: s.nodes ?? 0,
|
|
edges: s.edges ?? 0,
|
|
communities: s.communities,
|
|
clusters: aggregatedClusterCount,
|
|
processes: s.processes,
|
|
},
|
|
skillResult.skills,
|
|
{
|
|
skipAgentsMd,
|
|
skipSkills,
|
|
// Same resolved branch as the main run (#243) so the --skills
|
|
// re-generation of AGENTS.md/CLAUDE.md does not revert base_ref
|
|
// to "main".
|
|
defaultBranch: resolvedDefaultBranch,
|
|
// Mirror runFullAnalysis `noStats` bridge (#1477) — same expression;
|
|
// exercised on the `--skills` path by analyze-no-stats-bridge.test.ts.
|
|
noStats: options.stats === false,
|
|
hasPdg: options.pdg === true,
|
|
},
|
|
);
|
|
}
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
}
|
|
|
|
// #2639: opt-in self-commit of any AGENTS.md/CLAUDE.md churn written by
|
|
// this run (the primary generateAIContextFiles call inside
|
|
// runFullAnalysis, and/or the --skills regeneration above). Best-effort
|
|
// — never throws, so a missing git identity etc. can't fail `analyze`.
|
|
if (options.selfCommit === true && selfCommitSafety) {
|
|
selfCommitContextFiles(repoPath, ['AGENTS.md', 'CLAUDE.md'], selfCommitSafety);
|
|
}
|
|
|
|
const totalTime = ((Date.now() - t0) / 1000).toFixed(1);
|
|
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
|
|
console.log = origLog;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.warn = origWarn;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.error = origError;
|
|
|
|
bar.update(100, { phase: 'Done' });
|
|
bar.stop();
|
|
|
|
// ── Summary ────────────────────────────────────────────────────
|
|
const s = result.stats;
|
|
// A collapsed graph write is NOT a successful index. The other incomplete
|
|
// reasons (`incremental-in-progress`, `embedding-checkpoint-pending`)
|
|
// describe a run that did what it said and left work for next time; this
|
|
// one means most of your edges are gone, so every query answers a confident
|
|
// empty and the exit code is the only thing automation reads. Printing
|
|
// "indexed successfully" and exiting 0 here would be the same class of
|
|
// false certainty the check itself was written to remove.
|
|
if (result.graphWriteCollapsed) {
|
|
const { expected, persisted } = result.graphWriteCollapsed;
|
|
console.log(`\n Repository indexed INCOMPLETELY (${totalTime}s)\n`);
|
|
console.log(
|
|
` Graph write collapsed: the pipeline produced ${expected.toLocaleString()} relationships\n` +
|
|
` but only ${persisted.toLocaleString()} are readable from the index. Queries will answer\n` +
|
|
` with missing edges rather than an error.\n\n` +
|
|
` The index is recorded INCOMPLETE (graph-write-collapsed). Re-run\n` +
|
|
` \`gitnexus analyze --force\`; if it recurs, check disk space and run \`gitnexus doctor\`.`,
|
|
);
|
|
console.log(` ${repoPath}`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(`\n Repository indexed successfully (${totalTime}s)\n`);
|
|
console.log(
|
|
` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
|
|
);
|
|
console.log(` ${repoPath}`);
|
|
|
|
// Persistent (non-scrolling) warning when FTS indexing was skipped — the
|
|
// progress-bar log() that fired mid-run has already scrolled away, so the
|
|
// degraded-search state must also appear in the final summary (#1161).
|
|
if (result.ftsSkipped) {
|
|
// #2658 review L2: a build/verify failure is NOT an extension-unavailable
|
|
// problem — sending the user to install the extension is the wrong remedy.
|
|
if (result.ftsSkipReason === 'build-failed') {
|
|
console.log(
|
|
`\n Warning: full-text/BM25 search is disabled — the search index build failed this run.\n` +
|
|
` The FTS extension is available; rerun \`gitnexus analyze --repair-fts\`. If it persists,\n` +
|
|
` check the disk for space or corruption. Run \`gitnexus doctor\` for details.`,
|
|
);
|
|
} else {
|
|
console.log(
|
|
// NOT "then rerun" (#2841 §5.C): this run stamped `lastCommit`, so a
|
|
// plain rerun on an unchanged tree takes the up-to-date fast path and
|
|
// returns before Phase 3 could rebuild anything — the advice would be
|
|
// ineffective exactly when the user follows it. `--repair-fts` is the
|
|
// verb that rebuilds the search indexes without re-parsing the repo.
|
|
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
|
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto), then run\n` +
|
|
` \`gitnexus analyze --repair-fts\` to build the search indexes. Run \`gitnexus doctor\` for details.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await fs.access(getGlobalRegistryPath());
|
|
} catch {
|
|
console.log('\n Tip: Run `gitnexus setup` to configure MCP for your editor.');
|
|
}
|
|
|
|
console.log('');
|
|
} catch (err: unknown) {
|
|
clearInterval(elapsedTimer);
|
|
process.removeListener('SIGINT', sigintHandler);
|
|
console.log = origLog;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.warn = origWarn;
|
|
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
|
|
console.error = origError;
|
|
bar.stop();
|
|
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
// Registry name-collision from --name (#829) — surface as an
|
|
// actionable error rather than a generic stack-trace.
|
|
if (err instanceof RegistryNameCollisionError) {
|
|
cliError(
|
|
`\n Registry name collision:\n` +
|
|
` "${err.registryName}" is already used by "${err.existingPath}".\n\n` +
|
|
` Options:\n` +
|
|
` • Pick a different alias: gitnexus analyze --name <alias>\n` +
|
|
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)\n`,
|
|
{ registryName: err.registryName, existingPath: err.existingPath },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Another analyze held the index lock past the configured wait ceiling
|
|
// (#2658, GITNEXUS_INDEX_LOCK_TIMEOUT_MS). The on-disk index is being
|
|
// refreshed by the holder — this is a clean, expected condition, not a
|
|
// crash, so render the message without a stack trace.
|
|
if (err instanceof IndexLockTimeoutError) {
|
|
cliError(
|
|
` Another gitnexus analyze (pid ${err.holder.pid} on ${err.holder.hostname}) is ` +
|
|
`already refreshing this index and did not finish within the wait window.\n` +
|
|
` The on-disk index is being updated by that run. Retry later, or raise\n` +
|
|
` GITNEXUS_INDEX_LOCK_TIMEOUT_MS to wait longer.\n`,
|
|
{ recoveryHint: 'index-lock-timeout', holderPid: err.holder.pid },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Finalize invariant failure (#1169) — keep the rich actionable
|
|
// message intact and write through realStderrWrite so it can't be
|
|
// erased by a leftover bar refresh on slow terminals.
|
|
if (err instanceof AnalysisNotFinalizedError) {
|
|
writeFatalToStderr('Analysis did not finalize', err);
|
|
realStderrWrite(
|
|
`\n Diagnostic checklist:\n` +
|
|
` 1. Re-run "gitnexus analyze" - transient native errors often clear on retry.\n` +
|
|
` 2. Inspect ${err.storagePath} - a leftover lbug.wal indicates an aborted write.\n` +
|
|
` 3. If the failure persists, run with NODE_OPTIONS="--max-old-space-size=8192 --trace-exit"\n` +
|
|
` and attach the trace to the GitNexus issue tracker.\n\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// An extracted edge whose FROM→TO label pair is missing from GitNexus's own
|
|
// relation DDL (#2789). `assertDeclaredPair` aborts the run rather than let
|
|
// the bulk COPY fail late and silently drop the edge, so the user sees a
|
|
// mid-run crash inside GitNexus internals with nothing to act on. Name the
|
|
// pair, the relationship and the file that produced it, and say plainly that
|
|
// a re-run cannot help — this is deterministic for the same input.
|
|
// Checked by TYPE (repo norm, #2385) BEFORE the message-text heuristics
|
|
// below, and through the `cause` chain because the ingestion phase runner
|
|
// rewraps every phase failure as `Phase 'X' failed: …`.
|
|
const undeclaredPair = findUndeclaredRelationPairError(err);
|
|
if (undeclaredPair !== undefined) {
|
|
// Render the error's OWN message indented — same idiom as the
|
|
// `LbugWipeError` and page-size branches below. `UndeclaredRelationPairError`
|
|
// builds a fully self-contained message (pair, relationship type, both node
|
|
// ids, source file, issue URL, `.gitnexusignore` workaround) precisely
|
|
// because `gitnexus serve` forwards only `err.message` over worker IPC.
|
|
// Re-rendering those fields here would be a second copy of one string, free
|
|
// to drift from the first — and the actionable half would reach CLI users
|
|
// only. `undeclaredPair.message`, not the outer `msg`: the real error may be
|
|
// several `cause` levels below the phase wrapper `msg` came from.
|
|
cliError(` ${undeclaredPair.message.replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'undeclared-relation-pair',
|
|
labelPair: undeclaredPair.pairKey,
|
|
relationType: undeclaredPair.relationType,
|
|
sourceFile: undeclaredPair.sourceFile,
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// WAL corruption — the index file is unreadable. Give a clear recovery
|
|
// path without a confusing stack trace (the native error message alone
|
|
// is enough signal).
|
|
if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) {
|
|
cliError(
|
|
` The GitNexus index has a corrupted WAL file.\n` +
|
|
` This usually happens when a previous analysis was interrupted mid-write.\n` +
|
|
` ${WAL_RECOVERY_SUGGESTION}\n`,
|
|
{ recoveryHint: 'wal-corruption' },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (isLbugCheckpointIoError(err)) {
|
|
// #2599: when the checkpoint IO error also looks busy/locked, another
|
|
// handle holds the store open — name that actionable cause alongside the
|
|
// threshold hint (the original error is preserved so the hint still fires).
|
|
const heldOpen = isLbugCheckpointBusyError(err)
|
|
? ` Another process may hold the store open (a running \`gitnexus mcp\` server, or a\n` +
|
|
` stale reader) — close other GitNexus processes on this repo, then retry.\n`
|
|
: '';
|
|
cliError(
|
|
` LadybugDB failed while rotating/removing WAL checkpoint files.\n` +
|
|
heldOpen +
|
|
` This can happen when auto-checkpoint runs at the default threshold (~16MB).\n` +
|
|
` Retry with a larger checkpoint threshold to reduce checkpoint frequency:\n` +
|
|
` gitnexus analyze --wal-checkpoint-threshold ${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD}\n` +
|
|
` (or set GITNEXUS_WAL_CHECKPOINT_THRESHOLD=${RECOMMENDED_WAL_CHECKPOINT_THRESHOLD})\n` +
|
|
` (Try 33554432 = 32 MiB on small-disk / CI runners.)\n`,
|
|
{ recoveryHint: 'wal-checkpoint-threshold' },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// DB-family wipe failure (#2409, tri-review 4669518496 P2-4): the rebuild
|
|
// could not verify the LadybugDB file family was removed — usually another
|
|
// process (MCP server, serve worker, antivirus) holding the index open.
|
|
// Keyed on the error *type* (repo norm from #2385), never message text.
|
|
// The message itself is fully self-contained (survivor paths + stop-MCP /
|
|
// AV-exclusion / re-run guidance) because the serve worker forwards only
|
|
// `err.message` over IPC — this branch just renders it without the
|
|
// raw-stack fallback below.
|
|
if (err instanceof LbugWipeError) {
|
|
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'lbug-wipe-failed',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Buffer-manager frame-release failure on non-4K-page kernels (#1231).
|
|
// LadybugDB <= 0.17.x assumed 4 KiB OS pages when releasing evicted
|
|
// frames; Raspberry Pi 5 (16 KiB kernel pages) and other arm64 systems
|
|
// crash mid-COPY with a raw native message. 0.18.0 detects the page size
|
|
// at runtime, so the actionable fix depends on which side of that
|
|
// boundary the installed @ladybugdb/core is.
|
|
if (isLbugPageSizeFrameError(err)) {
|
|
const pageSize = getOsPageSize();
|
|
const ladybug = getRuntimeFingerprint().ladybugdb;
|
|
const pageLine =
|
|
pageSize !== undefined && pageSize !== 4096
|
|
? ` Detected OS page size: ${pageSize} bytes (non-4K — e.g. Raspberry Pi 5 16K kernel, Asahi Linux).\n`
|
|
: '';
|
|
// The upgrade variant must not assert version facts about an unknown
|
|
// version — mirror the doctor-side wording rule (#2424 review R2).
|
|
const upgradeIntro =
|
|
ladybug === undefined
|
|
? ` The installed @ladybugdb/core version is unknown — it may predate the\n` +
|
|
` runtime OS-page-size detection added in 0.18.0.\n`
|
|
: ` The installed @ladybugdb/core (${ladybug}) assumes 4 KiB pages in its buffer\n` +
|
|
` manager.\n`;
|
|
const guidance = isPageSizeAwareLadybug(ladybug)
|
|
? ` The installed @ladybugdb/core (${ladybug}) already detects the OS page size at runtime,\n` +
|
|
` so this configuration was expected to work. Please report it:\n` +
|
|
` https://github.com/abhigyanpatwari/GitNexus/issues/1231\n` +
|
|
` and include: gitnexus --version, node --version, getconf PAGE_SIZE, uname -a,\n` +
|
|
` and the full error message above.\n`
|
|
: upgradeIntro +
|
|
` Upgrade GitNexus to a release that bundles @ladybugdb/core >= 0.18.0\n` +
|
|
` (gitnexus >= 1.6.9), which detects the OS page size at runtime:\n` +
|
|
` npm install -g gitnexus@latest\n` +
|
|
` Last-resort workaround on Raspberry Pi 5: boot the 4 KiB-page kernel\n` +
|
|
` (config.txt: kernel=kernel8.img), at the cost of Pi 5 optimizations.\n`;
|
|
// Embed the raw native text (indented, no stack) so "the full error
|
|
// message above" is fulfillable — same idiom as the LbugWipeError
|
|
// branch. The errno suffix and the 0.18.0 guard's frame/granule numbers
|
|
// are the discriminating triage content (#2424 review P2).
|
|
cliError(
|
|
` LadybugDB's buffer manager failed to release frame memory.\n` +
|
|
` ${msg.replace(/\n/g, '\n ')}\n` +
|
|
pageLine +
|
|
guidance,
|
|
{ recoveryHint: 'lbug-page-size', pageSize, ladybugVersion: ladybug },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Local embedding runtime unsupported on this platform (macOS Intel ships no
|
|
// darwin/x64 ONNX native binding, #1515). The guard threw before importing
|
|
// transformers.js, so this is a clean, actionable GitNexus message. Checked
|
|
// before the network-heuristic isHfDownloadFailure branch below (and before
|
|
// the generic module-not-found "installation may be corrupt" hint) so the
|
|
// explicit platform message always takes priority.
|
|
if (isLocalEmbeddingRuntimeBlockerMessage(msg)) {
|
|
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'local-embedding-unsupported',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// The optional embedding stack (@huggingface/transformers → onnxruntime-node)
|
|
// was pruned at install time — usually a proxy-blocked NuGet download during
|
|
// onnxruntime-node's postinstall (#2370). Checked before the generic
|
|
// module-not-found "installation may be corrupt" hint below, which would
|
|
// otherwise misdiagnose a deliberate optional-dependency skip.
|
|
if (isMissingLocalEmbeddingStackMessage(msg)) {
|
|
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'local-embedding-stack-missing',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Malformed GITNEXUS_EMBEDDING_DIMS env var (#2385). readConfig() throws a
|
|
// plain Error (a config mistake, not an endpoint failure), surfacing here from
|
|
// httpEmbed()->readConfig() inside the analysis run. Show a clean config
|
|
// message rather than a raw stack dump. The --embedding-dims CLI flag is
|
|
// validated up front (EMBEDDING_DIMS_ERROR); this covers the env-var path.
|
|
// Checked before the endpoint/HF branches: it is a plain Error, so
|
|
// isHttpEmbeddingError() is false and the HF network heuristic must not claim it.
|
|
if (isHttpEmbeddingDimsError(msg)) {
|
|
cliError(` ${msg.replace(/\n/g, '\n ')}\n`, {
|
|
recoveryHint: 'embedding-dims-invalid',
|
|
});
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Custom HTTP embedding endpoint failure (#2385). When a `--embedding-base-url`
|
|
// is configured, HTTP mode never downloads a model — so a failure talking to
|
|
// that endpoint must NOT show the huggingface-download guidance. Keyed on the
|
|
// error *type* (HttpEmbeddingError), not its message text, so it stays correct
|
|
// regardless of locale or wording. Checked before the HF branch, whose network
|
|
// heuristic (`fetch failed` / `ECONNREFUSED`) would otherwise also match a
|
|
// wrapped endpoint-connection error. The header is deliberately neutral: this
|
|
// type covers both never-reached failures (connection/timeout/DNS) and
|
|
// reached-but-failed ones (4xx/5xx, dimension/shape mismatch), so it must not
|
|
// assert "unreachable". The thrown `msg` carries the specific reason (and the
|
|
// masked URL where one applies), so it is surfaced verbatim.
|
|
if (isHttpEmbeddingError(err)) {
|
|
cliError(
|
|
` The custom embedding endpoint request failed.\n` +
|
|
` ${msg.replace(/\n/g, '\n ')}\n` +
|
|
` Suggestions:\n` +
|
|
` 1. Verify the endpoint URL is reachable and running ` +
|
|
`(--embedding-base-url / GITNEXUS_EMBEDDING_URL: host, port, /v1 path).\n` +
|
|
` 2. Confirm the model name and embedding dimensions match what the endpoint serves.\n` +
|
|
` 3. Re-run without --embeddings to index without vectors.\n`,
|
|
{ recoveryHint: 'http-embedding-endpoint-error' },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// isHttpMode() is a pure presence probe (URL+MODEL) that never throws — a
|
|
// malformed GITNEXUS_EMBEDDING_DIMS is handled by the dims branch above — so
|
|
// no defensive try/catch is needed here (#2385).
|
|
const inHttpMode = isHttpMode();
|
|
|
|
// HF download failure — show clean guidance without the raw stack trace.
|
|
// Checked before writeFatalToStderr so the user sees one focused message
|
|
// rather than a stack-trace dump followed by a second remediation block.
|
|
// Gated on !inHttpMode: with a custom endpoint configured no model download
|
|
// is ever attempted, so a network error there is the endpoint's, handled by
|
|
// the HttpEmbeddingError branch above — never HF's (#2385).
|
|
if (
|
|
(isHfDownloadFailure(msg) || msg.includes('Failed to download embedding model')) &&
|
|
!inHttpMode
|
|
) {
|
|
cliError(
|
|
` The embedding model could not be downloaded.\n` +
|
|
` huggingface.co may be unreachable from your network\n` +
|
|
` (e.g. behind a corporate proxy or a regional firewall).\n` +
|
|
` Suggestions:\n` +
|
|
` 1. Set HF_ENDPOINT to a mirror and retry:\n` +
|
|
` HF_ENDPOINT=https://hf-mirror.com npx gitnexus analyze --embeddings\n` +
|
|
` (Windows: set HF_ENDPOINT=https://hf-mirror.com && npx gitnexus analyze --embeddings)\n` +
|
|
` 2. Check your proxy / VPN settings.\n` +
|
|
` 3. Once downloaded the model is cached — future runs work offline.\n`,
|
|
{ recoveryHint: 'hf-endpoint-unreachable' },
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// Bypass the redirected console.error and write the full stack to
|
|
// the real stderr captured at module load. The redirected
|
|
// console.error wraps every line with `\\x1b[2K\\r` (ANSI clear-line)
|
|
// and forces a bar.update() afterwards, which on some Windows
|
|
// terminals visually erases the failure message — the canonical
|
|
// shape of the silent-exit symptom in #1169.
|
|
writeFatalToStderr('Analysis failed', err);
|
|
|
|
// Provide helpful guidance for known failure modes
|
|
if (
|
|
msg.includes('Maximum call stack size exceeded') ||
|
|
msg.includes('call stack') ||
|
|
msg.includes('Map maximum size') ||
|
|
msg.includes('Invalid array length') ||
|
|
msg.includes('Invalid string length') ||
|
|
msg.includes('allocation failed') ||
|
|
msg.includes('heap out of memory') ||
|
|
msg.includes('JavaScript heap')
|
|
) {
|
|
cliError(
|
|
` This error typically occurs on very large repositories.\n` +
|
|
` Suggestions:\n` +
|
|
` 1. Add large vendored/generated directories to .gitnexusignore\n` +
|
|
` 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"\n` +
|
|
` 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"\n`,
|
|
{ recoveryHint: 'large-repo' },
|
|
);
|
|
} else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
|
|
// Note: the original arborist "Cannot destructure property 'package' of
|
|
// 'node.target'" crash happens inside npm *before* gitnexus code runs,
|
|
// so it can't be caught here. This branch handles dependency-resolution
|
|
// errors that surface at runtime (e.g. dynamic require failures).
|
|
cliError(
|
|
` This looks like an npm dependency resolution issue.\n` +
|
|
` Suggestions:\n` +
|
|
` 1. Clear the npm cache: npm cache clean --force\n` +
|
|
` 2. Update npm: npm install -g npm@latest\n` +
|
|
` 3. Reinstall gitnexus: npm install -g gitnexus@latest\n` +
|
|
` 4. Or try npx directly: npx gitnexus@latest analyze\n`,
|
|
{ recoveryHint: 'npm-resolution' },
|
|
);
|
|
} else if (
|
|
msg.includes('MODULE_NOT_FOUND') ||
|
|
msg.includes('Cannot find module') ||
|
|
msg.includes('ERR_MODULE_NOT_FOUND')
|
|
) {
|
|
cliError(
|
|
` A required module could not be loaded. The installation may be corrupt.\n` +
|
|
` Suggestions:\n` +
|
|
` 1. Reinstall: npm install -g gitnexus@latest\n` +
|
|
` 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze\n`,
|
|
{ recoveryHint: 'module-not-found' },
|
|
);
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// LadybugDB's native module holds open handles that prevent Node from exiting.
|
|
// ONNX Runtime also registers native atexit hooks that segfault on some
|
|
// platforms (#38, #40). Force-exit to ensure clean termination.
|
|
process.exit(0);
|
|
};
|