mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(schema): derive a fingerprint from the DDL this build creates `SCHEMA_FINGERPRINT` is a sha256 digest of the node and relation DDL that `runSchemaCreationQueries` actually executes, in the same shape as the existing `taintModelVersion` stamp (hex, sliced to 12). It exists because `INCREMENTAL_SCHEMA_VERSION` is hand-picked and has to *predict* whether an on-disk database matches this build's DDL. That number has collided with `main` eight times, twice exactly — and an exact clash is the quiet one, because the reuse gate is a strict `===`. `EMBEDDING_SCHEMA` is deliberately excluded: its `FLOAT[N]` width comes from `GITNEXUS_EMBEDDING_DIMS` at module load, so folding it in would make the digest a function of the environment rather than of code, and two runs of the same build under different env would thrash full rebuilds. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(storage): record the DDL fingerprint in RepoMeta `RepoMeta.schemaFingerprint` stores the digest of the DDL an index's tables were actually created from. It is the derived companion to `schemaVersion`, not its replacement: both are compared, and both must match. Absent means mismatch, deliberately. Grandfathering a missing fingerprint would let an incremental top-up stamp a fresh one onto a database whose DDL was never verified, permanently certifying exactly the wrong-shaped index the field exists to catch. The cost is one full rebuild per pre-existing index. The version ladder gains a note that its "re-check against origin/main before merge" ritual now only guards *semantic* bumps. v25, v26, v30, v31 and v34 all changed emitted ids, edges or wire formats while leaving the DDL byte-identical, and the fingerprint cannot see any of them — but DDL collisions no longer need renumbering. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(analyze): gate index reuse on the DDL fingerprint, not just the version (#2798) `INCREMENTAL_SCHEMA_VERSION` is a hand-incremented integer that has to predict a derived fact: whether the on-disk DDL matches the code's DDL. It has collided with `main` eight times, and twice the collision was *exact*. An exact clash is the silent one. Two builds stamp the same number over different DDL, the `===` reuse gate reads the index as current, every `CREATE ... TABLE` is then skipped as "already exists" (suppressed in `runSchemaCreationQueries`), and the edges whose endpoint pair the live database cannot hold are dropped by `fallbackRelationshipInserts`' bare `catch`. The result is a wrong graph, with no error anywhere. Reuse now requires the version AND the DDL fingerprint to match, in both the pre-pipeline force-rebuild guard and the `isIncremental` predicate, and the fingerprint is stamped alongside the version at the end of a run. Both conditions are necessary. The fingerprint does not replace the integer: most entries in the version ladder change emitted ids, edges or wire formats while the DDL stays byte-identical, and a fingerprint-only gate would stop forcing rebuilds for all of them. What it does buy is that two branches picking the same number no longer need renumbering. The new branch sits above the `alreadyUpToDate` fast path for the same reason the version guard does — a clean tree at an unchanged commit would otherwise early-return before either check ran. Closes #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(analyze): pin the DDL fingerprint gate and its two failure cases `schema-fingerprint.test.ts` pins the properties the gate rests on: the digest covers exactly the node and relation DDL that gets executed (recomputed from the exported lists, so adding a table or a FROM/TO pair without the fingerprint moving is impossible), it excludes the environment-derived embedding DDL, and it moves when any covered string moves. The two `incremental-orchestration` cases exercise the production path rather than modelling it: an index carrying the *current* version with a foreign fingerprint, and one with no fingerprint at all. Both were run against the pre-fix tree first and both failed there with `alreadyUpToDate === true` — the fast path swallowing the mismatch, which is the #2798 symptom exactly. `call-summary-schema-version.test.ts` widens its gate model to two equalities. The second argument defaults to the current fingerprint so all 33 existing version cases read unchanged, and a new case covers the collision, the legacy absence, and the semantic bump the fingerprint cannot see. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(review-skill): point the schema-constant check at the fingerprint, not the deleted integer All four `gitnexus-review` SKILL.md mirrors told reviewers to verify `INCREMENTAL_SCHEMA_VERSION` "was bumped or regenerated". That constant no longer exists, so the instruction sent every future reviewer looking for something they could not find — and, worse, past its replacement. The check for graph DDL is now derived: `SCHEMA_FINGERPRINT` moves on its own, so the question is whether the diff changed a string in `NODE_SCHEMA_QUERIES` / `REL_SCHEMA_QUERIES`, and whether a newly added DDL array was folded into the fingerprint at all — the one way the derived gate can still be bypassed. What did NOT change is called out explicitly: the parse-store `SCHEMA_BUMP` and the bench fingerprint sets are still hand-maintained and still need the re-check-against-base ritual, and semantic changes that leave the DDL untouched fall outside the fingerprint entirely — those rely on the analyzer runner-identity receipt. Found by the review swarm's docs lane. The original plan for #2798 claimed no documentation mentioned the constant; that sweep covered five root docs and never looked at `.claude/skills/**` or the three mirrors. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(migration): record the one-time rebuild the fingerprint switch costs Replacing `schemaVersion` with `schemaFingerprint` means every index written by an earlier GitNexus carries no fingerprint, reads as a mismatch, and is rebuilt once. That is deliberate — grandfathering absence would stamp a fresh fingerprint onto a database whose DDL was never verified — but until now it was undocumented, so a user's first post-upgrade analyze would announce a full re-analyze with nothing to explain it. MIGRATION.md already sets the precedent: PR #2363's meta.json → gitnexus.json rename was equally automatic and equally in need of an entry. This follows that shape, and is explicit about the parts that are easy to undersell: - the cost is per INDEX, and branch-scoped slots (#2106) each pay separately; on a large repository a full re-analyze is substantial, not a blip; - rollback is safe — an older binary sees no `schemaVersion` and forces its own rebuild, which is a cost, never a stale graph; - alternating between an old and a new binary rebuilds on every switch, because the end-of-run meta is written as a fresh literal so neither field survives the other's run. The retired ladder's per-version rationale is pointed at in git history rather than reproduced: `git show 561f913a3:.../repo-manager.ts`. That commit is an ancestor of origin/main, so the pointer survives this branch being squash-merged. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(identity): cover workspace-linked packages in the analyzer dependency digest `dependencyNames` enumerated `dependencies`, `optionalDependencies` and `peerDependencies` only. `gitnexus-shared` is declared as a devDependency (`file:../gitnexus-shared`), and in a source-mode run the build root is the gitnexus package tree, which does not contain that sibling. So a change to gitnexus-shared moved neither `build.digest` nor `dependencyRuntime.digest`. That gap matters more since #2798 deleted `INCREMENTAL_SCHEMA_VERSION`. A DDL-affecting edit there is still caught by `SCHEMA_FINGERPRINT`, but a SEMANTIC-only edit — a new `REL_TYPES` member, say, where the relation table carries a bare `type STRING` column so no CREATE statement moves — was covered by nothing at all. Roughly thirty of the retired ladder's entries were exactly that change class, and the runner-identity receipt is what now carries them. Only checkout-local specifiers are added: `file:`, `link:`, `workspace:`, `portal:` and npm's bare local-path shorthands. Pulling in every devDependency was rejected — vitest, eslint and typescript would enter the digest and force a full re-analyze on unrelated tool bumps, which is worse than the hole. Scanning the linked sibling for the first time exposed a latent throw: `collectArtifacts` honoured `PRUNED_RUNTIME_DIRECTORIES` only for a real directory, so a SYMLINKED `node_modules` fell through to the payload branch and died with "Analyzer identity input is not a file". Worktree-style dev layouts and pnpm shared stores hit this immediately — verified in this worktree, where `gitnexus-shared/node_modules` is such a symlink. Pruning it loses nothing: packages beneath are still reached through `resolveDependencyPackageRoot`. Verified: a real `analyze` in this worktree succeeds with `packageCount` 259; editing the linked package's source moves the digest, bumping an installed registry devDependency does not, and removing the link moves it. `DEPENDENCY_RUNTIME_CANONICALIZATION` is deliberately not bumped — freshness compares digests, not the label, and the input-set change already moves them. Follow-up worth having: no fixture in the suite declares `devDependencies`, so this has no regression test yet. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(analyze)!: delete INCREMENTAL_SCHEMA_VERSION, gate reuse on the DDL fingerprint alone The integer and its ~180-line version ladder are gone, along with `RepoMeta.schemaVersion`. Index reuse is now decided solely by `SCHEMA_FINGERPRINT`; a mismatch — including the absent stamp every pre-existing index carries — warns and forces a full re-analyze, which wipes and recreates the database so the tables are built from the current DDL. Deleting the integer is safe because it was already redundant: the runner-identity guard deep-compares the whole schema-v4 receipt, including a digest over the build tree, and forces a rebuild on ANY analyzer delta. Verified empirically — a comment-only edit to logger.ts, with the fingerprint byte identical, produced "runner identity changed ... forcing a full rebuild". The fingerprint is not thereby redundant. It fires where that guard cannot: a DDL-affecting change in `gitnexus-shared`, which is a workspace-linked devDependency and so sat outside both digests until the companion commit closed that gap. Review findings folded in, each correcting a line this rewrite itself introduced and never published: - B1: two assertions matched a log string the rewrite had renamed; both tests failed. They now assert what production emits. - B2: the pre-existing downgrade test perturbed `schemaVersion: 7`, a field this change deletes, so the spread carried a valid fingerprint, every guard passed, and the run legitimately took the fast path. It perturbs the fingerprint now, restoring the only integration coverage of the gate-above-the-fast-path ordering invariant. - N5: duplicate `schemaFingerprint` keys silently collapsed two assertions into one (TS1117). - N6: the absent-stamp message told non-git repositories their index was "built by an older GitNexus version" — on every run, about an index this exact build had just written. Non-git repos never record a fingerprint, and now the message says so. - N9: the on-disk stamp is shape-checked before being echoed, so a crafted gitnexus.json cannot push ANSI escapes through the CLI log. - N7: a test case that re-computed the same digest expression with its operands swapped, mislabelled as a randomness check on a module-level const. - N10: comments claiming the digest "cannot collide" (it is 48 bits), pointing at a vector-column gate that does not exist, and asserting storage/ is free of a core/ dependency two lines below a core/ value import. None of these were caught by `tsc -p tsconfig.json`, which covers src only, nor by eslint, where no-dupe-keys is off. `tsconfig.test.json` reports all three test defects and is not currently wired into CI. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(schema): pin that the fingerprint covers every DDL statement init executes `SCHEMA_QUERIES` is the list `runSchemaCreationQueries` iterates — the DDL that actually runs. The fingerprint hashes only two of its three members, and until now no test imported `SCHEMA_QUERIES` at all, so nothing tied the two together. A fourth member appended to that array — the one literally named for what init executes — would have been invisible to the gate. Every existing test would still pass, because they all recompute the digest from the same two arrays the fingerprint already uses. An index whose gate passed would then run `initLbug` over the old database, where `runSchemaCreationQueries` suppresses "already exists", so the new table would never be created and its edges would be dropped by `fallbackRelationshipInserts`' bare catch. A wrong graph, no error — exactly the failure #2798 exists to end. The check is a pure predicate over (executed, fingerprinted, documented exclusions) rather than a positional `toEqual`, so `EMBEDDING_SCHEMA` is named as an exclusion with its reason — its FLOAT[N] width is environment-derived — rather than sitting in a list where a future reader might "fix" it by folding it in. It asserts both directions and is order-insensitive, leaving ordering to the digest assertion that already pins it. The negative case is pinned in CI rather than checked by hand once: the same predicate over a synthetic fourth member must report it. If a refactor ever makes the predicate vacuous, that case fails even though the positive one would not. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(analyze): name the invariant the version deletion now rests on Deleting `INCREMENTAL_SCHEMA_VERSION` moved a load-bearing guarantee into an implicit one. Roughly thirty of the retired ladder's entries changed no DDL at all — node ids, wire formats, resolution tiers — and the fingerprint is structurally incapable of firing on any of them. Their only remaining cover is the analyzer runner-identity receipt, and nothing in the suite said so. This adds a table over the real `analyzerRunnerIdentitiesEqual` with a well-formed schema-v4 receipt: byte-identical reuses; an entrypoint-only difference reuses (CLI vs analyze worker); a moved build digest with unchanged DDL forces — that case IS the invariant, commented as such; and a dependency change, an ABI change, undefined, null, a schema-v3 legacy receipt, a missing build section and a non-sha256 digest all fail closed. The deleted `expect(INCREMENTAL_SCHEMA_VERSION).toBe(35)` pin is also worth naming: it failed CI on every bump by design, which is what made an author stop and think. Nothing replaced it. This does not restore that — a digest has no literal to pin — but it does make the mechanism that took over the job visible to the next person who reads the file. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(spring): pin CLASS_SCHEMA's membership in the fingerprinted DDL set When `INCREMENTAL_SCHEMA_VERSION` went away, its sibling in basicblock-callee-ids-schema.test.ts got a replacement assertion tying BASICBLOCK_SCHEMA to the fingerprint's input set. This file's `>= 23` floor was deleted with nothing put in its place. The file still asserts CLASS_SCHEMA's CONTENT — that the `frameworkAnnotations` column exists — but not that CLASS_SCHEMA is part of what the digest covers, and the second is what makes an index built before that column carry a different fingerprint and get rebuilt. Mirrors the sibling so the two read the same way. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(identity): stop a symlinked directory from aborting the whole analyze `collectArtifacts` fused two orthogonal facts into one condition: that four directory names never carry runtime payload, and that a symlink where a real directory was assumed falls through to the payload branch, where `snapshotReadableFile` stats the target, sees a directory, and throws "Analyzer identity input is not a file". The second was only fixed for those four names. Every other symlinked directory in a scanned package root still aborted the run — `dist -> build`, a vendored grammar link, anything inside a linked sibling checkout. Newly reachable, because making workspace-linked packages scannable pointed the scanner at a live checkout instead of an immutable registry tarball for the first time. Split along the actual seam: prune on the NAME alone, and give symlinks their own branch in the type dispatch, ahead of the payload branch. Link text is recorded rather than followed. Following was rejected on three grounds, each checked in source: the traversal is a stack with no visited set, so a self-referential link would recurse to `runtimeDepth` — which throws, trading one hard abort for another; `snapshotDirectory` rejects a symlink outright, so the directory guard could not accept one without a realpath rewrite of its canonical-path identity; and a link into an already-scanned tree double-counts against `runtimeEntries`/`runtimeBytes`, which also throw. The cost is stated in code: a link out of the package contributes its text, not its target's content. Links resolving to a regular file keep the existing content digest. The new `'unfollowed-symlink'` kind is threaded through every consumer, including the cache validator — which re-probes with `mode: 'link'`, since the readable-file probe resolves the target and would return null for exactly this kind, silently failing every warm validation. No canonicalization or cache-schema bump. Digest content changes only for trees that previously crashed: a delta scan over all 258 scanned roots of this install found no regular file bearing a pruned name and no symlink failing to resolve to a file, so `dependencyRuntime.digest` is byte-identical here. Six of the eight new tests fail against the unfixed tree with the exact production error; all eight pass after. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(analyze): give the reuse gate a real seam and sanitize logs at the funnel Cleanup pass over the #2798 branch. Net -183 lines. The gate had no extracted predicate, so its own test asserted it by regex-matching run-analyze.ts SOURCE TEXT. That pinned production formatting: one pattern froze three back-to-back single-name imports from './lbug/schema.js', so merging them — the obvious tidy-up — failed a test named "still imports the DDL digest itself". `schemaFingerprintMismatch` and `isSchemaFingerprintShaped` now live in core/lbug/schema.ts beside the constant. Not in run-analyze.ts next to `pdgModeMismatch`, because storage/ must stay off the analyze pipeline and mcp/resources.ts is a plausible second consumer — the same reasoning that puts `cjkSegmentationModeMismatch` in core/search/. The regex block is gone; the test calls the predicate. The three imports are merged. ANSI sanitation moved from one field to the funnel. The per-field guard's own comment stated the general hazard — gitnexus.json is parsed with no runtime shape validation and the notice reaches console.log — while two sibling guards twelve lines away echoed `runnerIdentity.schemaVersion` and `cjkSegmentation` from that same file raw into the same log. `log()` now strips C0/C1 controls, covering all seven guard messages and any written later. Also: - Deleted a duplicate integration test. After the downgrade test was repointed at `schemaFingerprint` it became the same scenario as the new one, differing only by an extra log assertion — which is now folded into the survivor. Saves a fixture and two full pipeline runs per CI pass. - Replaced a 3-parameter set-difference helper with one set equality. Its doc was false at one call site (arguments semantically swapped) and it needed a fourth test purely to prove itself non-vacuous; set equality cannot go vacuous. - Removed ~115 lines of runner-identity table that duplicated analyzer-identity.test.ts. The three genuinely uncovered cases moved there, and the #2798 invariant — build digest moved while the DDL did not — now asserts against a REAL analyzer-build-tree edit rather than a hand-built literal, which is strictly stronger than what it replaces. - MIGRATION.md quoted a log line the code cannot emit; it was written before the placeholder changed. - Restored the rationale on the `capabilities` docstring, which a previous pass replaced with its consequence — leaving a maintainer reading "duplicated by hand" as a wart to fix by importing, which is what the original forbade. - Marked the `isIncremental` conjunct as belt-and-braces: `!options.force` short-circuits before it, so it cannot decide anything. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(analyze): force a rebuild when the vector column width changes `CodeEmbedding.embedding` is declared `FLOAT[EMBEDDING_DIMS]`, resolved from `GITNEXUS_EMBEDDING_DIMS` at module load. Nothing gated it. Flip the variable on a same-commit clean tree and no guard fired at all: `alreadyUpToDate` returned over a `FLOAT[384]` table while the process embedded at 768. The only reaction anywhere discards the embedding CACHE and re-embeds — into a column whose type it never revisits. This predates #2798; `INCREMENTAL_SCHEMA_VERSION` never covered dims either. It surfaced because the fingerprint work had to reason about why `EMBEDDING_SCHEMA` must stay OUT of the digest: its width is environment-derived, so folding it in would make the same build disagree with itself and thrash rebuilds. That exclusion is correct, and it leaves the width needing its own guard. Modelled on `cjkSegmentation`, the closest sibling: an env-resolved scalar stamped at write time and compared by a small exported predicate that forces on mismatch. `embeddingDimsMismatch` sits in core/lbug/schema.ts beside `EMBEDDING_DIMS`, so the query side can adopt it without importing the analyze pipeline — mcp/local/local-backend.ts already warns on a cjkSegmentation disagreement and has the identical claim here, since the query path embeds at the live width against a table of unknown width with no validation at all today. ABSENCE IS NOT A MISMATCH, deliberately. Forcing on it would be dead code: `embeddingDims` and `schemaFingerprint` ship together, and a missing fingerprint already forces exactly one rebuild — which is where this stamp lands. Absence also carries no signal here, unlike the fingerprint: a missing fingerprint means "DDL this build cannot vouch for" and ships WITH a DDL change, whereas a missing dims stamp means only "written before the field existed", and that run's table agreed with that run's width. Drift requires the env to change, which absence says nothing about. The `cjkSegmentation` trick of folding absence into the default was unavailable — there is no width that is safe to assume for an existing table — so the stamp is instead written unconditionally, giving absence exactly one meaning. Malformed values are not grandfathered: null, '384', NaN and objects all read as a mismatch and err toward a rebuild. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(mcp): warn when the served index's vector width differs from the query embedder's The analyze side now forces a rebuild when the vector column width changes. The query side had no equivalent: a serving process embeds a query at its own width and searches a table whose width was fixed when the index was built. Disagree and the user gets wrong or missing semantic results with nothing explaining why. Mirrors the cjkSegmentation drift warning immediately above it — same warnings[] array, same per-query recomputation, agent-visible in the tool response, and it warns rather than refuses. A width mismatch degrades the semantic lane only; keyword results are unaffected, so `partial` is deliberately not set. Compares against `getEmbeddingDims()` — the width the query embedder actually produces — NOT schema.ts's `EMBEDDING_DIMS`. The two diverge exactly when GITNEXUS_EMBEDDING_DIMS is set on a server that embeds LOCALLY: the query path ignores that variable and embeds at 384, so comparing against the env-derived constant would report drift on a lane that works fine. The recorded width is what the vector CAST actually binds. `embeddingDimsMismatch` is imported from core/lbug/schema.js rather than restated, so "absent is not a mismatch" cannot drift between the analyze and query sides. That predicate was placed in schema.ts precisely so this consumer could reach it without importing the analyze pipeline. Two gates keep it quiet when it would be noise: it fires only for a repo where this process actually produced a query vector, so an index analyzed without --embeddings (or a server whose embedder is unavailable) never carries it. An untrusted recorded value — meta.json is schema-less JSON — is reported as "an unrecognized width" rather than echoed. `loadMeta` is hoisted out of the neighbouring try so both diagnostics share one read and an invalid GITNEXUS_FTS_CJK_SEGMENTATION cannot take this one down with it. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(identity): detect an npm-linked dev dependency the specifier cannot see `isLocallyLinkedSpecifier` admits a devDependency whose SPECIFIER is checkout-local. `npm link <pkg>` leaves the specifier a registry range while the node_modules entry symlinks to a checkout — locally linked, invisible to a specifier check, so a semantic-only edit there still moves neither digest. The obvious placement is unaffordable, measured rather than assumed: probing every dev-only name inside collectRuntimePackages costs 1998 resolutions, not the ~8 it looks like, because dependencyNames runs for every package in the BFS and published tarballs retain their devDependencies. Persisted path guards go 2221 -> 11050 (+398%), and every guard is re-probed on each warm validation — the path `status` takes. Scoped to the root package instead. The declared-intent half is untouched and still enumerated everywhere: it alone can emit the `<missing>` edge for a declared link whose checkout is absent, where resolution returns null and cannot distinguish that from an uninstalled dev tool. The new resolved-location half runs only when `parent.root === packageRoot`, resolves through the existing resolver so its path guards are recorded, and admits a name iff the realpath'd root carries no node_modules segment. Bounded against mis-fire by EXPANSION. "Not under node_modules" is a proxy for "checkout-local"; under a relocated pnpm virtual store every dev dep passes it and the whole dev tree folds into the receipt — against limits that THROW, so a legitimate install would abort. Measured here: uncapped, that shape takes 259 -> 347 packages and 2250 -> 3786 guards. The cap admits at most four and DROPS THE WHOLE CHANNEL on overflow rather than an arbitrary prefix, because the abort comes from the transitive payload of whichever trees get folded in — four of a mis-fired thirteen is still unbounded, and a sorted-prefix receipt would be arbitrary. Overflow falls back to the specifier-only receipt that ships today. Cost on this install: 259 packages unchanged, 13 dev names resolved, guards 2221 -> 2250 (+29, +1.3%). Verified against the real implementation, not just a replay: validation guards 16295 -> 16324, packageCount and artifactCount unchanged, and `dependencyRuntime.digest` byte-identical — so this forces no re-analysis for anyone. Each test fails on the defect it targets: disabling the channel kills the npm-link and cap cases; dropping the root-only scope makes the differential guard-count case fail at 2.8x guards; removing the specifier half kills the `<missing>` case. Refs #2798 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
8195 lines
342 KiB
TypeScript
8195 lines
342 KiB
TypeScript
/**
|
||
* Local Backend (Multi-Repo)
|
||
*
|
||
* Provides tool implementations using local .gitnexus/ indexes.
|
||
* Supports multiple indexed repositories via a global registry.
|
||
* LadybugDB connections are opened lazily per repo on first query.
|
||
*/
|
||
|
||
import fs from 'fs/promises';
|
||
import path from 'path';
|
||
import { createHash } from 'crypto';
|
||
import {
|
||
initLbug,
|
||
executeQuery,
|
||
executeParameterized,
|
||
closeLbug,
|
||
isLbugReady,
|
||
statDbIdentity,
|
||
dbIdentityChanged,
|
||
} from '../../core/lbug/pool-adapter.js';
|
||
import { queryClassBeanMetadata } from './bean-metadata.js';
|
||
import { querySpringAopMetadata } from './aop-metadata.js';
|
||
import { isValidQueryParams } from '../../core/lbug/query-params.js';
|
||
import { toDisplayLine } from './line-display.js';
|
||
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
|
||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||
// git utilities available if needed
|
||
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
|
||
import {
|
||
parseDiffHunks,
|
||
getCanonicalRepoRoot,
|
||
getGitRoot,
|
||
type FileDiff,
|
||
} from '../../storage/git.js';
|
||
import { realpathSync } from 'fs';
|
||
import {
|
||
listRegisteredRepos,
|
||
cleanupOldKuzuFiles,
|
||
canonicalizePath,
|
||
getStoragePaths,
|
||
loadMeta,
|
||
RegistryAmbiguousTargetError,
|
||
type RegistryEntry,
|
||
type BranchSummary,
|
||
} from '../../storage/repo-manager.js';
|
||
import {
|
||
GroupService,
|
||
type GroupToolPort,
|
||
type GroupSymbolResolution,
|
||
type GroupPdgFlowResult,
|
||
type GroupPdgFlowHop,
|
||
} from '../../core/group/service.js';
|
||
import { resolveAtGroupMemberRepoPath } from '../../core/group/resolve-at-member.js';
|
||
import { collectBestChunks } from '../../core/embeddings/types.js';
|
||
import {
|
||
DEFAULT_MCP_VECTOR_MAX_DISTANCE,
|
||
getVectorMaxDistance,
|
||
} from '../../core/embeddings/config.js';
|
||
import {
|
||
rankExactEmbeddingRows,
|
||
type ExactEmbeddingRow,
|
||
} from '../../core/embeddings/exact-search.js';
|
||
// These are two bare string constants, but `schema.ts` builds its DDL from
|
||
// `LINKABLE_LABELS` / `CALL_TARGET_TYPES` and so pulls five ingestion modules
|
||
// into the runtime closure (~70ms on a cold MCP start). Re-homing them in
|
||
// `gitnexus-shared` does NOT fix that on its own: `pool-adapter.js` below
|
||
// reaches `schema.ts` anyway via pool-adapter -> lbug-adapter -> csv-generator,
|
||
// all value imports. Cutting `csv-generator` (analyze-only code the MCP server
|
||
// never runs) out of the adapter chain is the change that would make it real.
|
||
// `embeddingDimsMismatch` rides along on this same import on purpose: it was
|
||
// homed in `schema.ts` (not run-analyze.ts) so the QUERY side could reuse the
|
||
// analyze side's comparator without pulling in the analyze pipeline, and this
|
||
// module already takes a value import from `schema.ts`, so it costs nothing.
|
||
import {
|
||
EMBEDDING_TABLE_NAME,
|
||
EMBEDDING_INDEX_NAME,
|
||
embeddingDimsMismatch,
|
||
} from '../../core/lbug/schema.js';
|
||
import { getExactScanLimit } from '../../core/platform/capabilities.js';
|
||
import { PhaseTimer } from '../../core/search/phase-timer.js';
|
||
import { ftsDegradedWarning, ftsQueryFailedWarning } from '../../core/search/fts-indexes.js';
|
||
import {
|
||
cjkSegmentationModeMismatch,
|
||
containsSegmentableCjkRun,
|
||
getSearchFTSCjkSegmentation,
|
||
isSupportedCjkSegmentationMode,
|
||
MAX_CJK_SEGMENTATION_QUERY_LENGTH,
|
||
} from '../../core/search/cjk-segmentation.js';
|
||
import {
|
||
checkStalenessAsync,
|
||
checkCwdMatch,
|
||
type StalenessInfo,
|
||
} from '../../core/git-staleness.js';
|
||
import { logger } from '../../core/logger.js';
|
||
import {
|
||
isLocalEmbeddingRuntimeBlockerMessage,
|
||
isMissingLocalEmbeddingStackMessage,
|
||
} from '../../core/embeddings/runtime-support.js';
|
||
import {
|
||
LIST_REPOS_DEFAULT_LIMIT,
|
||
LIST_REPOS_MAX_LIMIT,
|
||
EXPLAIN_DEFAULT_LIMIT,
|
||
EXPLAIN_MAX_LIMIT,
|
||
PDG_QUERY_DEFAULT_LIMIT,
|
||
PDG_QUERY_MAX_LIMIT,
|
||
} from '../tools.js';
|
||
import { findImportCycles } from '../../core/graph/import-cycles.js';
|
||
import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
|
||
import { decodeReachingDefReason } from '../../core/ingestion/cfg/reaching-def-reason-codec.js';
|
||
import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
|
||
import { compareCodeUnits } from '../../lib/utils.js';
|
||
import {
|
||
lookupExternalCallCount,
|
||
lookupUnresolvedCallCount,
|
||
} from '../../core/ingestion/scope-resolution/unresolved-receivers.js';
|
||
import {
|
||
fnLineOf,
|
||
isPdgDegradedLayerStatus,
|
||
makePdgImpactErrorResult,
|
||
makePdgLayerDegradedResult,
|
||
pdgLayerStatus,
|
||
pdgStampForMode,
|
||
runImpactPDG,
|
||
validateImpactMode,
|
||
pdgBridgeEvidenceForImpact,
|
||
betterBridgeEvidence,
|
||
composeUnifiedPdgImpactResult,
|
||
splitCalleeIds,
|
||
type ImpactMode,
|
||
type PdgImpactResult,
|
||
type PdgImpactErrorResult,
|
||
type PdgImpactTarget,
|
||
type PdgBridgeOptions,
|
||
type PdgBridgeEvidenceInfo,
|
||
type PdgLayerStatus,
|
||
} from './pdg-impact.js';
|
||
|
||
/**
|
||
* Candidate `type`s that label enrichment newly populates (#2687). Before that,
|
||
* these surfaced as `''`, which several resolution gates read as "kind unknown".
|
||
* Anything keyed on the empty string must name these explicitly.
|
||
*/
|
||
const VALUE_CANDIDATE_TYPES: ReadonlySet<string> = new Set(['Const', 'Variable', 'Static']);
|
||
|
||
/**
|
||
* Row cap on the name-resolution window in `resolveSymbolCandidates`. Named
|
||
* because the SQL `LIMIT` and the "is this window the complete match set?"
|
||
* guard must agree — a short page (`rows.length < CANDIDATE_WINDOW`) is the
|
||
* only truncation signal available when the COUNT leg fails.
|
||
*/
|
||
const CANDIDATE_WINDOW = 20;
|
||
|
||
/**
|
||
* The pieces every ambiguous-resolution payload shares, derived once.
|
||
*
|
||
* `outcome.total` is the resolver's real match count, NOT `candidates.length` —
|
||
* the window caps at {@link CANDIDATE_WINDOW}, so reporting the array length
|
||
* claimed 20 matches when 92 existed (and `rename` inherits `context`'s payload
|
||
* verbatim). `totalIsLowerBound` must travel with that count everywhere it is
|
||
* reported (#2787 review F3), INCLUDING the prose, because the message is what
|
||
* an agent actually reads: without it a failed COUNT reads as an exact 20. And
|
||
* truncation is measured against `total`, never against the window — the old
|
||
* form said "6 of 20" when 92 matched.
|
||
*
|
||
* Six call sites rebuilt all of that by hand with slightly different spellings.
|
||
* Deriving it in one place is what stops the invariants drifting apart again,
|
||
* the same reason `truncationFields` exists in `core/group/cross-impact.ts`.
|
||
*
|
||
* @param shownCount how many candidates the payload actually carries — the full
|
||
* window for `context`/`trace`, the `AMBIGUOUS_MAX_CANDIDATES` slice for
|
||
* `impact`.
|
||
* @param withTotal picks the suffix form: `(showing 6 of 92)` for the `impact`
|
||
* paths that slice, `(showing 20)` for the paths that return the whole window.
|
||
*/
|
||
function ambiguityReport(
|
||
outcome: { total: number; totalIsLowerBound?: boolean },
|
||
shownCount: number,
|
||
withTotal = false,
|
||
): {
|
||
/** `'at least '` when `total` is only a lower bound, else `''`. */
|
||
atLeast: string;
|
||
/** ` (showing N[ of M])` when the shown list is short of `total`, else `''`. */
|
||
showing: string;
|
||
/** The response fields the payload spreads verbatim. */
|
||
fields: {
|
||
totalCandidates: number;
|
||
totalIsLowerBound?: true;
|
||
candidatesTruncated?: true;
|
||
};
|
||
} {
|
||
const truncated = outcome.total > shownCount;
|
||
return {
|
||
atLeast: outcome.totalIsLowerBound ? 'at least ' : '',
|
||
showing: truncated
|
||
? withTotal
|
||
? ` (showing ${shownCount} of ${outcome.total})`
|
||
: ` (showing ${shownCount})`
|
||
: '',
|
||
fields: {
|
||
totalCandidates: outcome.total,
|
||
...(outcome.totalIsLowerBound ? { totalIsLowerBound: true as const } : {}),
|
||
...(truncated ? { candidatesTruncated: true as const } : {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
|
||
* excluding the empty entry and the `/index.*` forms — used to decide whether
|
||
* an `explain` target is a file path vs a (possibly dotted) symbol name. */
|
||
const SOURCE_FILE_EXTENSIONS: readonly string[] = EXTENSIONS.filter(
|
||
(e) => e.startsWith('.') && !e.includes('/'),
|
||
);
|
||
/** A target is path-ish if it has a path separator or ends in a known source
|
||
* extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
|
||
function looksLikeFilePath(target: string): boolean {
|
||
if (/[\\/]/.test(target)) return true;
|
||
const lower = target.toLowerCase();
|
||
return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||
}
|
||
|
||
/**
|
||
* Resolve a string tool param from its canonical name or legacy alias (#2175).
|
||
* Returns the first NON-BLANK string of [canonical, legacy] — the canonical (new)
|
||
* name is preferred when it carries a real value, otherwise the legacy value is used.
|
||
* A blank/whitespace new value therefore does NOT clobber a valid legacy value (e.g. a
|
||
* gradually-migrating client that always emits the new key, blank when unset). A
|
||
* non-string value (the MCP envelope is not schema-validated, so clients can send any
|
||
* JSON type) and an all-blank input resolve to `undefined`, so the caller returns a
|
||
* friendly required-param error instead of throwing `TypeError` on `.trim()`.
|
||
*/
|
||
function resolveAliasString(canonical: unknown, legacy: unknown): string | undefined {
|
||
for (const value of [canonical, legacy]) {
|
||
if (typeof value === 'string' && value.trim()) return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
interface StringAliasDefinition {
|
||
canonical: string;
|
||
aliases: readonly string[];
|
||
}
|
||
|
||
const TOOL_STRING_ALIASES: Readonly<Record<string, readonly StringAliasDefinition[]>> = {
|
||
impact: [{ canonical: 'target', aliases: ['name', 'symbol'] }],
|
||
context: [{ canonical: 'file_path', aliases: ['file'] }],
|
||
trace: [{ canonical: 'from_file', aliases: ['file'] }],
|
||
};
|
||
|
||
function normalizeToolParams(
|
||
method: string,
|
||
params: unknown,
|
||
): { params: Record<string, unknown> } | { error: string } {
|
||
const input = params && typeof params === 'object' ? (params as Record<string, unknown>) : {};
|
||
const definitions = TOOL_STRING_ALIASES[method];
|
||
if (!definitions) return { params: input };
|
||
|
||
const normalized = { ...input };
|
||
for (const { canonical, aliases } of definitions) {
|
||
const keys = [canonical, ...aliases];
|
||
const supplied: Array<{ key: string; value: string }> = [];
|
||
for (const key of keys) {
|
||
if (!Object.prototype.hasOwnProperty.call(input, key)) continue;
|
||
const value = input[key];
|
||
// Internal CLI callers materialize omitted optional flags as undefined.
|
||
if (value === undefined) continue;
|
||
if (typeof value !== 'string' || !value.trim()) {
|
||
return { error: `MCP parameter ${method}.${key} must be a non-empty string.` };
|
||
}
|
||
supplied.push({ key, value: value.trim() });
|
||
}
|
||
const distinctValues = new Set(supplied.map(({ value }) => value));
|
||
if (distinctValues.size > 1) {
|
||
return {
|
||
error: `Conflicting MCP parameters for ${method}.${canonical}: ${supplied
|
||
.map(({ key }) => key)
|
||
.join(', ')} must agree.`,
|
||
};
|
||
}
|
||
|
||
for (const alias of aliases) delete normalized[alias];
|
||
if (supplied.length > 0) normalized[canonical] = supplied[0].value;
|
||
}
|
||
|
||
if (
|
||
method === 'impact' &&
|
||
typeof normalized.target !== 'string' &&
|
||
(typeof normalized.target_uid !== 'string' || !normalized.target_uid.trim())
|
||
) {
|
||
return { error: 'MCP impact requires target, name, symbol, or target_uid.' };
|
||
}
|
||
return { params: normalized };
|
||
}
|
||
|
||
// AI context generation is CLI-only (gitnexus analyze)
|
||
// import { generateAIContextFiles } from '../../cli/ai-context.js';
|
||
|
||
/**
|
||
* Quick test-file detection for filtering impact results.
|
||
* Matches common test file patterns across all supported languages.
|
||
*/
|
||
export function isTestFilePath(filePath: string | null | undefined): boolean {
|
||
if (!filePath) return false;
|
||
const p = filePath.toLowerCase().replace(/\\/g, '/');
|
||
return (
|
||
p.includes('.test.') ||
|
||
p.includes('.spec.') ||
|
||
p.includes('__tests__/') ||
|
||
p.includes('__mocks__/') ||
|
||
p.includes('/test/') ||
|
||
p.includes('/tests/') ||
|
||
p.includes('/testing/') ||
|
||
p.includes('/fixtures/') ||
|
||
p.endsWith('_test.go') ||
|
||
p.endsWith('_test.py') ||
|
||
p.endsWith('_spec.rb') ||
|
||
p.endsWith('_test.rb') ||
|
||
p.includes('/spec/') ||
|
||
p.includes('/test_') ||
|
||
p.includes('/conftest.')
|
||
);
|
||
}
|
||
|
||
/** Valid LadybugDB node labels for safe Cypher query construction */
|
||
export const VALID_NODE_LABELS = new Set([
|
||
'File',
|
||
'Folder',
|
||
'Function',
|
||
'Class',
|
||
'Interface',
|
||
'Method',
|
||
'CodeElement',
|
||
'Community',
|
||
'Process',
|
||
'Struct',
|
||
'Enum',
|
||
'Macro',
|
||
'Typedef',
|
||
'Union',
|
||
'Namespace',
|
||
'Trait',
|
||
'Impl',
|
||
'TypeAlias',
|
||
'Const',
|
||
'Static',
|
||
'Property',
|
||
'Record',
|
||
'Delegate',
|
||
'Annotation',
|
||
'Constructor',
|
||
'Template',
|
||
'Module',
|
||
'Route',
|
||
'Tool',
|
||
]);
|
||
|
||
/** Valid relation types for impact analysis filtering */
|
||
export const VALID_RELATION_TYPES = new Set([
|
||
'CALLS',
|
||
'IMPORTS',
|
||
'EXTENDS',
|
||
'IMPLEMENTS',
|
||
'HAS_METHOD',
|
||
'HAS_PROPERTY',
|
||
'METHOD_OVERRIDES',
|
||
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
|
||
'METHOD_IMPLEMENTS',
|
||
'ACCESSES',
|
||
// Emitted by emit-references.ts / scope-resolution/graph-bridge/edges.ts and
|
||
// already part of the default impact relTypes + context() incoming queries.
|
||
// It was missing from this allowlist, so `impact({relationTypes:['USES']})`
|
||
// silently filtered to [] and fell back to the full default traversal
|
||
// (#2129/#1858 review F5). No IMPACT_RELATION_CONFIDENCE floor → 0.5 fallback,
|
||
// matching the FETCHES / WRAPS / HANDLES_ROUTE precedent below.
|
||
'USES',
|
||
'HANDLES_ROUTE',
|
||
'FETCHES',
|
||
'HANDLES_TOOL',
|
||
'ENTRY_POINT_OF',
|
||
'WRAPS',
|
||
// Emitted by the `di` pipeline phase (#2200 — DI collection injection,
|
||
// consumer Class → implementer Class). Valid here for explicit
|
||
// `relationTypes` filters, but deliberately NOT in the default impact()
|
||
// relTypes nor the context() incoming/outgoing lists — traversal is opt-in,
|
||
// like WRAPS/FETCHES. Also deliberately NO IMPACT_RELATION_CONFIDENCE entry
|
||
// (WRAPS/FETCHES precedent): the 0.5 unknown-type floor applies there,
|
||
// and the edges carry their own confidence (0.8) in the graph.
|
||
'INJECTS',
|
||
// Conditional and metadata-declaration evidence is opt-in for impact
|
||
// traversal, like INJECTS: explicit filters can follow activation
|
||
// constraints and declarations without changing the default callgraph
|
||
// surface.
|
||
'CONDITIONAL_ON',
|
||
'DECLARES',
|
||
// Spring proxy/advice evidence (#2416). Opt-in for traversal so existing
|
||
// impact defaults do not silently widen; target enrichment still surfaces
|
||
// advised/proxied state on ordinary impact calls.
|
||
'ADVISED_BY',
|
||
]);
|
||
|
||
/**
|
||
* Relation types the #1858 epistemic-boundary probe keys on. Kept as
|
||
* module-level `readonly` arrays (not Sets) because computeEpistemicBoundary
|
||
* binds them as Cypher query params (`r.type IN $heritage` / `IN $types`).
|
||
* The heritage set is exactly the IMPACT_RELATION_CONFIDENCE 0.85 tier —
|
||
* "statically verifiable, but the concrete binding past it is not".
|
||
*/
|
||
export const EPISTEMIC_HERITAGE_RELATION_TYPES: readonly string[] = [
|
||
'IMPLEMENTS',
|
||
'METHOD_IMPLEMENTS',
|
||
'EXTENDS',
|
||
];
|
||
export const EPISTEMIC_CONSUMER_RELATION_TYPES: readonly string[] = ['CALLS', 'USES', 'ACCESSES'];
|
||
|
||
/**
|
||
* Per-relation-type confidence floor for impact analysis.
|
||
*
|
||
* When the graph stores a relation with a confidence value, that stored
|
||
* value is used as-is (it reflects resolution-tier accuracy from analysis
|
||
* time). This map provides the floor for each edge type when no stored
|
||
* confidence is available, and is also used for display / tooltip hints.
|
||
*
|
||
* Rationale:
|
||
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
|
||
* EXTENDS – class hierarchy, statically verifiable → 0.85
|
||
* IMPLEMENTS – interface contract, statically verifiable → 0.85
|
||
* METHOD_OVERRIDES – method override, statically verifiable → 0.85
|
||
* METHOD_IMPLEMENTS – interface method implementation, statically verifiable → 0.85
|
||
* HAS_METHOD – structural containment → 0.95
|
||
* HAS_PROPERTY – structural containment → 0.95
|
||
* ACCESSES – field read/write, may be indirect → 0.8
|
||
* CONTAINS – folder/file containment → 0.95
|
||
* (unknown type) – conservative fallback → 0.5
|
||
*/
|
||
export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
|
||
CALLS: 0.9,
|
||
IMPORTS: 0.9,
|
||
EXTENDS: 0.85,
|
||
IMPLEMENTS: 0.85,
|
||
METHOD_OVERRIDES: 0.85,
|
||
METHOD_IMPLEMENTS: 0.85,
|
||
HAS_METHOD: 0.95,
|
||
HAS_PROPERTY: 0.95,
|
||
ACCESSES: 0.8,
|
||
CONTAINS: 0.95,
|
||
};
|
||
|
||
/**
|
||
* Return the confidence floor for a given relation type.
|
||
* Falls back to 0.5 for unknown types so they are not silently elevated.
|
||
*/
|
||
const confidenceForRelType = (relType: string | undefined): number =>
|
||
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
|
||
|
||
/**
|
||
* One row of `_runImpactBFS`'s per-depth frontier query, normalized to the seven
|
||
* columns the traversal consumes. The driver hands rows back keyed by name on
|
||
* some paths and positionally on others, so each column keeps the exact
|
||
* named-then-positional fallback the BFS has always used — but reads it once,
|
||
* here, so everything below the normalization sees a single shape.
|
||
*/
|
||
interface ImpactFrontierEdge {
|
||
/** Reached node id — `caller.id` upstream, `callee.id` downstream. */
|
||
id: string;
|
||
name: unknown;
|
||
type: unknown;
|
||
filePath: string;
|
||
/** `r.type`. Never null: the query filters on `r.type IN $relTypes`. */
|
||
relType: string;
|
||
/** `r.confidence`. May be null; those edges fall back to the per-type floor. */
|
||
confidence: unknown;
|
||
/** `n.id` — the frontier node this edge was reached FROM. */
|
||
sourceId: string;
|
||
}
|
||
|
||
/**
|
||
* Sort rank for the `confidence DESC` leg of {@link compareImpactEdgeStrength}.
|
||
* A null/non-numeric confidence ranks LAST (weakest), so a stored measurement
|
||
* always outranks a missing one. That is the same "keep the strongest evidence"
|
||
* intent the ordering exists for — and stating it here is the point: what an
|
||
* engine does with NULLs inside a DESC sort key is its choice, not ours.
|
||
*/
|
||
const impactEdgeConfidenceRank = (confidence: unknown): number =>
|
||
typeof confidence === 'number' && Number.isFinite(confidence)
|
||
? confidence
|
||
: Number.NEGATIVE_INFINITY;
|
||
|
||
/**
|
||
* `relType ASC, confidence DESC, sourceId ASC` — decides which of several edges
|
||
* reaching the SAME node stamps its `relationType`/`confidence` onto that node's
|
||
* `impacted` entry. Total over those three columns, so the winner is a genuine
|
||
* argmax rather than "whichever row the engine happened to emit first".
|
||
*/
|
||
function compareImpactEdgeStrength(a: ImpactFrontierEdge, b: ImpactFrontierEdge): number {
|
||
const byType = compareCodeUnits(a.relType, b.relType);
|
||
if (byType !== 0) return byType;
|
||
const aConfidence = impactEdgeConfidenceRank(a.confidence);
|
||
const bConfidence = impactEdgeConfidenceRank(b.confidence);
|
||
if (aConfidence !== bConfidence) return aConfidence > bConfidence ? -1 : 1;
|
||
return compareCodeUnits(a.sourceId, b.sourceId);
|
||
}
|
||
|
||
/**
|
||
* The whole former DB key: `id ASC` then {@link compareImpactEdgeStrength}.
|
||
* Orders the winning edges appended to `impacted` (distinct nodes, so `id` alone
|
||
* decides) and — on the `mode:'pdg'` bridge path only — the edge rows themselves.
|
||
*/
|
||
function compareImpactFrontierEdges(a: ImpactFrontierEdge, b: ImpactFrontierEdge): number {
|
||
return compareCodeUnits(a.id, b.id) || compareImpactEdgeStrength(a, b);
|
||
}
|
||
|
||
/**
|
||
* Structured logging for *swallowed* query failures — replaces empty catch
|
||
* blocks. The level reflects telemetry severity, NOT a promise about the
|
||
* caller: most callers catch the failure and degrade to a genuinely safe
|
||
* fallback (a usable result, usually with a caller-visible `partial`/`ftsUsed`
|
||
* flag), so these are not operation-level errors and must not log at `error`:
|
||
*
|
||
* - A benign missing optional table/label/column — a repo analyzed without
|
||
* processes/communities, or a pre-v3 PDG index lacking the `calleeIds`
|
||
* column — is a normal configuration, not a failure. Logged at `debug`
|
||
* (suppressed at the default `info` level; surfaced only when troubleshooting).
|
||
* - Any other swallowed failure is an unexpected-but-handled degradation:
|
||
* logged at `warn` so it stays observable without raising a false `error`
|
||
* alarm that would drown genuine, operation-aborting failures.
|
||
*
|
||
* `error` is intentionally NOT used here — it is reserved for failures that
|
||
* actually abort an operation, which log directly rather than through this
|
||
* best-effort-degradation helper.
|
||
*
|
||
* Contract for callers (#2283 review): only route a failure here when the
|
||
* caller ALSO surfaces the degradation in its result (a `partial` flag,
|
||
* `failed_files`, `traversalComplete:false`, …). A mutating or safety-critical
|
||
* path that would otherwise report success/clean (e.g. `rename` apply, the
|
||
* `detect_changes` safety gate) MUST set that result-level signal — `warn`
|
||
* alone is not a substitute for an honest result.
|
||
*/
|
||
function logQueryError(context: string, err: unknown): void {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
if (isBenignMissingTableError(err)) {
|
||
logger.debug({ context, err: msg }, 'GitNexus query skipped (missing optional data)');
|
||
return;
|
||
}
|
||
logger.warn({ context, err: msg }, 'GitNexus query failed (degraded)');
|
||
}
|
||
|
||
/**
|
||
* A "missing table/label/relation" prepare error is benign for the query tool's
|
||
* best-effort enrichment: a repo analyzed without processes or communities simply
|
||
* has no `Process`/`Community` tables, so the `STEP_IN_PROCESS` / `MEMBER_OF`
|
||
* enrichment queries fail to prepare. That is a normal configuration, NOT a
|
||
* degraded result — it must not raise the `partial` flag (which callers would
|
||
* then learn to ignore). Real failures (timeouts, locks, native faults) do.
|
||
*/
|
||
function isBenignMissingTableError(err: unknown): boolean {
|
||
const msg = err instanceof Error ? err.message : String(err ?? '');
|
||
// The `not (defined|found)` arm is scoped to a schema object (table/label/
|
||
// rel/column/property), mirroring lbug-adapter's isMissingColumnError
|
||
// (`/(table|column|property).*not found/i`): an unscoped "not found" matched
|
||
// operation failures like `rg: not found` (ripgrep absent) or `Symbol not
|
||
// found`, which this helper would then silently demote to `debug` (#2283).
|
||
return /does not exist|no such (table|label|rel)|unknown (table|label)|(table|label|rel|column|property)[^\n]*\bnot (defined|found)\b/i.test(
|
||
msg,
|
||
);
|
||
}
|
||
|
||
const isReadOnlyDbError = (err: unknown): boolean => {
|
||
// Walk the `cause` chain (bounded) so a wrapped read-only error (e.g. the
|
||
// pool adapter's `{ cause }` wrapper) is still detected here — this is the
|
||
// copy the MCP cypher handler uses to surface its curated read-only message
|
||
// (#2068 follow-up). Mirrors lbug-adapter's isReadOnlyDbError.
|
||
let cur: unknown = err;
|
||
for (let depth = 0; depth < 5 && cur != null; depth++) {
|
||
const msg = cur instanceof Error ? cur.message : String(cur);
|
||
if (/read-only database/i.test(msg)) return true;
|
||
cur = cur instanceof Error ? (cur as { cause?: unknown }).cause : undefined;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
/**
|
||
* Per-query latency telemetry for production aggregation (#553).
|
||
*
|
||
* Logged at `debug` level — timing is observability/telemetry, not an
|
||
* error. Operators wanting per-query timing set `GITNEXUS_LOG_LEVEL=debug`
|
||
* (or equivalent). Emitting at `error` level (the original migration
|
||
* artifact) caused alerting rules to fire on every successful query and
|
||
* inflated stderr noise for every MCP/CLI invocation.
|
||
*
|
||
* Emitted via the project logger which routes to stderr — never stdout —
|
||
* because the MCP stdio transport uses stdout exclusively for JSON-RPC
|
||
* responses (#324) and the CLI e2e test `tool output goes to stdout via
|
||
* fd 1` asserts stdout parses cleanly as JSON.
|
||
*/
|
||
function logQueryTiming(query: string, phases: Record<string, number>): void {
|
||
const totalMs = phases.wall ?? Object.values(phases).reduce((a, b) => a + b, 0);
|
||
const truncated = query.length > 80 ? `${query.slice(0, 80)}…` : query;
|
||
logger.debug({ query: truncated, totalMs, phases }, 'GitNexus query timing');
|
||
}
|
||
|
||
export interface CodebaseContext {
|
||
projectName: string;
|
||
stats: {
|
||
fileCount: number;
|
||
functionCount: number;
|
||
communityCount: number;
|
||
processCount: number;
|
||
};
|
||
}
|
||
|
||
/** Collapse dropped-site boundary notes into an epistemic verdict: any note at
|
||
* all means the count is a lower bound, none means it is exact (#2744). */
|
||
/**
|
||
* Why a count is a lower bound, as a machine-readable split.
|
||
*
|
||
* `epistemic` is a single enum and `boundaries` is prose, so a consumer that is
|
||
* not a human — a coding agent gating its own edits on this result — can tell
|
||
* THAT the answer is short but not WHY, and cannot branch on the difference.
|
||
* The two causes are independent and have opposite remedies:
|
||
*
|
||
* - `receiverTyping` — the analyzer dropped call sites because it could not
|
||
* establish the receiver's type. A resolver defect. Fixable, and shrinking:
|
||
* this is the population the structural-receiver work targets.
|
||
* - `dispatchBoundary` — the symbol sits behind an interface with real
|
||
* consumers or multiple implementations, so callers binding through a DI
|
||
* container or dynamic dispatch are genuinely untraceable statically. NOT a
|
||
* defect; a compiler would refuse here too.
|
||
*
|
||
* Collapsing them told the reader "impact may be higher" for both, which made
|
||
* the fixable cause indistinguishable from the irreducible one — and made
|
||
* "the hedge should stop appearing" an unfalsifiable goal, because there was no
|
||
* way to see which producer was still firing.
|
||
*
|
||
* Every field counts MISSING THINGS, never notes. The unit is stated per field
|
||
* because the two producers can only measure at different granularities (see
|
||
* `dispatchBoundary`), and a consumer comparing the numbers has to know which
|
||
* it is holding. Counting notes here is the specific mistake to avoid: there is
|
||
* one note per symbol name / per boundary node, so a note count reports the
|
||
* number of SENTENCES, which has no relation to how much is missing.
|
||
*/
|
||
export interface EpistemicCauses {
|
||
/**
|
||
* Call SITES dropped at index time because the receiver's type could not be
|
||
* established. Unit: call sites, taken from the index's
|
||
* `unresolvedReceiverMembers` summary — the same number the prose note quotes.
|
||
*/
|
||
readonly receiverTyping: number;
|
||
/**
|
||
* Symbols on the far side of a dispatch boundary that the traversal could not
|
||
* attribute to the queried symbol: implementations plus interface-level
|
||
* consumers, summed over the boundary nodes that were flagged.
|
||
*
|
||
* Unit: SYMBOLS, not call sites — deliberately, because a call-site count is
|
||
* not derivable on this side. The graph does not retain per-site multiplicity
|
||
* for these edges: consumers are counted with `COUNT(DISTINCT other.id)`, and
|
||
* languages that set `collapseMemberCallsByCallerTarget` emit one CALLS edge
|
||
* per (caller, target) pair no matter how many syntactic sites exist. A
|
||
* symbol reachable through two flagged boundary nodes is counted once per
|
||
* node, so this is itself a lower bound.
|
||
*
|
||
* It is still directly comparable in magnitude with `receiverTyping` — both
|
||
* answer "how much is missing" — which `boundaries.length` was not.
|
||
*/
|
||
readonly dispatchBoundary: number;
|
||
/**
|
||
* Call sites whose receiver was rooted OUTSIDE the indexed program —
|
||
* `System.out.println`, `fetch(...)`, `os.environ.*`. Reported, but NOT a
|
||
* reason the count is short: there is no in-graph node an edge could have
|
||
* reached, so the analysis is complete for the program as given.
|
||
*
|
||
* Surfaced so "no uncertainty" is distinguishable from "we judged 76 calls to
|
||
* be outside the program". A compiler resolves these against the JDK / BCL /
|
||
* lib.d.ts; lacking those, this number IS the boundary.
|
||
*
|
||
* Unit: call sites — same unit and same source as `receiverTyping`.
|
||
*/
|
||
readonly externalBoundary: number;
|
||
}
|
||
|
||
function epistemicFrom(dropped: { notes: readonly string[]; sites: number; external: number }): {
|
||
epistemic: 'exact' | 'lower-bound';
|
||
boundaries?: string[];
|
||
causes?: EpistemicCauses;
|
||
} {
|
||
// An index whose only drops were external still reports `exact` — nothing was
|
||
// lost — but carries the boundary count so "complete" is distinguishable from
|
||
// "we judged N calls to leave the program".
|
||
return dropped.notes.length === 0
|
||
? dropped.external > 0
|
||
? {
|
||
epistemic: 'exact',
|
||
causes: { receiverTyping: 0, dispatchBoundary: 0, externalBoundary: dropped.external },
|
||
}
|
||
: { epistemic: 'exact' }
|
||
: {
|
||
epistemic: 'lower-bound',
|
||
boundaries: [...dropped.notes],
|
||
// SITES, not notes. There is one note per symbol name but it reports N
|
||
// dropped sites, so counting notes would have published `1` next to
|
||
// prose saying `2 call sites` — a consumer branching on the number
|
||
// would read a different magnitude than the human reading the text.
|
||
causes: {
|
||
receiverTyping: dropped.sites,
|
||
dispatchBoundary: 0,
|
||
externalBoundary: dropped.external,
|
||
},
|
||
};
|
||
}
|
||
|
||
interface RepoHandle {
|
||
id: string; // unique key = repo name (basename)
|
||
name: string;
|
||
repoPath: string;
|
||
storagePath: string;
|
||
lbugPath: string;
|
||
indexedAt: string;
|
||
lastCommit: string;
|
||
remoteUrl?: string;
|
||
stats?: RegistryEntry['stats'];
|
||
/** Primary/flat branch name, when known (#2106). */
|
||
branch?: string;
|
||
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
||
branches?: BranchSummary[];
|
||
}
|
||
|
||
/** Resolve symlinks for path comparison; falls back to path.resolve on error.
|
||
* Uses `realpathSync.native` (not the pure-JS `realpathSync`) so that Windows
|
||
* 8.3 short names (e.g. RUNNER~1 → runneradmin) are expanded to long form,
|
||
* matching the output of `git rev-parse --show-toplevel`. */
|
||
function tryRealpath(p: string): string {
|
||
try {
|
||
return realpathSync.native(p);
|
||
} catch {
|
||
return path.resolve(p);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
|
||
*
|
||
* When `launchCwd` is a linked worktree of the same canonical repository as
|
||
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
|
||
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
|
||
* that `git diff` sees the correct working directory and index.
|
||
*
|
||
* Returns `repoPath` unchanged in all other cases (non-worktree, git
|
||
* unavailable, unrelated repo).
|
||
*
|
||
* Extracted as a module-level export so tests can pass any `launchCwd` instead
|
||
* of relying on `process.cwd()`, which is fixed to the server launch directory
|
||
* and cannot be changed mid-process.
|
||
*/
|
||
export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string {
|
||
try {
|
||
// Verify repoPath is a git root before comparing against its canonical
|
||
// root. If getGitRoot returns a different path, repoPath is an arbitrary
|
||
// subdirectory — skip both the linked-worktree guard and auto-detection
|
||
// and fall through to the repoPath fallback.
|
||
const repoGitRoot = getGitRoot(repoPath);
|
||
const repoCanonical =
|
||
repoGitRoot && tryRealpath(repoGitRoot) === tryRealpath(repoPath)
|
||
? getCanonicalRepoRoot(repoPath)
|
||
: null;
|
||
|
||
// Early exit: if repoPath is a linked worktree (differs from its canonical
|
||
// main-checkout root), return it unchanged. Do NOT override it with the
|
||
// server's launch directory — that would silently replace the explicitly-
|
||
// resolved worktree index with the main checkout.
|
||
//
|
||
// getCanonicalRepoRoot returns the main-checkout path for both the checkout
|
||
// and all linked worktrees:
|
||
// repoPath === canonical → main checkout (auto-detect may fire below)
|
||
// repoPath !== canonical → linked worktree (return as-is)
|
||
if (repoCanonical && tryRealpath(repoPath) !== tryRealpath(repoCanonical)) {
|
||
return repoPath;
|
||
}
|
||
|
||
const launchGitRoot = getGitRoot(launchCwd);
|
||
if (launchGitRoot) {
|
||
// Normalise via realpathSync before comparing so macOS /var → /private/var
|
||
// symlinks (and Windows 8.3 short names) don't create false mismatches.
|
||
const realLaunch = tryRealpath(launchGitRoot);
|
||
const realRepo = tryRealpath(repoPath);
|
||
if (realLaunch !== realRepo) {
|
||
const launchCanonical = getCanonicalRepoRoot(launchCwd);
|
||
// Use tryRealpath on both canonical values for cross-platform safety.
|
||
if (
|
||
launchCanonical &&
|
||
repoCanonical &&
|
||
tryRealpath(launchCanonical) === tryRealpath(repoCanonical)
|
||
) {
|
||
return launchGitRoot;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Best-effort; fall through to repoPath.
|
||
}
|
||
return repoPath;
|
||
}
|
||
|
||
/**
|
||
* Length of the path-derived suffix appended to a colliding repo id.
|
||
* Exported so tests can pin the suffix shape without re-deriving the
|
||
* literal; see `assignRepoId()` and the hashed-id resolution tier (#1658).
|
||
*
|
||
* Note: base64url is an *encoding*, not a hash — it preserves byte order, so
|
||
* two paths that share a long common prefix (sibling clones under one parent)
|
||
* collapse to the same sliced suffix. `assignRepoId()` keeps the legacy
|
||
* base64url suffix only for the first colliding duplicate (id compatibility)
|
||
* and falls back to a content hash of the resolved path on a real collision
|
||
* (#2054).
|
||
*/
|
||
export const REPO_ID_HASH_LENGTH = 6;
|
||
|
||
interface TraceParams {
|
||
from?: string;
|
||
from_uid?: string;
|
||
from_file?: string;
|
||
to?: string;
|
||
to_uid?: string;
|
||
to_file?: string;
|
||
maxDepth?: number;
|
||
includeTests?: boolean;
|
||
}
|
||
|
||
interface ImpactParams {
|
||
target: string;
|
||
target_uid?: string;
|
||
file_path?: string;
|
||
kind?: string;
|
||
direction: 'upstream' | 'downstream';
|
||
/**
|
||
* Blast-radius engine (KTD1/KTD5). Absent / `undefined` / `'callgraph'` →
|
||
* the unchanged inter-procedural symbol→symbol BFS. `'pdg'` → the opt-in,
|
||
* intra-procedural Program Dependence Graph traversal (`_runImpactPDG`).
|
||
* Validated in `_impactImpl`; any other value is a hard `{ error }`.
|
||
*/
|
||
mode?: ImpactMode;
|
||
/**
|
||
* Statement anchor for `mode:'pdg'` (1-based source line). When provided, the
|
||
* PDG traversal seeds the dependence slice on the BasicBlock(s) at THIS line
|
||
* within the target symbol — answering "what statements depend on the code at
|
||
* line N?" — instead of the whole-symbol seed (which is empty for a function,
|
||
* since its intra-procedural reach stays inside its own blocks). Only
|
||
* meaningful with `mode:'pdg'`; rejected for `mode:'callgraph'`.
|
||
*/
|
||
line?: number;
|
||
maxDepth?: number;
|
||
crossDepth?: number;
|
||
relationTypes?: string[];
|
||
includeTests?: boolean;
|
||
minConfidence?: number;
|
||
limit?: number;
|
||
offset?: number;
|
||
summaryOnly?: boolean;
|
||
}
|
||
|
||
/** One route in an `api_impact` result. `executionFlows` are process names. */
|
||
interface ApiImpactRoute {
|
||
route: string;
|
||
method: string | null;
|
||
handler: string;
|
||
responseShape: { success: string[]; error: string[] };
|
||
middleware: string[];
|
||
middlewareDetection?: 'partial';
|
||
middlewareNote?: string;
|
||
consumers: Array<{ name: string; file: string; accesses: string[]; attributionNote?: string }>;
|
||
mismatches?: Array<{
|
||
consumer: string;
|
||
field: string;
|
||
reason: string;
|
||
confidence: 'high' | 'low';
|
||
}>;
|
||
executionFlows: string[];
|
||
impactSummary: {
|
||
directConsumers: number;
|
||
affectedFlows: number;
|
||
riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||
warning?: string;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* `api_impact` is polymorphic by match count: a single matched route returns the
|
||
* route object directly; two or more return the wrapped `{ routes, total }`
|
||
* form; any guard failure returns `{ error }`.
|
||
*/
|
||
type ApiImpactResult =
|
||
| ApiImpactRoute
|
||
| { routes: ApiImpactRoute[]; total: number }
|
||
| { error: string };
|
||
|
||
/**
|
||
* One repository entry as returned by {@link LocalBackend.listRepos} and in each
|
||
* `list_repos` page. Named so the `listRepos`/`listReposPage` return types read
|
||
* clearly instead of an opaque `Awaited<ReturnType<…>>` expression.
|
||
*/
|
||
export interface RepoListing {
|
||
name: string;
|
||
path: string;
|
||
indexedAt: string;
|
||
lastCommit: string;
|
||
remoteUrl?: string;
|
||
stats?: any;
|
||
staleness?: { commitsBehind: number; hint?: string };
|
||
siblings?: Array<{ name: string; path: string; lastCommit: string }>;
|
||
/** Primary/flat branch name, when known (#2106). */
|
||
branch?: string;
|
||
/** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */
|
||
branches?: Array<Omit<BranchSummary, 'stats'>>;
|
||
}
|
||
|
||
/** Continuation metadata for the paginated `list_repos` MCP tool (#2119). */
|
||
export interface ListReposPagination {
|
||
/** Total repositories across all pages. */
|
||
total: number;
|
||
/** Effective page size used (equals the requested limit; out-of-range is rejected, not clamped). */
|
||
limit: number;
|
||
/** Offset this page started at. */
|
||
offset: number;
|
||
/** Number of repositories actually returned in this page. */
|
||
returned: number;
|
||
/** True when more repositories remain past this page. */
|
||
hasMore: boolean;
|
||
/** Offset to request next; present only when `hasMore` is true. */
|
||
nextOffset?: number;
|
||
}
|
||
|
||
/**
|
||
* Validate and normalise `list_repos` pagination arguments.
|
||
*
|
||
* @internal Exported for unit testing; not part of the public API surface.
|
||
*
|
||
* There is NO MCP-SDK-level enforcement of a tool's advertised `inputSchema`
|
||
* (the SDK validates only the JSON-RPC envelope), and `callTool` is reachable
|
||
* directly, so the backend is the real validation boundary. Malformed values —
|
||
* non-number, `NaN`, non-integer, `limit < 1`, `limit > maxLimit`, or
|
||
* `offset < 0` — are REJECTED with a clear error. `limit` is bounded but NOT
|
||
* silently clamped: an over-max value throws (symmetric with the other bounds)
|
||
* so a client never receives a smaller page than it asked for without knowing.
|
||
* An omitted value (only `undefined`) falls back to the default.
|
||
*/
|
||
export function parseListReposPagination(
|
||
params: { limit?: unknown; offset?: unknown } | null | undefined,
|
||
opts: { defaultLimit: number; maxLimit: number },
|
||
): { limit: number; offset: number } {
|
||
const requireInt = (value: unknown, field: string, min: number, max?: number): number => {
|
||
const valid =
|
||
typeof value === 'number' &&
|
||
Number.isInteger(value) &&
|
||
value >= min &&
|
||
(max === undefined || value <= max);
|
||
if (!valid) {
|
||
const bound = max === undefined ? `>= ${min}` : `between ${min} and ${max}`;
|
||
throw new Error(
|
||
`list_repos: "${field}" must be an integer ${bound} (received ${JSON.stringify(value)})`,
|
||
);
|
||
}
|
||
return value;
|
||
};
|
||
|
||
let limit = opts.defaultLimit;
|
||
if (params?.limit !== undefined) {
|
||
limit = requireInt(params.limit, 'limit', 1, opts.maxLimit);
|
||
}
|
||
|
||
let offset = 0;
|
||
if (params?.offset !== undefined) {
|
||
offset = requireInt(params.offset, 'offset', 0);
|
||
}
|
||
|
||
return { limit, offset };
|
||
}
|
||
|
||
/**
|
||
* #2655: a tool result can carry a `staleness` field only if it is a plain
|
||
* object that isn't an error envelope and doesn't already carry one. Raw-array
|
||
* results (non-tabular `cypher` rows) are excluded because the CLI's `--limit`
|
||
* and other consumers branch on `Array.isArray`, so wrapping them would break
|
||
* that contract. Shared by `attachToolStaleness` and the dispatch site, which
|
||
* uses it to skip the freshness `git` spawn for results that can't carry it.
|
||
*/
|
||
function canCarryStaleness(result: unknown): result is Record<string, unknown> {
|
||
return (
|
||
result !== null &&
|
||
typeof result === 'object' &&
|
||
!Array.isArray(result) &&
|
||
!('error' in result) &&
|
||
!('staleness' in result)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* #2655: attach a non-blocking `staleness` signal to a tool result when the
|
||
* index is behind HEAD, mirroring the `list_repos` `{commitsBehind, hint}`
|
||
* shape. Only ever ADDS a field to a carryable object result (see
|
||
* {@link canCarryStaleness}) — it never changes an existing result's shape.
|
||
*/
|
||
export function attachToolStaleness(
|
||
result: unknown,
|
||
staleness: StalenessInfo | undefined,
|
||
): unknown {
|
||
if (!staleness?.isStale || !canCarryStaleness(result)) {
|
||
return result;
|
||
}
|
||
return {
|
||
...result,
|
||
staleness: { commitsBehind: staleness.commitsBehind, hint: staleness.hint },
|
||
};
|
||
}
|
||
|
||
/** tri-review Residual-2: see `LocalBackend.lastObservedPoolState`'s doc comment. */
|
||
interface PoolObservedState {
|
||
indexedAt?: string;
|
||
dbIdentity: Awaited<ReturnType<typeof statDbIdentity>>;
|
||
ftsStatus?: string;
|
||
}
|
||
|
||
export class LocalBackend {
|
||
private static readonly TOOL_STALENESS_TTL_MS = 5000;
|
||
private repos: Map<string, RepoHandle> = new Map();
|
||
private contextCache: Map<string, CodebaseContext> = new Map();
|
||
private initializedRepos: Set<string> = new Set();
|
||
private reinitPromises: Map<string, Promise<void>> = new Map();
|
||
private lastStalenessCheck: Map<string, number> = new Map();
|
||
// #2655: commit-behind freshness for the hot read tools. Stores the IN-FLIGHT
|
||
// promise (not just a timestamp) so N concurrent tool calls arriving before
|
||
// the first `git rev-list` resolves share one subprocess instead of each
|
||
// spawning their own; the resolved value is reused for TOOL_STALENESS_TTL_MS.
|
||
// Keyed by lbugPath (like lastStalenessCheck) — NOT repoPath — because flat
|
||
// and branch handles for one repo share a repoPath but carry different
|
||
// lastCommit values, so a repoPath key would serve one handle's freshness for
|
||
// the other; lbugPath is unique per flat/branch index.
|
||
private toolStalenessCache: Map<string, { at: number; value: Promise<StalenessInfo> }> =
|
||
new Map();
|
||
// tri-review Residual-2: consolidates what were three parallel per-poolKey
|
||
// Maps (lastObservedIndexedAt / lastObservedDbIdentity / lastObservedFtsStatus)
|
||
// touched in lockstep at every call site below — one Map, one delete, one
|
||
// shape. Keyed by lbugPath (not stored on the repo handle) because branch
|
||
// handles are produced fresh by applyBranchScope on every resolveRepo call,
|
||
// so mutating the handle would not persist across calls and the staleness
|
||
// check would reinit forever (#2106).
|
||
// - `indexedAt`: last meta.indexedAt observed for an open pool.
|
||
// - `dbIdentity`: file identity of the lbug the pool last opened (#2614 F1)
|
||
// — an atomic swap or in-place incremental changes the inode; reiniting
|
||
// on that covers the window where meta.indexedAt hasn't caught up (and
|
||
// the incremental case), so a rebuilt index is never served stale even
|
||
// when the stamp looks current.
|
||
// - `ftsStatus`: last meta.capabilities.fts.status observed (#2767).
|
||
// `--repair-fts` intentionally never restamps `indexedAt` (it doesn't
|
||
// regenerate the graph), so this is the dedicated signal a warm session
|
||
// uses to notice a repair — independent of the file-identity heuristic,
|
||
// which the repair path also triggers but only incidentally.
|
||
private lastObservedPoolState: Map<string, PoolObservedState> = new Map();
|
||
/** Merge-patch one poolKey's observed state, preserving fields not passed. */
|
||
private setObservedState(poolKey: string, patch: Partial<PoolObservedState>): void {
|
||
const current = this.lastObservedPoolState.get(poolKey) ?? { dbIdentity: null };
|
||
this.lastObservedPoolState.set(poolKey, { ...current, ...patch });
|
||
}
|
||
private groupToolSvc: GroupService | null = null;
|
||
/**
|
||
* One-shot stderr warnings for sibling-clone drift, keyed by
|
||
* `${repoId}|${cwdGitRoot}`. Without this guard every tool call
|
||
* from inside a sibling clone would print the same warning,
|
||
* making MCP stderr unreadable.
|
||
*/
|
||
private warnedSiblingDrift: Set<string> = new Set();
|
||
|
||
/**
|
||
* One-shot stderr warning for the VECTOR-extension fallback. Without this
|
||
* guard the diagnostic would fire on every `semanticSearch()` call on
|
||
* platforms where the extension is unsupported (e.g. Windows), making MCP
|
||
* stderr noisy per DoD §2.8.
|
||
*/
|
||
private warnedVectorUnsupported = false;
|
||
|
||
/**
|
||
* One-shot warning when a pruned or Node-unloadable optional embedding stack
|
||
* (#2370/#2372) forces semantic search to fall back to BM25 — so the
|
||
* degradation is visible once instead of silent.
|
||
*/
|
||
private warnedMissingEmbeddingStack = false;
|
||
|
||
/**
|
||
* Width the semantic lane last produced a QUERY vector at for an index, keyed
|
||
* by `lbugPath` (like `lastObservedPoolState`, and for the same reason: branch
|
||
* handles are rebuilt by `applyBranchScope` on every `resolveRepo`, so state
|
||
* hung off the handle would not survive to the next call).
|
||
*
|
||
* Exists so `query()` can raise the vector-column drift warning (#2798) ONLY
|
||
* where a width actually matters — a call that embedded something. The lane
|
||
* returns before importing the embedder when the index holds no vectors, and
|
||
* swallows an unavailable/pruned embedder into `[]`; a width complaint about
|
||
* either is noise about a comparison that never happened, and every index
|
||
* analyzed without `--embeddings` would carry it on every query.
|
||
*
|
||
* Recorded rather than recomputed at the warning site because the comparand
|
||
* must be the width the CAST actually binds — `getEmbeddingDims()` (the HTTP
|
||
* dimensions, else the local model's fixed 384), NOT `schema.ts`'s
|
||
* env-derived `EMBEDDING_DIMS`. Those two disagree exactly when
|
||
* `GITNEXUS_EMBEDDING_DIMS` is set on a server embedding LOCALLY, where the
|
||
* env value is the one the query path ignores — comparing against it would
|
||
* report drift on a lane that is working fine.
|
||
*
|
||
* Written only on definite outcomes (set once a vector exists, deleted where
|
||
* the lane provably embedded nothing), so concurrent queries against one
|
||
* index write the same value and an entry never outlives the fact it records.
|
||
*/
|
||
private lastQueryEmbeddingDims: Map<string, number> = new Map();
|
||
|
||
/**
|
||
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
|
||
*/
|
||
getGroupService(): GroupService {
|
||
if (!this.groupToolSvc) {
|
||
const port: GroupToolPort = {
|
||
resolveRepo: (p) => this.resolveRepo(p),
|
||
impact: (r, p) => this.impact(r as RepoHandle, p),
|
||
query: (r, p) => this.query(r as RepoHandle, p),
|
||
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
|
||
context: (r, p) => this.context(r as RepoHandle, p),
|
||
trace: (r, p) => this.traceForGroup(r as RepoHandle, p),
|
||
resolveSymbol: (r, q) => this.resolveSymbolForGroup(r as RepoHandle, q),
|
||
pdgFlows: (r, anchor, opts) => this.pdgFlowsForGroup(r as RepoHandle, anchor, opts),
|
||
};
|
||
this.groupToolSvc = new GroupService(port);
|
||
}
|
||
return this.groupToolSvc;
|
||
}
|
||
|
||
/**
|
||
* Adapt local `trace` to the group port. The assembled group/cross-repo trace
|
||
* presents 1-based endpoints (via resolveSymbolForGroup), so convert the hop
|
||
* lines here too — otherwise one response mixes 1-based endpoints with 0-based
|
||
* hops (#2380). Single-repo `trace` dispatches directly (not through this
|
||
* port) and stays 0-based (documented full-parity follow-up).
|
||
*/
|
||
private async traceForGroup(repo: RepoHandle, params: TraceParams): Promise<unknown> {
|
||
const result = await this.trace(repo, params);
|
||
const hops = (result as { hops?: Array<{ startLine?: number | null }> }).hops;
|
||
if (Array.isArray(hops)) {
|
||
for (const hop of hops) {
|
||
hop.startLine = toDisplayLine(hop.startLine);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Adapt the shared symbol resolver to the GroupToolPort contract. Used by the
|
||
* cross-repo trace path to locate which member repo an endpoint lives in and
|
||
* recover its node id (== bridge `Contract.symbolUid`).
|
||
*/
|
||
private async resolveSymbolForGroup(
|
||
repo: RepoHandle,
|
||
query: { name?: string; uid?: string; file_path?: string },
|
||
): Promise<GroupSymbolResolution> {
|
||
await this.ensureInitialized(repo);
|
||
const outcome = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid: query.uid, name: query.name },
|
||
{ file_path: query.file_path },
|
||
);
|
||
if (outcome.kind === 'ok') {
|
||
const s = outcome.symbol;
|
||
return {
|
||
kind: 'ok',
|
||
symbol: {
|
||
id: s.id,
|
||
name: s.name,
|
||
type: s.type,
|
||
filePath: s.filePath,
|
||
startLine: toDisplayLine(s.startLine),
|
||
endLine: toDisplayLine(s.endLine),
|
||
},
|
||
};
|
||
}
|
||
if (outcome.kind === 'ambiguous') {
|
||
return {
|
||
kind: 'ambiguous',
|
||
candidates: outcome.candidates.map((c) => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
type: c.type,
|
||
filePath: c.filePath,
|
||
startLine: toDisplayLine(c.startLine),
|
||
})),
|
||
};
|
||
}
|
||
return { kind: 'not_found' };
|
||
}
|
||
|
||
/**
|
||
* Intra-procedural REACHING_DEF data-flow for a single anchor symbol, adapted
|
||
* to the GroupToolPort contract. Reuses the same anchor + `flows` query as the
|
||
* `pdg_query` tool. `available:false` (not an error) when the repo has no PDG
|
||
* `flows` layer, so the cross-repo trace degrades to call-level hops.
|
||
*/
|
||
private async pdgFlowsForGroup(
|
||
repo: RepoHandle,
|
||
anchor: { name?: string; uid?: string; file_path?: string },
|
||
opts: { limit?: number },
|
||
): Promise<GroupPdgFlowResult> {
|
||
try {
|
||
await this.ensureInitialized(repo);
|
||
return await this._pdgFlowsForGroupImpl(repo, anchor, opts);
|
||
} catch {
|
||
// Enrichment is auxiliary — never let a PDG query failure fail the trace.
|
||
return { available: false, hops: [] };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Intra-procedural REACHING_DEF data-flow within the anchor symbol's block
|
||
* span. Reuses the same anchored, bind-param-only `flows` query as
|
||
* `pdg_query` (no rel-property index ⇒ the BasicBlock id-prefix + line-span
|
||
* anchor IS the bound). The anchor is resolved by UID when available (the
|
||
* boundary symbol is known precisely), avoiding the name-ambiguity the
|
||
* by-name `resolveBlockAnchor` path can hit. Data flow never crosses the repo
|
||
* boundary — this only describes how values move toward the boundary call
|
||
* inside one function.
|
||
*/
|
||
private async _pdgFlowsForGroupImpl(
|
||
repo: RepoHandle,
|
||
anchor: { name?: string; uid?: string; file_path?: string },
|
||
opts: { limit?: number },
|
||
): Promise<GroupPdgFlowResult> {
|
||
const rawLimit = opts.limit ?? PDG_QUERY_DEFAULT_LIMIT;
|
||
const limit =
|
||
Number.isInteger(rawLimit) && rawLimit >= 1 && rawLimit <= PDG_QUERY_MAX_LIMIT
|
||
? rawLimit
|
||
: PDG_QUERY_DEFAULT_LIMIT;
|
||
|
||
// Meta probe: layer present iff the flows cap is stamped. `false` is a
|
||
// definitive absence (degrade to call-level); `undefined` is unreadable
|
||
// meta (fall through and infer presence from rows found).
|
||
const pdgStamped = await pdgStampForMode(repo.lbugPath, 'flows');
|
||
if (pdgStamped === false) return { available: false, hops: [] };
|
||
|
||
// Resolve the anchor symbol (UID is precise; fall back to name/file).
|
||
const resolved = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid: anchor.uid, name: anchor.name },
|
||
{ file_path: anchor.file_path },
|
||
);
|
||
if (resolved.kind !== 'ok') {
|
||
// Layer may exist but we couldn't anchor — report availability from the
|
||
// stamp so the caller's note reflects the layer, not the miss.
|
||
return { available: pdgStamped === true, hops: [] };
|
||
}
|
||
const sym = resolved.symbol;
|
||
|
||
// Same span-anchored clause as resolveBlockAnchor's symbol branch: the
|
||
// BasicBlock startLine is 1-based vs the 0-based symbol span, so shift both
|
||
// bounds +1. `idPrefix`/`symStart`/`symEnd` are bind params; the edge type
|
||
// is a hardcoded literal — no user string is ever interpolated.
|
||
const hasSpan =
|
||
typeof sym.startLine === 'number' &&
|
||
typeof sym.endLine === 'number' &&
|
||
sym.endLine >= sym.startLine;
|
||
const idPrefix = `BasicBlock:${sym.filePath}:`;
|
||
const anchorClause = hasSpan
|
||
? 'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd'
|
||
: 'a.id STARTS WITH $idPrefix';
|
||
const queryParams: Record<string, unknown> = hasSpan
|
||
? { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 }
|
||
: { idPrefix };
|
||
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
|
||
WHERE r.type = 'REACHING_DEF' AND ${anchorClause}
|
||
RETURN a.startLine AS defLine, b.startLine AS useLine, b.text AS useText, r.reason AS reason
|
||
ORDER BY useLine, defLine, reason, a.id, b.id
|
||
LIMIT ${limit + 1}`,
|
||
queryParams,
|
||
);
|
||
|
||
const truncated = rows.length > limit;
|
||
const capped = truncated ? rows.slice(0, limit) : rows;
|
||
const hops: GroupPdgFlowHop[] = capped.map((r: Record<string, unknown>) => ({
|
||
// Number()/String() coerce the LadybugDB object/tuple cell; a bare
|
||
// `as number` cast on a nullish cell would surface NaN downstream.
|
||
line: Number(r.useLine ?? r[1] ?? 0),
|
||
text: String(r.useText ?? r[2] ?? '').trim(),
|
||
variable: decodeReachingDefReason(String(r.reason ?? r[3] ?? '')).name || undefined,
|
||
}));
|
||
|
||
const available = pdgStamped === true || hops.length > 0;
|
||
return {
|
||
available,
|
||
...(hops[0]?.variable ? { variable: hops[0].variable } : {}),
|
||
hops,
|
||
...(truncated ? { truncated: true } : {}),
|
||
};
|
||
}
|
||
|
||
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
|
||
async dispose(): Promise<void> {
|
||
await closeLbug();
|
||
}
|
||
|
||
// ─── Initialization ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Initialize from the global registry.
|
||
* Returns true if at least one repo is available.
|
||
*/
|
||
async init(): Promise<boolean> {
|
||
await this.refreshRepos();
|
||
return this.repos.size > 0;
|
||
}
|
||
|
||
/**
|
||
* Re-read the global registry and update the in-memory repo map.
|
||
* New repos are added, existing repos are updated, removed repos are pruned.
|
||
* LadybugDB connections for removed repos are NOT closed (they idle-timeout naturally).
|
||
*/
|
||
private async refreshRepos(): Promise<void> {
|
||
const entries = await listRegisteredRepos({ validate: true });
|
||
|
||
// Build the next map from scratch and swap it in atomically. Mutating the
|
||
// live map in place let stale entries influence fresh id assignment: a
|
||
// bare-name id, once handed to the first registry entry, stuck to it across
|
||
// refreshes and reorders, and colliding path suffixes silently overwrote
|
||
// each other so sibling clones disappeared from `list_repos` (#2054).
|
||
const nextRepos = new Map<string, RepoHandle>();
|
||
const nextContext = new Map<string, CodebaseContext>();
|
||
const assigned = new Map<string, string>(); // id -> resolved repo path
|
||
|
||
// Assign ids over a path-sorted view so a registered clone always gets the
|
||
// same id regardless of the registry's on-disk order: the bare name and
|
||
// each path-derived suffix become a pure function of the resolved-path set,
|
||
// not of iteration order, so a memorized id can't drift to a different
|
||
// clone after a registry reorder (#2067 follow-up).
|
||
const ordered = [...entries].sort((a, b) => {
|
||
const ra = path.resolve(a.path);
|
||
const rb = path.resolve(b.path);
|
||
return ra < rb ? -1 : ra > rb ? 1 : 0;
|
||
});
|
||
|
||
for (const entry of ordered) {
|
||
// path.resolve (not canonicalizePath) matches the pre-#2054 collision
|
||
// check and keeps refreshRepos free of mockable deps on the hot init
|
||
// path. registerRepo writes path.resolve'd paths (not realpath), and
|
||
// resolveRepoFromCache canonicalizes both sides when matching by path, so
|
||
// keying id assignment on path.resolve here is consistent and correct.
|
||
const resolved = path.resolve(entry.path);
|
||
const id = this.assignRepoId(entry.name, entry.path, resolved, assigned);
|
||
|
||
const storagePath = entry.storagePath;
|
||
const lbugPath = path.join(storagePath, 'lbug');
|
||
|
||
// Clean up any leftover KuzuDB files from before the LadybugDB migration.
|
||
// If kuzu exists but lbug doesn't, warn so the user knows to re-analyze.
|
||
const kuzu = await cleanupOldKuzuFiles(storagePath);
|
||
if (kuzu.found && kuzu.needsReindex) {
|
||
logger.error(
|
||
`GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`,
|
||
);
|
||
}
|
||
|
||
const handle: RepoHandle = {
|
||
id,
|
||
name: entry.name,
|
||
repoPath: entry.path,
|
||
storagePath,
|
||
lbugPath,
|
||
indexedAt: entry.indexedAt,
|
||
lastCommit: entry.lastCommit,
|
||
remoteUrl: entry.remoteUrl,
|
||
stats: entry.stats,
|
||
branch: entry.branch,
|
||
branches: entry.branches,
|
||
};
|
||
|
||
nextRepos.set(id, handle);
|
||
|
||
// Build lightweight context (no LadybugDB needed)
|
||
const s = entry.stats || {};
|
||
nextContext.set(id, {
|
||
projectName: entry.name,
|
||
stats: {
|
||
fileCount: s.files || 0,
|
||
functionCount: s.nodes || 0,
|
||
communityCount: s.communities || 0,
|
||
processCount: s.processes || 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
// Prune per-clone pool state for databases that are no longer registered.
|
||
// The LadybugDB pool and the init/staleness/reinit maps are keyed by the
|
||
// immutable lbugPath (see ensureInitialized), so a repo id that merely
|
||
// moves to a different clone needs NO eviction — distinct clones have
|
||
// distinct lbugPaths and can never share a pool entry, which is what closes
|
||
// the resolve→query wrong-clone window for good (#2067). Only a path that
|
||
// dropped out of the registry must release its pooled connection + state.
|
||
const liveLbugPaths = new Set([...nextRepos.values()].map((h) => h.lbugPath));
|
||
// Branch pools (opened on demand by applyBranchScope) are NOT in this.repos
|
||
// — branch handles are minted fresh and discarded — so add every registered
|
||
// branch's lbugPath to the live set. Pure string work over the already-in-
|
||
// memory registry snapshot; no disk I/O on this hot path (#2106 R3).
|
||
for (const entry of entries) {
|
||
for (const b of entry.branches ?? []) {
|
||
liveLbugPaths.add(getStoragePaths(entry.path, b.branch).lbugPath);
|
||
}
|
||
}
|
||
// initializedRepos is the authoritative set of OPENED pool keys (flat AND
|
||
// branch); union it with the previously-known flat handles so an orphaned
|
||
// branch pool (e.g. after `clean --branch` removes its summary) is closed
|
||
// and forgotten too, not just flat handles.
|
||
const knownKeys = new Set<string>([
|
||
...[...this.repos.values()].map((h) => h.lbugPath),
|
||
...this.initializedRepos,
|
||
]);
|
||
for (const key of knownKeys) {
|
||
if (liveLbugPaths.has(key)) continue;
|
||
this.initializedRepos.delete(key);
|
||
this.lastStalenessCheck.delete(key);
|
||
this.toolStalenessCache.delete(key);
|
||
this.lastObservedPoolState.delete(key);
|
||
this.reinitPromises.delete(key);
|
||
closeLbug(key).catch(() => {});
|
||
}
|
||
|
||
this.repos = nextRepos;
|
||
this.contextCache = nextContext;
|
||
}
|
||
|
||
/**
|
||
* Assign a collision-free in-memory id for a registered repo.
|
||
*
|
||
* - Unique name → the bare lowercased name.
|
||
* - Duplicate name → a path-derived suffix. The *first* colliding clone keeps
|
||
* the legacy `base64url(path)` suffix so ids generated before #2054 still
|
||
* resolve (the #1658 hashed-id tier). base64url is an encoding, not a hash:
|
||
* it preserves byte order, so sibling clones under one parent (e.g.
|
||
* `.../REPO_2` and `.../REPO_3`) yield identical leading characters and thus
|
||
* the same sliced suffix. Any further collision therefore falls back to a
|
||
* content hash of the *resolved* path (order-insensitive), extended
|
||
* deterministically until unique.
|
||
*
|
||
* `assigned` maps every id handed out in this refresh to its resolved path,
|
||
* so a candidate is "free" when it is unused or already owned by this exact
|
||
* path. This method records its own assignment into `assigned` before
|
||
* returning, so the map-update is the function's invariant, not a caller
|
||
* obligation. A returned id never overwrites a different path's handle (#2054).
|
||
*/
|
||
private assignRepoId(
|
||
name: string,
|
||
repoPath: string,
|
||
resolved: string,
|
||
assigned: Map<string, string>,
|
||
): string {
|
||
const base = name.toLowerCase();
|
||
const free = (id: string): boolean => {
|
||
const owner = assigned.get(id);
|
||
return owner === undefined || owner === resolved;
|
||
};
|
||
// Record the assignment so subsequent entries in the same refresh see this
|
||
// id as taken (the function owns its own invariant).
|
||
const claim = (id: string): string => {
|
||
assigned.set(id, resolved);
|
||
return id;
|
||
};
|
||
|
||
if (free(base)) return claim(base);
|
||
|
||
// Legacy suffix from the *raw* path — kept byte-for-byte so the first
|
||
// colliding duplicate keeps the id it had before #2054 (#1658 tier).
|
||
const legacy = `${base}-${Buffer.from(repoPath)
|
||
.toString('base64url')
|
||
.slice(0, REPO_ID_HASH_LENGTH)
|
||
.toLowerCase()}`;
|
||
if (free(legacy)) return claim(legacy);
|
||
|
||
// Real collision — hash the resolved path. Lowercase hex survives the
|
||
// `paramLower` lookup in resolveRepoFromCache.
|
||
const digest = createHash('sha256').update(resolved).digest('hex');
|
||
for (let len = REPO_ID_HASH_LENGTH; len <= digest.length; len++) {
|
||
const candidate = `${base}-${digest.slice(0, len)}`;
|
||
if (free(candidate)) return claim(candidate);
|
||
}
|
||
|
||
// Two distinct resolved paths sharing a full SHA-256 digest is a hash
|
||
// break, not a runtime condition — fail loudly rather than silently
|
||
// overwrite a different repo's handle (#2054 invariant).
|
||
throw new Error(
|
||
`GitNexus internal: unable to assign a unique repo id for "${name}" at ${repoPath}`,
|
||
);
|
||
}
|
||
|
||
// ─── Repo Resolution ─────────────────────────────────────────────
|
||
|
||
/**
|
||
* Resolve which repo to use.
|
||
* - If repoParam is given, match by name or path
|
||
* - If only 1 repo, use it
|
||
* - If 0 or multiple without param, throw with helpful message
|
||
*
|
||
* On a miss, re-reads the registry once in case a new repo was indexed
|
||
* while the MCP server was running.
|
||
*/
|
||
async resolveRepo(repoParam?: string, branch?: string): Promise<RepoHandle> {
|
||
let refreshedAfterAmbiguity = false;
|
||
let result: RepoHandle | null;
|
||
try {
|
||
result = this.resolveRepoFromCache(repoParam);
|
||
} catch (err) {
|
||
if (!(err instanceof RegistryAmbiguousTargetError)) throw err;
|
||
// Stale in-memory duplicate siblings can linger after unregister; refresh
|
||
// once before re-throwing so a resolved registry can disambiguate (#1658).
|
||
await this.refreshRepos();
|
||
refreshedAfterAmbiguity = true;
|
||
result = this.resolveRepoFromCache(repoParam);
|
||
}
|
||
|
||
if (result) {
|
||
// Issue: silent graph drift across sibling clones.
|
||
// If the caller's cwd lives in a *different* on-disk clone of
|
||
// the same repo (matched by `remoteUrl`), warn once per
|
||
// (repo, cwd) pair on stderr. We do not fail or refuse to
|
||
// serve — the index is still the best answer we have — but
|
||
// the operator/agent has to know the answer may be stale.
|
||
this.maybeWarnSiblingDrift(result).catch(() => {
|
||
/* best-effort; never throw from resolveRepo */
|
||
});
|
||
return this.applyBranchScope(result, branch);
|
||
}
|
||
|
||
// Miss — refresh registry and try once more (skip if already refreshed above)
|
||
if (!refreshedAfterAmbiguity) {
|
||
await this.refreshRepos();
|
||
}
|
||
const retried = this.resolveRepoFromCache(repoParam);
|
||
if (retried) {
|
||
this.maybeWarnSiblingDrift(retried).catch(() => {});
|
||
return this.applyBranchScope(retried, branch);
|
||
}
|
||
|
||
// Still no match — throw with helpful message
|
||
if (this.repos.size === 0) {
|
||
throw new Error('No indexed repositories. Run: gitnexus analyze');
|
||
}
|
||
|
||
// Build a disambiguated "Available: …" list (#829). When two handles
|
||
// share a name, annotate each colliding label with its path so the
|
||
// caller can actually pick the right one. Single-name entries render
|
||
// identically to pre-#829 output.
|
||
const nameCounts = new Map<string, number>();
|
||
for (const h of this.repos.values()) {
|
||
const key = h.name.toLowerCase();
|
||
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
|
||
}
|
||
const labels = [...this.repos.values()].map((h) =>
|
||
(nameCounts.get(h.name.toLowerCase()) ?? 0) > 1 ? `${h.name} (${h.repoPath})` : h.name,
|
||
);
|
||
|
||
if (repoParam) {
|
||
throw new Error(`Repository "${repoParam}" not found. Available: ${labels.join(', ')}`);
|
||
}
|
||
throw new Error(
|
||
`Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${labels.join(', ')}`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Re-point a resolved repo handle at a specific branch index (#2106).
|
||
*
|
||
* - No `branch` (default) → the flat workspace handle, unchanged (backward
|
||
* compatible: every existing caller passes no branch).
|
||
* - `branch` equal to the flat slot's **on-disk** recorded branch → the
|
||
* flat handle. The disk meta is read before any cached state is trusted
|
||
* (#2364 review F1): the flat slot follows the checked-out working tree
|
||
* (#2354), so a plain analyze after a branch switch restamps the meta
|
||
* without any repo-resolution miss that would refresh a long-lived
|
||
* server's cached handle — the cached label can otherwise serve another
|
||
* branch's content under the old name (the pool staleness reinit
|
||
* hot-swaps content without updating `handle.branch`).
|
||
* - `branch` matching an indexed pinned branch → a handle whose
|
||
* `lbugPath` points at `branches/<slug>/lbug`; the connection pool keys by
|
||
* `lbugPath`, so this is the only change needed to scope every tool. The
|
||
* sub-index lbug must actually exist on disk — `adoptFlatBranchLabel`
|
||
* deletes the whole dir when the flat slot takes ownership, and a stale
|
||
* cached summary must not route to the deleted path.
|
||
* - Cached `handle.branch` is trusted only when there is no readable flat
|
||
* meta to contradict it (legacy shapes, #2106 R4).
|
||
* - Any miss → a clear error (never a silently-empty result against the
|
||
* wrong DB), after exactly one `refreshRepos()` so newly-pinned branches
|
||
* and restamped labels the cached handle predates resolve on the next
|
||
* call.
|
||
*/
|
||
private async applyBranchScope(handle: RepoHandle, branch?: string): Promise<RepoHandle> {
|
||
if (!branch) return handle;
|
||
// At most one cache refresh per resolution: enough for the NEXT call to
|
||
// see fresh handles, without paying two registry re-scans when several
|
||
// stale arms fire in one degraded resolution.
|
||
let refreshed = false;
|
||
const refreshOnce = async (): Promise<void> => {
|
||
if (refreshed) return;
|
||
refreshed = true;
|
||
await this.refreshRepos().catch(() => {});
|
||
};
|
||
// One small JSON read per scoped call; mid-run meta writes preserve the
|
||
// old label until the end-of-run atomic stamp (run-analyze dirty stamps
|
||
// spread the existing meta), so this read never runs ahead of the DB.
|
||
const flatMeta = await loadMeta(path.dirname(handle.lbugPath));
|
||
if (flatMeta?.branch && flatMeta.branch === branch) {
|
||
// The disk meta decides routing, so it also supplies the metadata —
|
||
// the cached handle's label/commit/stats can predate the restamp.
|
||
return {
|
||
...handle,
|
||
branch: flatMeta.branch,
|
||
indexedAt: flatMeta.indexedAt ?? handle.indexedAt,
|
||
lastCommit: flatMeta.lastCommit ?? handle.lastCommit,
|
||
stats: flatMeta.stats ?? handle.stats,
|
||
};
|
||
}
|
||
|
||
// A registry entry claiming `branch` both as the flat label AND as a
|
||
// pinned summary is an adopt-degraded state (rm kept the summary while
|
||
// the label restamped) — never serve the possibly stale-vintage pin for
|
||
// a label the flat slot claims; fall through to the honest error.
|
||
const summary =
|
||
handle.branch !== branch ? handle.branches?.find((b) => b.branch === branch) : undefined;
|
||
if (summary) {
|
||
const { lbugPath } = getStoragePaths(handle.repoPath, branch);
|
||
// The lbug is the artifact the pool opens, so its presence is the
|
||
// serviceability truth — a half-deleted dir can outlive its meta.json
|
||
// while the lbug is gone, and vice versa (#2364 review F1 arm ii).
|
||
// Only provably-absent errno counts as missing: a transient EACCES/EIO
|
||
// on a healthy pinned sub-index must serve the handle (the pool open
|
||
// surfaces the real error) rather than a false "not indexed".
|
||
const probeCode = await fs.access(lbugPath).then(
|
||
() => null,
|
||
(e: unknown) => (e as NodeJS.ErrnoException)?.code ?? 'UNKNOWN',
|
||
);
|
||
const subIndexMissing = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
|
||
if (!subIndexMissing) {
|
||
return {
|
||
...handle,
|
||
lbugPath,
|
||
indexedAt: summary.indexedAt,
|
||
lastCommit: summary.lastCommit,
|
||
stats: summary.stats,
|
||
};
|
||
}
|
||
// Stale summary (sub-index adopted/deleted): refresh so later calls see
|
||
// fresh handles, then fall through — the flat meta above is the truth.
|
||
await refreshOnce();
|
||
}
|
||
|
||
if (handle.branch && handle.branch === branch) {
|
||
// No readable flat meta (missing/corrupt — loadMeta → null): keep the
|
||
// pre-#2354 trust in the cached label (#2106 R4 legacy shapes). A
|
||
// readable meta that names another branch means the label is stale.
|
||
if (!flatMeta?.branch) return handle;
|
||
}
|
||
|
||
// Every miss refreshes once before erroring: newly-pinned branches and
|
||
// restamped labels the cached handle predates become resolvable on the
|
||
// caller's next attempt (the cache otherwise only refreshes on repo-
|
||
// resolution misses and list_repos).
|
||
await refreshOnce();
|
||
|
||
// The flat slot's label comes from the authoritative meta when readable —
|
||
// never echo a cached label the meta just contradicted (a "not indexed:
|
||
// main / indexed: main" self-contradiction). Cached summaries may still
|
||
// lag; they are a hint, not a promise.
|
||
const flatLabel = flatMeta?.branch ?? handle.branch;
|
||
const indexed = [flatLabel, ...(handle.branches?.map((b) => b.branch) ?? [])].filter(
|
||
(b) => Boolean(b) && b !== branch,
|
||
);
|
||
const available = indexed.length > 0 ? indexed.join(', ') : '(workspace only)';
|
||
// Post-#2354 a bare `analyze --branch <X>` refuses to run unless X is
|
||
// checked out, so the guidance must lead with the checkout (#2364 F6).
|
||
throw new Error(
|
||
`Branch "${branch}" is not indexed for "${handle.name}". ` +
|
||
`Indexed branches: ${available}. The workspace index follows the ` +
|
||
`checked-out branch — check out "${branch}" and re-run: gitnexus analyze ` +
|
||
`(add --branch ${branch} while it is checked out to pin a separate sub-index).`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Try to resolve a repo from the in-memory cache. Returns null on miss.
|
||
* Throws {@link RegistryAmbiguousTargetError} when `repoParam` matches
|
||
* multiple handles by name and cwd cannot disambiguate (#1658).
|
||
*/
|
||
private resolveRepoFromCache(repoParam?: string): RepoHandle | null {
|
||
if (this.repos.size === 0) return null;
|
||
|
||
if (repoParam) {
|
||
const paramLower = repoParam.toLowerCase();
|
||
const looksLikePath =
|
||
path.isAbsolute(repoParam) || repoParam.includes(path.sep) || repoParam.includes('/');
|
||
|
||
const resolvePathMatch = (): RepoHandle | undefined => {
|
||
const canonicalTarget = canonicalizePath(repoParam);
|
||
return [...this.repos.values()].find((handle) => {
|
||
const stored = canonicalizePath(handle.repoPath);
|
||
return process.platform === 'win32'
|
||
? stored.toLowerCase() === canonicalTarget.toLowerCase()
|
||
: stored === canonicalTarget;
|
||
});
|
||
};
|
||
|
||
// Path-like params first (absolute or contains separators) — aligns with
|
||
// resolveRegistryEntry (#829). Bare aliases such as ".tmp-repro-mini" must
|
||
// not be resolved via path.resolve(cwd) before duplicate-name handling.
|
||
if (looksLikePath) {
|
||
const pathMatch = resolvePathMatch();
|
||
if (pathMatch) return pathMatch;
|
||
}
|
||
|
||
// Exact name before id — the first duplicate sibling keeps id === name
|
||
// (e.g. id "shared"), so a name lookup must not be captured by the id tier.
|
||
const nameMatches = [...this.repos.values()].filter(
|
||
(handle) => handle.name.toLowerCase() === paramLower,
|
||
);
|
||
if (nameMatches.length === 1) return nameMatches[0];
|
||
if (nameMatches.length > 1) {
|
||
const cwdPick = this.pickRepoHandleForCwd(nameMatches);
|
||
if (cwdPick) return cwdPick;
|
||
throw new RegistryAmbiguousTargetError(
|
||
repoParam,
|
||
nameMatches.map((h) => this.handleToRegistryEntry(h)),
|
||
);
|
||
}
|
||
|
||
// Stable hashed id (e.g. "shared-abc123") from repoId() collision suffix
|
||
if (this.repos.has(paramLower)) return this.repos.get(paramLower)!;
|
||
|
||
// Bare name resolved as a cwd-relative path (e.g. "myrepo" against process.cwd()),
|
||
// after name/id tiers. Path-like strings with separators were handled at the top.
|
||
if (!looksLikePath) {
|
||
const pathMatch = resolvePathMatch();
|
||
if (pathMatch) return pathMatch;
|
||
}
|
||
|
||
// Partial name — only when unambiguous
|
||
const partialMatches = [...this.repos.values()].filter((handle) =>
|
||
handle.name.toLowerCase().includes(paramLower),
|
||
);
|
||
if (partialMatches.length === 1) return partialMatches[0];
|
||
|
||
return null;
|
||
}
|
||
|
||
if (this.repos.size === 1) {
|
||
return this.repos.values().next().value!;
|
||
}
|
||
|
||
return null; // Multiple repos, no param — ambiguous
|
||
}
|
||
|
||
/**
|
||
* Prefer the indexed repo whose path matches the git root of process.cwd().
|
||
*
|
||
* In MCP stdio server mode, `process.cwd()` is the server's launch directory,
|
||
* not the agent client's cwd. If the server was started from an unrelated
|
||
* directory, `getGitRoot` returns null and duplicate-name resolution throws
|
||
* {@link RegistryAmbiguousTargetError} — callers should pass an absolute path.
|
||
*/
|
||
private pickRepoHandleForCwd(candidates: RepoHandle[]): RepoHandle | null {
|
||
const cwdRoot = getGitRoot(process.cwd());
|
||
if (!cwdRoot) return null;
|
||
const canonicalCwd = canonicalizePath(cwdRoot);
|
||
const cwdMatches = candidates.filter((handle) => {
|
||
const stored = canonicalizePath(handle.repoPath);
|
||
return process.platform === 'win32'
|
||
? stored.toLowerCase() === canonicalCwd.toLowerCase()
|
||
: stored === canonicalCwd;
|
||
});
|
||
return cwdMatches.length === 1 ? cwdMatches[0] : null;
|
||
}
|
||
|
||
private handleToRegistryEntry(handle: RepoHandle): RegistryEntry {
|
||
return {
|
||
name: handle.name,
|
||
path: handle.repoPath,
|
||
storagePath: handle.storagePath,
|
||
indexedAt: handle.indexedAt,
|
||
lastCommit: handle.lastCommit,
|
||
stats: handle.stats,
|
||
remoteUrl: handle.remoteUrl,
|
||
};
|
||
}
|
||
|
||
// ─── Lazy LadybugDB Init ────────────────────────────────────────────
|
||
|
||
/**
|
||
* Ensure the LadybugDB pool is open for the *resolved* repo.
|
||
*
|
||
* Takes the `RepoHandle` the caller resolved — NOT a bare id — and keys the
|
||
* pool (and the init/staleness/reinit maps) by the immutable `lbugPath`. Two
|
||
* things matter for multi-clone correctness: (1) the handle is the one the
|
||
* caller resolved, so a concurrent `refreshRepos` can't substitute a different
|
||
* clone; (2) the pool key is the database path, so distinct clones never share
|
||
* a pool entry even when their name-derived id transiently collides (#2067).
|
||
*/
|
||
private async ensureInitialized(repo: RepoHandle): Promise<void> {
|
||
const poolKey = repo.lbugPath;
|
||
// If a reinit is already in progress for this repo, wait for it
|
||
const pending = this.reinitPromises.get(poolKey);
|
||
if (pending) return pending;
|
||
|
||
// Check if the index was rebuilt since we opened the connection (#297).
|
||
// Throttle staleness checks to at most once per 5 seconds per repo to
|
||
// avoid an fs.readFile round-trip on every tool invocation.
|
||
if (this.initializedRepos.has(poolKey) && isLbugReady(poolKey)) {
|
||
const now = Date.now();
|
||
const lastCheck = this.lastStalenessCheck.get(poolKey) ?? 0;
|
||
if (now - lastCheck < 5000) return; // Checked recently — skip
|
||
|
||
this.lastStalenessCheck.set(poolKey, now);
|
||
try {
|
||
// Read the metadata that sits next to THIS handle's lbug. For the
|
||
// flat/primary handle this is `<storagePath>/gitnexus.json`; for a
|
||
// branch handle it is `<storagePath>/branches/<slug>/gitnexus.json`.
|
||
// loadMeta falls back to legacy meta.json during migration.
|
||
// Reading the flat meta for a branch handle would compare the branch
|
||
// index's indexedAt against the primary's and thrash the pool (#2106).
|
||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||
const observedState = this.lastObservedPoolState.get(poolKey);
|
||
// Compare against the last indexedAt OBSERVED for this pool (keyed by
|
||
// lbugPath), not the handle's — branch handles are fresh spreads so a
|
||
// handle mutation would not persist and would reinit on every check.
|
||
const observed = observedState?.indexedAt ?? repo.indexedAt;
|
||
const stampChanged = !!meta?.indexedAt && meta.indexedAt !== observed;
|
||
// #2614 F1: also reinit on a file-identity change. An atomic swap (or an
|
||
// in-place incremental) changes the lbug inode; keying only on
|
||
// meta.indexedAt let a reader that reinited inside the pre-swap window
|
||
// latch on the old inode forever (its stamp already == meta.indexedAt).
|
||
const currentIdentity = await statDbIdentity(repo.lbugPath);
|
||
const identityChanged = dbIdentityChanged(
|
||
observedState?.dbIdentity ?? null,
|
||
currentIdentity,
|
||
);
|
||
// #2767: `--repair-fts` intentionally never restamps `indexedAt` (it
|
||
// doesn't regenerate the graph), so `stampChanged` alone can't notice
|
||
// a repair. `capabilities.fts.status` is the field repair-fts DOES
|
||
// write, so a change there is a third, independent reinit trigger —
|
||
// sibling to stampChanged/identityChanged, not a replacement for them
|
||
// (identityChanged still catches an in-place mutation even if the
|
||
// caps stamp were somehow missed).
|
||
const ftsStatus = meta?.capabilities?.fts?.status;
|
||
const ftsCapsChanged = observedState?.ftsStatus !== ftsStatus;
|
||
if (stampChanged || identityChanged || ftsCapsChanged) {
|
||
// Index was rebuilt/swapped — DELEGATE the close/reopen to the pool's
|
||
// initLbug, which refuses to evict (and close the shared Database)
|
||
// while a query is in flight (its checkedOut>0 guard). Calling
|
||
// closeLbug directly here bypassed that guard and could close a
|
||
// Database mid-query — a native use-after-free (#2614). Wrap in
|
||
// reinitPromises to serialize concurrent detectors.
|
||
const reinit = (async () => {
|
||
try {
|
||
const reopened = await initLbug(poolKey, repo.lbugPath);
|
||
// tri-review NEW-7: advance the observed stamp/caps watermarks
|
||
// only AFTER initLbug completes, not before calling it — still
|
||
// regardless of `reopened` true/false (a stamp/caps change with
|
||
// an unchanged file must not re-trigger on every check), but if
|
||
// initLbug THROWS the watermark must stay at its old value so
|
||
// the next staleness check retries, instead of a failed reinit
|
||
// silently latching as "already applied" and never trying again.
|
||
const patch: Partial<PoolObservedState> = { ftsStatus };
|
||
if (meta?.indexedAt) patch.indexedAt = meta.indexedAt;
|
||
// Advance the observed IDENTITY only when the pool actually rolled
|
||
// over. If a query was in flight, initLbug served the current
|
||
// handle and returned false; leaving the identity divergent
|
||
// re-triggers the reopen on a later idle check instead of latching.
|
||
if (reopened) patch.dbIdentity = await statDbIdentity(repo.lbugPath);
|
||
this.setObservedState(poolKey, patch);
|
||
} finally {
|
||
this.reinitPromises.delete(poolKey);
|
||
}
|
||
})();
|
||
this.reinitPromises.set(poolKey, reinit);
|
||
return reinit;
|
||
} else {
|
||
return; // Pool is current
|
||
}
|
||
} catch {
|
||
return; // Can't read meta — assume pool is fine
|
||
}
|
||
}
|
||
|
||
try {
|
||
await initLbug(poolKey, repo.lbugPath);
|
||
this.initializedRepos.add(poolKey);
|
||
// #2767: ftsStatus is deliberately left unset (undefined) here rather
|
||
// than issuing an extra loadMeta read — every tool call already routes
|
||
// through ensureInitialized, so an extra per-cold-init read adds up,
|
||
// and the cost of skipping it is negligible: at most one redundant
|
||
// initLbug call on the first warm check (initLbug itself no-ops
|
||
// cheaply via a single fs.stat when the file identity is actually
|
||
// unchanged, per pool-adapter.ts's own "unchanged → reuse" guard), not
|
||
// a real reopen.
|
||
this.setObservedState(poolKey, {
|
||
indexedAt: repo.indexedAt,
|
||
dbIdentity: await statDbIdentity(repo.lbugPath),
|
||
});
|
||
} catch (err: any) {
|
||
// If lock error, mark as not initialized so next call retries
|
||
this.initializedRepos.delete(poolKey);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// ─── Public Getters ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Get context for a specific repo (or the single repo if only one).
|
||
*/
|
||
getContext(repoId?: string): CodebaseContext | null {
|
||
if (repoId && this.contextCache.has(repoId)) {
|
||
return this.contextCache.get(repoId)!;
|
||
}
|
||
if (this.repos.size === 1) {
|
||
return this.contextCache.values().next().value ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* List all registered repos with their metadata.
|
||
* Re-reads the global registry so newly indexed repos are discovered
|
||
* without restarting the MCP server.
|
||
*
|
||
* Each entry includes:
|
||
* - `staleness`: if the indexed clone's own HEAD has moved past
|
||
* the recorded `lastCommit` (option D in the issue's fix list).
|
||
* - `siblings`: other registered entries sharing the same
|
||
* `remoteUrl` (option B's payoff: callers can see at a glance
|
||
* that another clone of the same logical repo is registered).
|
||
* - `remoteUrl`: the canonical origin URL recorded at index time.
|
||
*/
|
||
async listRepos(): Promise<RepoListing[]> {
|
||
await this.refreshRepos();
|
||
const handles = [...this.repos.values()];
|
||
|
||
// Pre-group registered handles by `remoteUrl` so the sibling
|
||
// lookup is O(1) per handle. We reuse the in-memory `this.repos`
|
||
// (already populated by `refreshRepos`) instead of doing a fresh
|
||
// `readRegistry()` per entry — that would be N file reads for N
|
||
// registered repos.
|
||
const isWin = process.platform === 'win32';
|
||
const norm = (p: string) => (isWin ? path.resolve(p).toLowerCase() : path.resolve(p));
|
||
const byRemote = new Map<string, RepoHandle[]>();
|
||
for (const h of handles) {
|
||
if (!h.remoteUrl) continue;
|
||
const list = byRemote.get(h.remoteUrl) ?? [];
|
||
list.push(h);
|
||
byRemote.set(h.remoteUrl, list);
|
||
}
|
||
|
||
// Check staleness for all repos in parallel instead of sequentially.
|
||
// Each check spawns an async `git rev-list` — with 200 repos the sync
|
||
// variant took ~50 s; parallel async brings it under a second (#1363).
|
||
const stalenessResults = await Promise.all(
|
||
handles.map((h) => checkStalenessAsync(h.repoPath, h.lastCommit)),
|
||
);
|
||
|
||
return handles.map((h, i) => {
|
||
const stale = stalenessResults[i];
|
||
const selfNorm = norm(h.repoPath);
|
||
const siblings = h.remoteUrl
|
||
? (byRemote.get(h.remoteUrl) ?? []).filter((e) => norm(e.repoPath) !== selfNorm)
|
||
: [];
|
||
return {
|
||
name: h.name,
|
||
path: h.repoPath,
|
||
indexedAt: h.indexedAt,
|
||
lastCommit: h.lastCommit,
|
||
remoteUrl: h.remoteUrl,
|
||
stats: h.stats,
|
||
staleness: stale.isStale
|
||
? { commitsBehind: stale.commitsBehind, hint: stale.hint }
|
||
: undefined,
|
||
siblings:
|
||
siblings.length > 0
|
||
? siblings.map((s) => ({
|
||
name: s.name,
|
||
path: s.repoPath,
|
||
lastCommit: s.lastCommit,
|
||
}))
|
||
: undefined,
|
||
branch: h.branch,
|
||
branches:
|
||
h.branches && h.branches.length > 0
|
||
? h.branches.map((b) => ({
|
||
branch: b.branch,
|
||
indexedAt: b.indexedAt,
|
||
lastCommit: b.lastCommit,
|
||
}))
|
||
: undefined,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Paginated view over {@link listRepos} for the `list_repos` MCP tool (#2119).
|
||
*
|
||
* `listRepos()` itself still returns the FULL array — its resource and CLI
|
||
* consumers (`gitnexus://repos`, `gitnexus://setup`, startup logs) need every
|
||
* entry, so pagination lives ONLY here, on the tool surface, to keep the
|
||
* response under MCP/LLM token-truncation limits.
|
||
*
|
||
* Determinism: a single registry snapshot is taken per call, then sorted by
|
||
* lower-cased name with the repository path as a tie-breaker. Sibling clones
|
||
* share a name but never a path (#2054), so `(name, path)` is a total order —
|
||
* paging never skips or duplicates an entry while the registry is unchanged.
|
||
* Codepoint comparison (not `localeCompare`) keeps page boundaries stable
|
||
* across machines/locales, matching the existing `refreshRepos` ordering.
|
||
*/
|
||
async listReposPage(params?: { limit?: unknown; offset?: unknown } | null): Promise<{
|
||
repositories: RepoListing[];
|
||
pagination: ListReposPagination;
|
||
}> {
|
||
const { limit, offset } = parseListReposPagination(params, {
|
||
defaultLimit: LIST_REPOS_DEFAULT_LIMIT,
|
||
maxLimit: LIST_REPOS_MAX_LIMIT,
|
||
});
|
||
|
||
// One consistent snapshot per call (listRepos refreshes the registry once),
|
||
// sorted into a stable total order before slicing.
|
||
const all = await this.listRepos();
|
||
all.sort(
|
||
(a, b) =>
|
||
compareCodeUnits(a.name.toLowerCase(), b.name.toLowerCase()) ||
|
||
compareCodeUnits(a.path, b.path),
|
||
);
|
||
|
||
const total = all.length;
|
||
const repositories = all.slice(offset, offset + limit);
|
||
const returned = repositories.length;
|
||
const hasMore = offset + returned < total;
|
||
|
||
return {
|
||
repositories,
|
||
pagination: {
|
||
total,
|
||
limit,
|
||
offset,
|
||
returned,
|
||
hasMore,
|
||
...(hasMore && { nextOffset: offset + returned }),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Best-effort sibling-clone drift warning.
|
||
*
|
||
* When the resolved index has a `remoteUrl` recorded and the caller's
|
||
* `process.cwd()` is inside a *different* clone of the same repo, emit
|
||
* one stderr line per (repo, cwd) pair so the operator knows the
|
||
* graph may be stale relative to what's actually on disk under their
|
||
* cwd. Silent on path matches and on repos without a remote URL.
|
||
*
|
||
* Limitation: in MCP stdio server mode `process.cwd()` is the
|
||
* server's CWD at start time, *not* the agent client's CWD. The
|
||
* warning therefore only fires when the MCP server itself was
|
||
* launched from inside a sibling clone (typical for `npx gitnexus
|
||
* serve` from a polecat workspace). Surfacing the client's CWD
|
||
* would require a per-tool-call `cwd` parameter — out of scope for
|
||
* the current MCP contract.
|
||
*
|
||
* Pure side-effect (stderr); never affects the returned handle.
|
||
* After the first computation for a given (repo, cwd) pair the
|
||
* result is cached so subsequent `resolveRepo()` calls don't
|
||
* re-shell-out to git.
|
||
*/
|
||
private async maybeWarnSiblingDrift(handle: RepoHandle): Promise<void> {
|
||
if (!handle.remoteUrl) return;
|
||
let cwd: string;
|
||
try {
|
||
cwd = process.cwd();
|
||
} catch {
|
||
return;
|
||
}
|
||
// Early-exit cache: keyed on (repo, cwd) BEFORE any git shellout.
|
||
// After the first call for a given cwd, this short-circuits the
|
||
// up-to-four `execSync`/`execFileSync` calls inside `checkCwdMatch`
|
||
// — important for MCP-server mode where `process.cwd()` is constant
|
||
// and `resolveRepo` runs on every tool call.
|
||
const cacheKey = `${handle.id}|${cwd}`;
|
||
if (this.warnedSiblingDrift.has(cacheKey)) return;
|
||
|
||
const match = await checkCwdMatch(cwd);
|
||
if (
|
||
match.match !== 'sibling-by-remote' ||
|
||
!match.entry ||
|
||
!match.cwdGitRoot ||
|
||
match.entry.path !== handle.repoPath ||
|
||
!match.hint
|
||
) {
|
||
// Cache "nothing to warn about" outcomes too — `checkCwdMatch`
|
||
// is deterministic for a fixed (registry, cwd) pair, so re-running
|
||
// it yields nothing new.
|
||
this.warnedSiblingDrift.add(cacheKey);
|
||
return;
|
||
}
|
||
|
||
this.warnedSiblingDrift.add(cacheKey);
|
||
logger.error(`GitNexus: ${match.hint}`);
|
||
}
|
||
|
||
// ─── Tool Dispatch ───────────────────────────────────────────────
|
||
|
||
/**
|
||
* #2655: attach a commits-behind freshness signal to a hot-read-tool result,
|
||
* skipping the `git` spawn entirely for results that can't carry it (error
|
||
* envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an
|
||
* error-returning call pays nothing.
|
||
*/
|
||
private async withToolStaleness(repo: RepoHandle, result: unknown): Promise<unknown> {
|
||
if (!canCarryStaleness(result)) return result;
|
||
// Defensive: `checkStalenessAsync` self-catches today, but a rejection here
|
||
// must never fail the tool — degrade to no-staleness. Paired with the
|
||
// evict-on-reject in `stalenessForTool`, a transient failure also can't
|
||
// poison the TTL cache entry (#2655 review F1).
|
||
const staleness = await this.stalenessForTool(repo).catch(() => undefined);
|
||
return attachToolStaleness(result, staleness);
|
||
}
|
||
|
||
/**
|
||
* #2655: commits-behind freshness for the hot read tools, deduped per index.
|
||
* Returns a shared in-flight promise so concurrent tool calls spawn at most
|
||
* one `git rev-list` per index per TTL window; the resolved value is cached
|
||
* for TOOL_STALENESS_TTL_MS. Keyed by lbugPath so flat and branch handles
|
||
* (same repoPath, different lastCommit) don't share an entry. Non-blocking by
|
||
* construction: `checkStalenessAsync` swallows git failures to
|
||
* `{ isStale: false }`, so a git error never fails the tool — it just omits
|
||
* the `staleness` field.
|
||
*/
|
||
private stalenessForTool(repo: RepoHandle): Promise<StalenessInfo> {
|
||
const now = Date.now();
|
||
const cached = this.toolStalenessCache.get(repo.lbugPath);
|
||
if (cached && now - cached.at < LocalBackend.TOOL_STALENESS_TTL_MS) {
|
||
return cached.value;
|
||
}
|
||
// Evict the entry if the check rejects so a transient failure isn't served
|
||
// (as a permanently-rejecting promise) for the rest of the TTL window; the
|
||
// next call then re-runs. A resolving promise is never evicted, so happy-path
|
||
// dedup is untouched (#2655 review F1). `Promise.resolve` wraps the call so a
|
||
// non-thenable return can't throw at this boundary — a no-op for the real
|
||
// async `checkStalenessAsync`, robust defense-in-depth otherwise.
|
||
const entry: { at: number; value: Promise<StalenessInfo> } = {
|
||
at: now,
|
||
// Only evict if THIS entry is still current — a later call may have
|
||
// installed a fresh (resolving) entry for the same key before a slow
|
||
// rejection lands, and that newer entry must not be dropped.
|
||
value: Promise.resolve(checkStalenessAsync(repo.repoPath, repo.lastCommit)).catch((err) => {
|
||
if (this.toolStalenessCache.get(repo.lbugPath) === entry) {
|
||
this.toolStalenessCache.delete(repo.lbugPath);
|
||
}
|
||
throw err;
|
||
}),
|
||
};
|
||
this.toolStalenessCache.set(repo.lbugPath, entry);
|
||
return entry.value;
|
||
}
|
||
|
||
async callTool(method: string, params: any): Promise<any> {
|
||
if (method === 'list_repos') {
|
||
// Paginated tool surface (#2119). `listRepos()` is unchanged for internal
|
||
// callers; the tool wraps it in { repositories, pagination } and forwards
|
||
// the limit/offset args that this dispatch previously discarded.
|
||
return this.listReposPage(params);
|
||
}
|
||
|
||
if (method.startsWith('group_')) {
|
||
return this.handleGroupTool(method, params || {});
|
||
}
|
||
|
||
const normalized = normalizeToolParams(method, params);
|
||
if ('error' in normalized) return { error: normalized.error };
|
||
const p = normalized.params;
|
||
|
||
// #2175: Claude Code drops a tool-call argument named exactly "query", so the
|
||
// query/cypher tools advertise "search_query"/"statement" while still accepting the
|
||
// legacy "query" key for backward compat. The alias is resolved with `?? ` (new name
|
||
// wins) at every consumer site rather than by mutating params here, so precedence is
|
||
// uniform and there is no hidden mutation: query()/cypher() read it directly, the
|
||
// legacy "search" alias routes through query(), and the cross-repo group-forward
|
||
// resolves it self-contained in callToolAtGroupRepo. This is permanent compatibility
|
||
// — third-party MCP clients may legitimately send "query", so the alias is not slated
|
||
// for removal even if Claude Code's argument handling later changes.
|
||
if (
|
||
(method === 'impact' || method === 'query' || method === 'context' || method === 'trace') &&
|
||
typeof p.repo === 'string' &&
|
||
p.repo.startsWith('@')
|
||
) {
|
||
return this.callToolAtGroupRepo(method, p);
|
||
}
|
||
|
||
// Resolve repo from optional param (re-reads registry on miss). An optional
|
||
// `branch` param scopes the resolved handle to that branch's index (#2106).
|
||
const repo = await this.resolveRepo(
|
||
p.repo as string | undefined,
|
||
p.branch as string | undefined,
|
||
);
|
||
|
||
switch (method) {
|
||
case 'query':
|
||
return this.withToolStaleness(repo, await this.query(repo, p));
|
||
case 'cypher': {
|
||
const raw = await this.cypher(repo, p);
|
||
return this.withToolStaleness(repo, this.formatCypherAsMarkdown(raw));
|
||
}
|
||
case 'context':
|
||
return this.withToolStaleness(repo, await this.context(repo, p));
|
||
case 'explain':
|
||
return this.explain(repo, p);
|
||
case 'pdg_query':
|
||
return this.pdgQuery(repo, p);
|
||
case 'impact':
|
||
return this.withToolStaleness(repo, await this.impact(repo, p as unknown as ImpactParams));
|
||
case 'detect_changes':
|
||
return this.detectChanges(repo, p);
|
||
case 'check':
|
||
return this.check(repo, p);
|
||
case 'rename':
|
||
return this.rename(repo, p as unknown as Parameters<LocalBackend['rename']>[1]);
|
||
// Legacy aliases for backwards compatibility
|
||
case 'search':
|
||
return this.query(repo, p);
|
||
case 'explore':
|
||
return this.context(repo, {
|
||
name: typeof p.name === 'string' ? p.name : undefined,
|
||
...p,
|
||
});
|
||
case 'overview':
|
||
return this.overview(repo, p);
|
||
case 'route_map':
|
||
return this.routeMap(repo, p);
|
||
case 'shape_check':
|
||
return this.shapeCheck(repo, p);
|
||
case 'tool_map':
|
||
return this.toolMap(repo, p);
|
||
case 'api_impact':
|
||
return this.apiImpact(repo, p);
|
||
case 'trace':
|
||
return this.trace(repo, p);
|
||
default:
|
||
throw new Error(`Unknown tool: ${method}`);
|
||
}
|
||
}
|
||
|
||
// ─── Tool Implementations ────────────────────────────────────────
|
||
|
||
/** Check repository graph invariants that are suitable for CI gating. */
|
||
private async check(repo: RepoHandle, params?: { cycles?: boolean }): Promise<any> {
|
||
if (params?.cycles === false) {
|
||
return { error: 'No checks selected. Set "cycles" to true.' };
|
||
}
|
||
await this.ensureInitialized(repo);
|
||
const rowLimit = 100_001;
|
||
// determinism: probe — overflow guard, not a window. The one-past cap is compared for exact equality below
|
||
// and the whole result is REPLACED by an error, so a truncated page never reaches a caller.
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (source:File)-[r:CodeRelation]->(target:File)
|
||
WHERE r.type = 'IMPORTS'
|
||
AND (r.reason IS NULL OR (
|
||
r.reason <> 'swift-scope: implicit module visibility'
|
||
AND r.reason <> 'markdown-link'
|
||
))
|
||
RETURN source.filePath AS source, target.filePath AS target
|
||
LIMIT ${rowLimit}`,
|
||
{},
|
||
);
|
||
if (rows.length === rowLimit) {
|
||
return {
|
||
error: `Import graph exceeds the ${rowLimit - 1} edge safety limit.`,
|
||
truncated: true,
|
||
};
|
||
}
|
||
const cycles = findImportCycles(
|
||
rows.map((row: any) => ({
|
||
source: String(row.source ?? row[0] ?? ''),
|
||
target: String(row.target ?? row[1] ?? ''),
|
||
})),
|
||
);
|
||
return {
|
||
status: cycles.length === 0 ? 'clean' : 'cycles_found',
|
||
cycleCount: cycles.length,
|
||
cycles: cycles.map((files) => ({ files })),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Query tool — process-grouped search.
|
||
*
|
||
* 1. Hybrid search (BM25 + semantic) to find matching symbols
|
||
* 2. Trace each match to its process(es) via STEP_IN_PROCESS
|
||
* 3. Group by process, rank by aggregate relevance + internal cluster cohesion
|
||
* 4. Return: { processes, process_symbols, definitions }
|
||
*/
|
||
private async query(
|
||
repo: RepoHandle,
|
||
params: {
|
||
query?: string;
|
||
search_query?: string;
|
||
task_context?: string;
|
||
goal?: string;
|
||
limit?: number;
|
||
max_symbols?: number;
|
||
include_content?: boolean;
|
||
},
|
||
): Promise<any> {
|
||
// #2175: each consumer resolves the search_query/query alias itself (there is no
|
||
// chokepoint mutation in callTool). This also serves the GroupService port, which
|
||
// reaches query() carrying only the legacy `query` key.
|
||
const rawQuery = resolveAliasString(params.search_query, params.query);
|
||
if (!rawQuery?.trim()) {
|
||
return { error: 'search_query (or legacy query) parameter is required and cannot be empty.' };
|
||
}
|
||
|
||
await this.ensureInitialized(repo);
|
||
|
||
const processLimit = params.limit || 5;
|
||
const maxSymbolsPerProcess = params.max_symbols || 10;
|
||
const includeContent = params.include_content ?? false;
|
||
const searchQuery = rawQuery.trim();
|
||
|
||
// Per-phase timing instrumentation (#553). Records wall time for each
|
||
// observable sub-step of the search pipeline so production latency can
|
||
// be aggregated offline for Pareto analysis and bottleneck detection.
|
||
// Overhead is <0.1 ms per phase; the timer is passive and never alters
|
||
// query behaviour.
|
||
const timer = new PhaseTimer();
|
||
const wallStart = performance.now();
|
||
|
||
// Step 1: Run hybrid search to get matching symbols. BM25 and vector
|
||
// search run concurrently via Promise.all — use `timer.time()` for
|
||
// each so both get independent wall-time records without fighting
|
||
// over a single `current` phase slot.
|
||
const searchLimit = processLimit * maxSymbolsPerProcess; // fetch enough raw results
|
||
const [bm25SearchResult, semanticResults] = await Promise.all([
|
||
timer.time('bm25', this.bm25Search(repo, searchQuery, searchLimit)),
|
||
timer.time('vector', this.semanticSearch(repo, searchQuery, searchLimit)),
|
||
]);
|
||
|
||
// Guard against undefined results (#1489) — when FTS is entirely
|
||
// unavailable the search helper may return an unexpected shape.
|
||
const bm25Results = bm25SearchResult?.results ?? [];
|
||
const ftsUsed = bm25SearchResult?.ftsUsed ?? false;
|
||
// #2767: log every non-benign per-table FTS query error server-side,
|
||
// regardless of whether OTHER tables succeeded — previously a real error
|
||
// on N-1 of N tables while one succeeded left zero diagnostic trail.
|
||
const ftsQueryErrors = bm25SearchResult?.nonBenignErrors;
|
||
if (ftsQueryErrors) {
|
||
// tri-review NEW-5: these strings are already classified non-benign by
|
||
// classifyFtsQueryError — do NOT route them through logQueryError,
|
||
// whose own broader, unanchored isBenignMissingTableError regex (any
|
||
// "does not exist" substring, anywhere) could disagree and silently
|
||
// demote an already-flagged real error to debug, undercutting the
|
||
// severity signal this classification exists to preserve.
|
||
for (const err of ftsQueryErrors) {
|
||
logger.warn({ context: 'query:fts-search', err }, 'GitNexus query failed (degraded)');
|
||
}
|
||
}
|
||
|
||
// Merge via reciprocal rank fusion
|
||
timer.start('merge');
|
||
const scoreMap = new Map<string, { score: number; data: any }>();
|
||
|
||
for (let i = 0; i < bm25Results.length; i++) {
|
||
const result = bm25Results[i];
|
||
const key = result.nodeId || result.filePath;
|
||
const rrfScore = 1 / (60 + i);
|
||
const existing = scoreMap.get(key);
|
||
if (existing) {
|
||
existing.score += rrfScore;
|
||
} else {
|
||
scoreMap.set(key, { score: rrfScore, data: result });
|
||
}
|
||
}
|
||
|
||
const safeSemanticResults = semanticResults ?? [];
|
||
for (let i = 0; i < safeSemanticResults.length; i++) {
|
||
const result = safeSemanticResults[i];
|
||
const key = result.nodeId || result.filePath;
|
||
const rrfScore = 1 / (60 + i);
|
||
const existing = scoreMap.get(key);
|
||
if (existing) {
|
||
existing.score += rrfScore;
|
||
} else {
|
||
scoreMap.set(key, { score: rrfScore, data: result });
|
||
}
|
||
}
|
||
|
||
// Tiebreak on the key (#2787). `rrfScore` is `1 / (60 + i)` off the ARRAY
|
||
// POSITION of each hit, so equal scores are routine, and `Array.sort` is
|
||
// stable — ties would otherwise be resolved by Map insertion order, which
|
||
// is DB row order. The expansion queries feeding `bm25Results` are ordered
|
||
// now, so this is belt-and-braces; it also stops a future unordered query
|
||
// upstream from silently reintroducing the drift at the `slice` boundary.
|
||
const merged = Array.from(scoreMap.entries())
|
||
.sort((a, b) => b[1].score - a[1].score || compareCodeUnits(a[0], b[0]))
|
||
.slice(0, searchLimit);
|
||
timer.stop(); // merge
|
||
|
||
// Step 2: For each match with a nodeId, trace to process(es)
|
||
timer.start('symbol_lookup');
|
||
const processMap = new Map<
|
||
string,
|
||
{
|
||
id: string;
|
||
label: string;
|
||
heuristicLabel: string;
|
||
processType: string;
|
||
stepCount: number;
|
||
totalScore: number;
|
||
cohesionBoost: number;
|
||
symbols: any[];
|
||
}
|
||
>();
|
||
const definitions: any[] = []; // standalone symbols not in any process
|
||
|
||
// Batch-fetch process participation, cohesion, and (optionally) content for
|
||
// ALL matched symbols in 2-3 graph queries instead of 2-3 *per symbol*. The
|
||
// previous per-symbol loop issued up to 3N sequential pool round-trips
|
||
// (searchLimit symbols × {STEP_IN_PROCESS, MEMBER_OF, content}); on a warm
|
||
// repo the IPC + query-setup overhead of those round-trips dominated query
|
||
// latency. Collapsing to `WHERE n.id IN $nodeIds` preserves identical output
|
||
// (the aggregation loop below is unchanged) while cutting the round-trips.
|
||
// Array params bind through the pool exactly as bm25Search's
|
||
// `WHERE n.id IN $nodeIds` already does. (Ported from gitnexus-enterprise
|
||
// PR #222 — N+1 → 2-3 batched queries.)
|
||
const nodeIds = merged.map(([, m]) => m.data?.nodeId).filter((id): id is string => !!id);
|
||
|
||
const processRowsByNode = new Map<string, any[]>();
|
||
const cohesionByNode = new Map<string, { cohesion: number; module?: string }>();
|
||
const contentByNode = new Map<string, string>();
|
||
// Set when a batched enrichment query throws a REAL failure (timeout, lock,
|
||
// native fault) — NOT the benign "no Process/Community table" case, which is
|
||
// a normal config (a repo analyzed without processes/communities) and must
|
||
// not raise a `partial` flag callers would learn to ignore. See
|
||
// isBenignMissingTableError + the response build below.
|
||
let enrichmentDegraded = false;
|
||
|
||
// Chunk the IN-list like the impact path (CHUNK_SIZE=100) so a large result
|
||
// set never builds an unbounded `IN` parameter. Default batch is
|
||
// processLimit*maxSymbolsPerProcess (≤ one chunk), but chunk for robustness.
|
||
const QUERY_CHUNK_SIZE = 100;
|
||
for (let i = 0; i < nodeIds.length; i += QUERY_CHUNK_SIZE) {
|
||
const ids = nodeIds.slice(i, i + QUERY_CHUNK_SIZE);
|
||
|
||
// Processes each symbol participates in. `n.id AS nodeId` is prepended as
|
||
// column 0 so rows from many symbols can be re-associated to their symbol.
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
WHERE n.id IN $nodeIds
|
||
RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
||
ORDER BY nodeId, pid, step
|
||
`,
|
||
{ nodeIds: ids },
|
||
);
|
||
for (const row of rows) {
|
||
const nid = row.nodeId ?? row[0];
|
||
let list = processRowsByNode.get(nid);
|
||
if (!list) processRowsByNode.set(nid, (list = []));
|
||
list.push(row);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('query:process-lookup', e);
|
||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||
}
|
||
|
||
// Cluster membership + cohesion. Keep the FIRST community row per node to
|
||
// mirror the prior per-symbol `LIMIT 1` (each symbol keeps ITS community,
|
||
// not one community for the whole batch).
|
||
//
|
||
// "First" only means something once the rows are ordered (#2787): a node
|
||
// can hold several MEMBER_OF edges, and the winner set `cohesion`, which
|
||
// feeds cohesionBoost -> priority -> the processes `slice` below. ORDER BY
|
||
// c.id makes the pick the lowest community id, every run.
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||
WHERE n.id IN $nodeIds
|
||
RETURN n.id AS nodeId, c.cohesion AS cohesion, c.heuristicLabel AS module
|
||
ORDER BY nodeId, c.id
|
||
`,
|
||
{ nodeIds: ids },
|
||
);
|
||
for (const row of rows) {
|
||
const nid = row.nodeId ?? row[0];
|
||
if (!cohesionByNode.has(nid)) {
|
||
cohesionByNode.set(nid, {
|
||
cohesion: (row.cohesion ?? row[1]) || 0,
|
||
module: row.module ?? row[2],
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logQueryError('query:cluster-info', e);
|
||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||
}
|
||
|
||
// Optionally fetch content for every matched symbol.
|
||
if (includeContent) {
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)
|
||
WHERE n.id IN $nodeIds
|
||
RETURN n.id AS nodeId, n.content AS content
|
||
`,
|
||
{ nodeIds: ids },
|
||
);
|
||
for (const row of rows) {
|
||
const nid = row.nodeId ?? row[0];
|
||
contentByNode.set(nid, row.content ?? row[1]);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('query:content-fetch', e);
|
||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Aggregation is unchanged from the per-symbol version — it now reads the
|
||
// pre-fetched maps instead of issuing a query per symbol. Iterating `merged`
|
||
// in the same (sorted) order preserves processMap insertion order, the
|
||
// definitions order, and the item.score association exactly.
|
||
for (const [_, item] of merged) {
|
||
const sym = item.data;
|
||
if (!sym.nodeId) {
|
||
// File-level results go to definitions
|
||
definitions.push({
|
||
name: sym.name,
|
||
type: sym.type || 'File',
|
||
filePath: sym.filePath,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const processRows = processRowsByNode.get(sym.nodeId) ?? [];
|
||
const coh = cohesionByNode.get(sym.nodeId);
|
||
const cohesion = coh?.cohesion ?? 0;
|
||
const module = coh?.module;
|
||
const content = includeContent ? contentByNode.get(sym.nodeId) : undefined;
|
||
|
||
const symbolEntry = {
|
||
id: sym.nodeId,
|
||
name: sym.name,
|
||
type: sym.type,
|
||
filePath: sym.filePath,
|
||
startLine: toDisplayLine(sym.startLine),
|
||
endLine: toDisplayLine(sym.endLine),
|
||
...(module ? { module } : {}),
|
||
...(includeContent && content ? { content } : {}),
|
||
};
|
||
|
||
if (processRows.length === 0) {
|
||
// Symbol not in any process — goes to definitions
|
||
definitions.push(symbolEntry);
|
||
} else {
|
||
// Add to each process it belongs to
|
||
for (const row of processRows) {
|
||
// Positional fallbacks shift +1 because `n.id AS nodeId` is column 0.
|
||
const pid = row.pid ?? row[1];
|
||
const label = row.label ?? row[2];
|
||
const hLabel = row.heuristicLabel ?? row[3];
|
||
const pType = row.processType ?? row[4];
|
||
const stepCount = row.stepCount ?? row[5];
|
||
const step = row.step ?? row[6];
|
||
|
||
if (!processMap.has(pid)) {
|
||
processMap.set(pid, {
|
||
id: pid,
|
||
label,
|
||
heuristicLabel: hLabel,
|
||
processType: pType,
|
||
stepCount,
|
||
totalScore: 0,
|
||
cohesionBoost: 0,
|
||
symbols: [],
|
||
});
|
||
}
|
||
|
||
const proc = processMap.get(pid)!;
|
||
proc.totalScore += item.score;
|
||
proc.cohesionBoost = Math.max(proc.cohesionBoost, cohesion);
|
||
proc.symbols.push({
|
||
...symbolEntry,
|
||
process_id: pid,
|
||
step_index: step,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
timer.stop(); // symbol_lookup
|
||
|
||
// Step 3: Rank processes by aggregate score + internal cohesion boost
|
||
timer.start('ranking');
|
||
const rankedProcesses = Array.from(processMap.values())
|
||
.map((p) => ({
|
||
...p,
|
||
priority: p.totalScore + p.cohesionBoost * 0.1, // cohesion as subtle ranking signal
|
||
}))
|
||
.sort((a, b) => b.priority - a.priority || compareCodeUnits(a.id, b.id))
|
||
.slice(0, processLimit);
|
||
timer.stop(); // ranking
|
||
|
||
// Step 4: Build response
|
||
timer.start('formatting');
|
||
const processes = rankedProcesses.map((p) => ({
|
||
id: p.id,
|
||
summary: p.heuristicLabel || p.label,
|
||
priority: Math.round(p.priority * 1000) / 1000,
|
||
symbol_count: p.symbols.length,
|
||
process_type: p.processType,
|
||
step_count: p.stepCount,
|
||
}));
|
||
|
||
const processSymbols = rankedProcesses.flatMap((p) =>
|
||
p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({
|
||
...s,
|
||
// remove internal fields
|
||
})),
|
||
);
|
||
|
||
// Deduplicate process_symbols by id
|
||
const seen = new Set<string>();
|
||
const dedupedSymbols = processSymbols.filter((s) => {
|
||
if (seen.has(s.id)) return false;
|
||
seen.add(s.id);
|
||
return true;
|
||
});
|
||
timer.stop(); // formatting
|
||
|
||
// End-to-end wall time — deliberately a separate mark so callers can
|
||
// compare sum(phases) vs wall to see how much Promise.all concurrency
|
||
// saved. Must come before summary() so it's included.
|
||
timer.mark('wall', performance.now() - wallStart);
|
||
const timing = timer.summary();
|
||
logQueryTiming(searchQuery, timing);
|
||
|
||
// Compose a single `warning` from all degraded conditions (FTS-missing
|
||
// and/or a real enrichment failure) so neither overwrites the other, and
|
||
// flag `partial` when enrichment was lost. Both are omitted on the clean
|
||
// path, leaving the success-path response shape byte-identical.
|
||
const warnings: string[] = [];
|
||
if (!ftsUsed) {
|
||
// #2767: attach what THIS session resolved (repo/branch/indexed-at) so a
|
||
// CLI/MCP mismatch is visible in the warning itself rather than requiring
|
||
// a separate debugging round-trip. tri-review NEW-3: `indexedAt` reads
|
||
// from `lastObservedPoolState` (kept current by ensureInitialized's
|
||
// staleness check, including a same-call reinit) rather than the `repo`
|
||
// handle resolved before that check ran — a warm backend that just
|
||
// reopened against a newer on-disk index must not warn with stale
|
||
// metadata. No extra I/O: the map is already maintained per-request.
|
||
const warningContext = {
|
||
repoName: repo.name,
|
||
branch: repo.branch,
|
||
indexedAt: this.lastObservedPoolState.get(repo.lbugPath)?.indexedAt ?? repo.indexedAt,
|
||
};
|
||
// tri-review NEW-1: every table failing for a REAL error (timeout,
|
||
// connection reset) is not a missing-index condition — `ftsDegradedWarning`'s
|
||
// "run --repair-fts" headline won't fix it. Route to a dedicated message
|
||
// instead of burying the real cause as a trailing suffix on bad advice.
|
||
warnings.push(
|
||
ftsQueryErrors
|
||
? ftsQueryFailedWarning({ ...warningContext, lastErrorRedacted: ftsQueryErrors[0] })
|
||
: ftsDegradedWarning(warningContext),
|
||
);
|
||
} else if (ftsQueryErrors) {
|
||
// #2767: at least one FTS table succeeded (ftsUsed=true) but another
|
||
// hit a real, non-benign error — results may be silently missing
|
||
// matches from that table with no signal, the same "partial success"
|
||
// shape the enrichmentDegraded branch below already surfaces. Mirror
|
||
// that convention instead of only logging server-side.
|
||
warnings.push(
|
||
`FTS keyword search partially failed — ${ftsQueryErrors.length} of the configured indexes hit a query error and were skipped; results may be missing matches from those node types (see server logs).`,
|
||
);
|
||
}
|
||
// #2331: a CJK query against a server process resolving
|
||
// GITNEXUS_FTS_CJK_SEGMENTATION to 'none' silently misses sub-phrase
|
||
// matches with no other signal — this is the only place an agent driving
|
||
// GitNexus through the query tool can learn the capability exists.
|
||
try {
|
||
const cjkMode = getSearchFTSCjkSegmentation();
|
||
if (containsSegmentableCjkRun(searchQuery) && cjkMode !== 'bigram') {
|
||
warnings.push(
|
||
'Query contains CJK characters — sub-phrase matches require GITNEXUS_FTS_CJK_SEGMENTATION=bigram set for both `analyze` and this server process, then `gitnexus analyze --force`.',
|
||
);
|
||
} else if (
|
||
cjkMode === 'bigram' &&
|
||
searchQuery.length > MAX_CJK_SEGMENTATION_QUERY_LENGTH &&
|
||
containsSegmentableCjkRun(searchQuery)
|
||
) {
|
||
// #2339: bigram mode is enabled, but the query exceeds the length
|
||
// cap that guards segmentCjkSpans's per-character allocation cost —
|
||
// applyCjkSegmentationIfEnabled silently skips segmentation above
|
||
// this length, so an over-cap CJK query returns zero results for
|
||
// text that IS indexed and present verbatim, with no other signal.
|
||
warnings.push(
|
||
`Query exceeds the ${MAX_CJK_SEGMENTATION_QUERY_LENGTH}-character CJK segmentation cap — ` +
|
||
'sub-phrase matches are skipped for this query even though GITNEXUS_FTS_CJK_SEGMENTATION=bigram is enabled. Shorten the query to search within the cap.',
|
||
);
|
||
}
|
||
} catch (err) {
|
||
// Best-effort diagnostic only — never fail the query over it.
|
||
logQueryError('query:cjk-warning', err);
|
||
}
|
||
// #2339: the checks above only compare the QUERY's own content against
|
||
// the live process's mode — they can't detect "server mode is 'bigram'
|
||
// but the on-disk index was actually built under 'none'/legacy" (env var
|
||
// changed without a full --force re-analyze, or a plain/--repair-fts
|
||
// analyze ran instead). That mismatch affects every CJK query against
|
||
// this repo, not just one whose own text happens to contain CJK, so it's
|
||
// a separate, unconditional check — not folded into the branches above.
|
||
//
|
||
// Hoisted out of the try below so the vector-width check after it reads the
|
||
// same meta instead of paying a second read per query, and so an invalid
|
||
// GITNEXUS_FTS_CJK_SEGMENTATION (the only thing that actually throws in
|
||
// there) cannot take an unrelated diagnostic down with it. Needs no guard
|
||
// of its own: loadMeta() returns null on any read/parse failure.
|
||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||
try {
|
||
// meta.json is on-disk state inside the analyzed repo, read via a
|
||
// schema-less JSON.parse — not trusted input. Validate before
|
||
// interpolating it into agent-visible tool output (#2339): an
|
||
// unrecognized value is itself evidence of a corrupt/foreign index,
|
||
// reported generically rather than echoed verbatim.
|
||
const persistedMode = meta?.cjkSegmentation;
|
||
if (meta && persistedMode !== undefined && !isSupportedCjkSegmentationMode(persistedMode)) {
|
||
warnings.push(
|
||
"This repo's index metadata has an unrecognized CJK segmentation mode stamp — the index " +
|
||
'may be corrupt or from an incompatible GitNexus version. Run `gitnexus analyze --force` to rebuild it.',
|
||
);
|
||
} else if (
|
||
meta &&
|
||
cjkSegmentationModeMismatch(meta.cjkSegmentation, getSearchFTSCjkSegmentation())
|
||
) {
|
||
warnings.push(
|
||
`Index was built with CJK segmentation mode '${meta.cjkSegmentation ?? 'none'}', but this ` +
|
||
`server is resolving '${getSearchFTSCjkSegmentation()}' — sub-phrase CJK search results ` +
|
||
'may be incomplete. Set GITNEXUS_FTS_CJK_SEGMENTATION to the same value for both the ' +
|
||
'`analyze` process and this server, then run `gitnexus analyze --force` to rebuild under ' +
|
||
"the agreed mode (do not assume the live server's mode is the one to keep — re-analyzing " +
|
||
'under the wrong mode can strip an already-working bigram-segmented index back to `none`).',
|
||
);
|
||
}
|
||
} catch (err) {
|
||
// loadMeta() itself never throws (it returns null on any read/parse
|
||
// failure) — the actual throw source here is getSearchFTSCjkSegmentation()
|
||
// on an invalid env value, same root cause as the catch above. This is
|
||
// a separate, independently-guarded diagnostic though, so it gets its
|
||
// own log context rather than sharing 'query:cjk-warning'.
|
||
logQueryError('query:cjk-mode-drift', err);
|
||
}
|
||
// #2798: the query-side half of the vector-column width guard. `analyze`
|
||
// compares `RepoMeta.embeddingDims` against its own live width and forces a
|
||
// full rebuild; a SERVING process cannot rebuild anything, so it says so
|
||
// instead. `CodeEmbedding.embedding` is `FLOAT[N]` fixed at build time, and
|
||
// when this process embeds at a different N the vector CALL fails its CAST
|
||
// and the exact-scan fallback scores a query vector against stored vectors
|
||
// of another length — wrong or empty semantic hits whose only trace was a
|
||
// once-per-process server log the agent driving this tool never sees.
|
||
//
|
||
// Warn, never refuse: BM25 results are still good, and the hybrid answer
|
||
// minus its semantic lane beats no answer at all. Same shape as the CJK
|
||
// drift check above — composed into `warnings`, recomputed per query rather
|
||
// than latched, and carrying the fix rather than just the symptom.
|
||
//
|
||
// Gated on a width this call actually embedded at (see
|
||
// `lastQueryEmbeddingDims`) so the tools that never embed — every other
|
||
// method on this backend — and every index analyzed without `--embeddings`
|
||
// stay quiet. ABSENT `embeddingDims` is NOT a mismatch: that is
|
||
// `embeddingDimsMismatch`'s own rule, reused rather than restated so the
|
||
// two sides of the guard cannot drift apart.
|
||
const queryEmbeddingDims = this.lastQueryEmbeddingDims.get(repo.lbugPath);
|
||
if (
|
||
queryEmbeddingDims !== undefined &&
|
||
meta &&
|
||
embeddingDimsMismatch(meta.embeddingDims, queryEmbeddingDims)
|
||
) {
|
||
// Only NAME a recorded width that could be one — meta.json is untrusted,
|
||
// schema-less on-disk state, so a value that is not a positive integer is
|
||
// reported generically rather than echoed into agent-visible output (the
|
||
// reason the CJK stamp above is validated, and what run-analyze does with
|
||
// the same field).
|
||
const recordedDims: unknown = meta.embeddingDims;
|
||
const built =
|
||
typeof recordedDims === 'number' && Number.isInteger(recordedDims) && recordedDims > 0
|
||
? `FLOAT[${recordedDims}]`
|
||
: 'an unrecognized width';
|
||
warnings.push(
|
||
`Index's vector column was built at ${built}, but this server embeds queries at ` +
|
||
`FLOAT[${queryEmbeddingDims}] — semantic search results may be wrong or missing (the ` +
|
||
'width is fixed when the index is built, and no incremental run revisits it). Re-run ' +
|
||
'`gitnexus analyze --force` with the embedding configuration this server uses, or pin ' +
|
||
'both sides to one width with GITNEXUS_EMBEDDING_DIMS (or `analyze --embedding-dims`). ' +
|
||
'Keyword results are unaffected.',
|
||
);
|
||
}
|
||
if (enrichmentDegraded) {
|
||
warnings.push(
|
||
'Symbol enrichment partially failed — some process/cohesion/content data may be missing from these results (see server logs).',
|
||
);
|
||
}
|
||
// #2767: a partial FTS failure (some tables ok, one or more real errors)
|
||
// is as much a "results may be incomplete" signal as enrichmentDegraded —
|
||
// flag it the same way rather than only via the warning string.
|
||
const ftsPartial = ftsUsed && !!ftsQueryErrors;
|
||
|
||
return {
|
||
processes,
|
||
process_symbols: dedupedSymbols,
|
||
definitions: definitions.slice(0, 20), // cap standalone definitions
|
||
timing,
|
||
...(warnings.length > 0 && { warning: warnings.join(' ') }),
|
||
...((enrichmentDegraded || ftsPartial) && { partial: true }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* BM25 keyword search helper - uses LadybugDB FTS for always-fresh results
|
||
*/
|
||
private async bm25Search(
|
||
repo: RepoHandle,
|
||
query: string,
|
||
limit: number,
|
||
): Promise<{ results: any[]; ftsUsed: boolean; nonBenignErrors?: string[] }> {
|
||
let searchFTSFromLbug;
|
||
try {
|
||
({ searchFTSFromLbug } = await import('../../core/search/bm25-index.js'));
|
||
} catch (err: any) {
|
||
// Module import can fail in sandboxed MCP contexts (#1489)
|
||
logger.warn(
|
||
{ err: err?.message },
|
||
'GitNexus: bm25-index.js import failed — falling back to semantic-only',
|
||
);
|
||
return { results: [], ftsUsed: false };
|
||
}
|
||
let ftsResponse;
|
||
try {
|
||
ftsResponse = await searchFTSFromLbug(query, limit, repo.lbugPath);
|
||
} catch (err: any) {
|
||
// Swallowed, gracefully-degraded failure: the search falls back to
|
||
// semantic-only (a valid result), and the most common cause is simply an
|
||
// un-indexed FTS extension — a normal configuration, not an operation
|
||
// error. Logged at warn (matching the sibling import-failure fallback
|
||
// above), never error, so it does not raise a false alarm.
|
||
logger.warn(
|
||
{ err: err.message },
|
||
'GitNexus: BM25/FTS search failed (FTS indexes may not exist) — falling back to semantic-only',
|
||
);
|
||
return { results: [], ftsUsed: false };
|
||
}
|
||
|
||
// Guard against unexpected response shape (#1489) — ftsResponse.results
|
||
// could be undefined when the FTS extension is unavailable in the MCP process.
|
||
const bm25Results = ftsResponse?.results ?? [];
|
||
const ftsUsed = ftsResponse?.ftsAvailable ?? false;
|
||
const nonBenignErrors = ftsResponse?.nonBenignErrors;
|
||
|
||
const results: any[] = [];
|
||
|
||
for (const bm25Result of bm25Results) {
|
||
const fullPath = bm25Result.filePath;
|
||
try {
|
||
// Prefer direct nodeId lookup (exact FTS-matched nodes) over filePath fallback.
|
||
// Without this, LIMIT 3 on filePath returns arbitrary symbols rather than
|
||
// the nodes that actually scored highest in the BM25 index.
|
||
const nodeIds = bm25Result.nodeIds?.length ? bm25Result.nodeIds : null;
|
||
const symbols = nodeIds
|
||
? await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)
|
||
WHERE n.id IN $nodeIds
|
||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||
ORDER BY startLine, id
|
||
`,
|
||
{ nodeIds },
|
||
)
|
||
: await executeParameterized(
|
||
repo.lbugPath,
|
||
// Same BasicBlock exclusion as detect_changes (#2082 U7): on a
|
||
// --pdg index a function-heavy file has far more BasicBlock rows
|
||
// than symbols, so an unfiltered LIMIT 3 would surface nameless
|
||
// substrate rows and displace the real symbols.
|
||
`
|
||
MATCH (n)
|
||
WHERE n.filePath = $filePath
|
||
AND NOT n.id STARTS WITH 'BasicBlock:'
|
||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||
ORDER BY startLine, id
|
||
LIMIT 3
|
||
`,
|
||
{ filePath: fullPath },
|
||
);
|
||
|
||
if (symbols.length > 0) {
|
||
for (const sym of symbols) {
|
||
results.push({
|
||
nodeId: sym.id || sym[0],
|
||
name: sym.name || sym[1],
|
||
type: sym.type || sym[2],
|
||
filePath: sym.filePath || sym[3],
|
||
// Raw 0-based here — `bm25Search` is only called from `query()`,
|
||
// whose aggregation loop applies `toDisplayLine` once (see below).
|
||
// Converting here too would double-shift BM25-matched lines (#2380).
|
||
startLine: sym.startLine ?? sym[4],
|
||
endLine: sym.endLine ?? sym[5],
|
||
bm25Score: bm25Result.score,
|
||
});
|
||
}
|
||
} else {
|
||
const fileName = fullPath.split('/').pop() || fullPath;
|
||
results.push({
|
||
name: fileName,
|
||
type: 'File',
|
||
filePath: bm25Result.filePath,
|
||
bm25Score: bm25Result.score,
|
||
});
|
||
}
|
||
} catch {
|
||
const fileName = fullPath.split('/').pop() || fullPath;
|
||
results.push({
|
||
name: fileName,
|
||
type: 'File',
|
||
filePath: bm25Result.filePath,
|
||
bm25Score: bm25Result.score,
|
||
});
|
||
}
|
||
}
|
||
|
||
return { results, ftsUsed, ...(nonBenignErrors && { nonBenignErrors }) };
|
||
}
|
||
|
||
/**
|
||
* Semantic vector search helper
|
||
*/
|
||
private async semanticSearch(repo: RepoHandle, query: string, limit: number): Promise<any[]> {
|
||
// Whether THIS call produced a query vector — see `lastQueryEmbeddingDims`.
|
||
// A local flag, not a re-read of the map: the map may still hold an earlier
|
||
// call's width, and the catch below must only clear an entry it did not set.
|
||
let embeddedDims: number | undefined;
|
||
try {
|
||
// Check if embedding table exists before loading the model (avoids heavy model init when embeddings are off)
|
||
// determinism: probe — aggregate singleton. COUNT(*) with no grouping key returns exactly one row, and only
|
||
// the count is read, to decide whether to load the embedding model at all.
|
||
const tableCheck = await executeQuery(
|
||
repo.lbugPath,
|
||
`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN COUNT(*) AS cnt LIMIT 1`,
|
||
);
|
||
if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0) {
|
||
// No vectors to search: nothing is embedded below, so drop any width a
|
||
// previous call recorded rather than let query() warn about a lane that
|
||
// did not run this time (#2798).
|
||
this.lastQueryEmbeddingDims.delete(repo.lbugPath);
|
||
return [];
|
||
}
|
||
|
||
const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
|
||
const queryVec = await embedQuery(query);
|
||
const dims = getEmbeddingDims();
|
||
// #2798: the width this query vector really was produced at — the same
|
||
// value the CAST below binds against the index's `FLOAT[N]` column, and
|
||
// therefore the only honest comparand for query()'s drift warning.
|
||
embeddedDims = dims;
|
||
this.lastQueryEmbeddingDims.set(repo.lbugPath, dims);
|
||
const queryVecStr = `[${queryVec.join(',')}]`;
|
||
const maxDistance = getVectorMaxDistance(DEFAULT_MCP_VECTOR_MAX_DISTANCE);
|
||
|
||
let bestChunks = new Map<
|
||
string,
|
||
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
|
||
>();
|
||
// Always TRY the vector lane — no platform gate. LadybugDB ships the
|
||
// VECTOR extension for every supported platform, Windows included
|
||
// (#2623 follow-up; the old `platform !== 'win32'` gate was stale), so
|
||
// whether the index is queryable is a per-machine runtime fact. The
|
||
// catch below is the fallback: any failure (extension unloadable, index
|
||
// absent, older DB) degrades to the exact scan with a once-per-backend
|
||
// diagnostic instead of being silently swallowed.
|
||
try {
|
||
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
|
||
const vectorQuery = `
|
||
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
|
||
CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit})
|
||
YIELD node AS emb, distance
|
||
WITH emb, distance
|
||
WHERE distance < ${maxDistance}
|
||
RETURN emb.nodeId AS nodeId, emb.chunkIndex AS chunkIndex,
|
||
emb.startLine AS startLine, emb.endLine AS endLine, distance
|
||
ORDER BY distance
|
||
`;
|
||
|
||
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
|
||
return embResults.map((row) => ({
|
||
nodeId: row.nodeId ?? row[0],
|
||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||
startLine: row.startLine ?? row[2] ?? 0,
|
||
endLine: row.endLine ?? row[3] ?? 0,
|
||
distance: row.distance ?? row[4],
|
||
}));
|
||
});
|
||
} catch (err) {
|
||
bestChunks = new Map();
|
||
if (!this.warnedVectorUnsupported) {
|
||
// Rare diagnostic: surface why semantic search fell back to the
|
||
// exact scan. Emitted once per `LocalBackend` instance lifetime to
|
||
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
|
||
this.warnedVectorUnsupported = true;
|
||
logger.warn(
|
||
{ err },
|
||
'GitNexus [query:vector]: vector index query failed; using exact scan fallback',
|
||
);
|
||
}
|
||
}
|
||
|
||
if (bestChunks.size === 0) {
|
||
const embeddingCount = Number(tableCheck[0].cnt ?? tableCheck[0][0] ?? 0);
|
||
const exactLimit = getExactScanLimit();
|
||
if (embeddingCount > exactLimit) return [];
|
||
|
||
const rows = await executeQuery(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (e:${EMBEDDING_TABLE_NAME})
|
||
RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex,
|
||
e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding
|
||
`,
|
||
);
|
||
const exactRows: ExactEmbeddingRow[] = rows.map((row) => ({
|
||
nodeId: row.nodeId ?? row[0],
|
||
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
|
||
startLine: row.startLine ?? row[2] ?? 0,
|
||
endLine: row.endLine ?? row[3] ?? 0,
|
||
embedding: row.embedding ?? row[4] ?? [],
|
||
}));
|
||
bestChunks = new Map(
|
||
rankExactEmbeddingRows(exactRows, queryVec, limit, maxDistance).map((row) => [
|
||
row.nodeId,
|
||
{
|
||
distance: row.distance,
|
||
chunkIndex: row.chunkIndex,
|
||
startLine: row.startLine,
|
||
endLine: row.endLine,
|
||
},
|
||
]),
|
||
);
|
||
}
|
||
|
||
if (bestChunks.size === 0) return [];
|
||
|
||
const results: any[] = [];
|
||
|
||
for (const [nodeId, chunk] of Array.from(bestChunks.entries()).slice(0, limit)) {
|
||
const labelEndIdx = nodeId.indexOf(':');
|
||
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
|
||
|
||
// Validate label against known node types to prevent Cypher injection
|
||
if (!VALID_NODE_LABELS.has(label)) continue;
|
||
|
||
try {
|
||
const nodeQuery =
|
||
label === 'File'
|
||
? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
|
||
: `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`;
|
||
|
||
const nodeRows = await executeParameterized(repo.lbugPath, nodeQuery, { nodeId });
|
||
if (nodeRows.length > 0) {
|
||
const nodeRow = nodeRows[0];
|
||
results.push({
|
||
nodeId,
|
||
name: nodeRow.name ?? nodeRow[0] ?? '',
|
||
type: label,
|
||
filePath: nodeRow.filePath ?? nodeRow[1] ?? '',
|
||
distance: chunk.distance,
|
||
startLine: chunk.startLine,
|
||
endLine: chunk.endLine,
|
||
});
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
return results;
|
||
} catch (err) {
|
||
// Nothing was embedded on this path unless the throw happened after the
|
||
// vector existed (a failed lookup downstream of a good embedding, where
|
||
// the width IS still the live one). Clearing only in the former case
|
||
// keeps the recorded width a fact rather than a leftover (#2798).
|
||
if (embeddedDims === undefined) this.lastQueryEmbeddingDims.delete(repo.lbugPath);
|
||
// Embeddings disabled is the common, silent case. But a pruned or
|
||
// Node-unloadable optional stack (#2370/#2372) also lands here — surface it
|
||
// once so semantic search doesn't silently degrade to BM25 with no hint
|
||
// (the exact silent-degradation mode #2370 exists to fix). Emitted once per
|
||
// LocalBackend instance to keep stderr quiet on hot paths (like the VECTOR
|
||
// fallback above). All other errors stay silent, as before.
|
||
const message = err instanceof Error ? err.message : '';
|
||
if (
|
||
!this.warnedMissingEmbeddingStack &&
|
||
(isMissingLocalEmbeddingStackMessage(message) ||
|
||
isLocalEmbeddingRuntimeBlockerMessage(message))
|
||
) {
|
||
this.warnedMissingEmbeddingStack = true;
|
||
logger.warn(`GitNexus [query:vector]: ${message}`);
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async executeCypher(
|
||
repoName: string,
|
||
query: string,
|
||
params: Record<string, unknown> = {},
|
||
): Promise<any> {
|
||
const repo = await this.resolveRepo(repoName);
|
||
return this.cypher(repo, { query, params });
|
||
}
|
||
|
||
private async cypher(
|
||
repo: RepoHandle,
|
||
// #2175: "statement" is the advertised param; "query" is the legacy alias,
|
||
// still accepted (and the field the internal executeCypher() passes). New wins.
|
||
request: { query?: string; statement?: string; params?: Record<string, unknown> },
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
if (!isLbugReady(repo.lbugPath)) {
|
||
return { error: 'LadybugDB not ready. Index may be corrupted.' };
|
||
}
|
||
if (request.params !== undefined && !isValidQueryParams(request.params)) {
|
||
return {
|
||
error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
|
||
};
|
||
}
|
||
|
||
const cypherText = resolveAliasString(request.statement, request.query) ?? '';
|
||
if (!cypherText.trim()) {
|
||
// Mirror query()'s friendly required-param error instead of letting an empty
|
||
// string fall through to a raw LadybugDB prepare error (#2175 review).
|
||
return { error: 'statement (or legacy query) parameter is required and cannot be empty.' };
|
||
}
|
||
|
||
try {
|
||
const result = await executeParameterized(repo.lbugPath, cypherText, request.params ?? {});
|
||
return result;
|
||
} catch (err: any) {
|
||
const msg = err.message || 'Query failed';
|
||
if (isReadOnlyDbError(err)) {
|
||
return {
|
||
error:
|
||
'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
|
||
};
|
||
}
|
||
if (isWalCorruptionError(err)) {
|
||
return {
|
||
error: msg,
|
||
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
|
||
};
|
||
}
|
||
return { error: msg };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Format raw Cypher result rows as a markdown table for LLM readability.
|
||
* Falls back to raw result if rows aren't tabular objects.
|
||
*/
|
||
private formatCypherAsMarkdown(result: any): any {
|
||
if (!Array.isArray(result) || result.length === 0) return result;
|
||
|
||
const firstRow = result[0];
|
||
if (typeof firstRow !== 'object' || firstRow === null) return result;
|
||
|
||
const keys = Object.keys(firstRow);
|
||
if (keys.length === 0) return result;
|
||
|
||
const header = '| ' + keys.join(' | ') + ' |';
|
||
const separator = '| ' + keys.map(() => '---').join(' | ') + ' |';
|
||
const dataRows = result.map(
|
||
(row: any) =>
|
||
'| ' +
|
||
keys
|
||
.map((k) => {
|
||
const v = row[k];
|
||
if (v === null || v === undefined) return '';
|
||
if (typeof v === 'object') return JSON.stringify(v);
|
||
// Collapse newlines so a multi-line cell value (e.g. a symbol's
|
||
// `content`) stays on one physical line. Otherwise the rendered row
|
||
// spans multiple lines, which corrupts the table and breaks the
|
||
// CLI's `--limit` line-based slicing (#2310 review).
|
||
return String(v).replace(/\r?\n/g, ' ');
|
||
})
|
||
.join(' | ') +
|
||
' |',
|
||
);
|
||
|
||
return {
|
||
markdown: [header, separator, ...dataRows].join('\n'),
|
||
row_count: result.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Aggregate same-named clusters: group by heuristicLabel, sum symbols,
|
||
* weighted-average cohesion, filter out tiny clusters (<5 symbols).
|
||
* Raw communities stay intact in LadybugDB for Cypher queries.
|
||
*/
|
||
private aggregateClusters(clusters: any[]): any[] {
|
||
const groups = new Map<
|
||
string,
|
||
{ ids: string[]; totalSymbols: number; weightedCohesion: number; largest: any }
|
||
>();
|
||
|
||
for (const c of clusters) {
|
||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||
const symbols = c.symbolCount || 0;
|
||
const cohesion = c.cohesion || 0;
|
||
const existing = groups.get(label);
|
||
|
||
if (!existing) {
|
||
groups.set(label, {
|
||
ids: [c.id],
|
||
totalSymbols: symbols,
|
||
weightedCohesion: cohesion * symbols,
|
||
largest: c,
|
||
});
|
||
} else {
|
||
existing.ids.push(c.id);
|
||
existing.totalSymbols += symbols;
|
||
existing.weightedCohesion += cohesion * symbols;
|
||
if (symbols > (existing.largest.symbolCount || 0)) {
|
||
existing.largest = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
return Array.from(groups.entries())
|
||
.map(([label, g]) => ({
|
||
id: g.largest.id,
|
||
label,
|
||
heuristicLabel: label,
|
||
symbolCount: g.totalSymbols,
|
||
cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0,
|
||
subCommunities: g.ids.length,
|
||
}))
|
||
.filter((c) => c.symbolCount >= 5)
|
||
.sort(
|
||
(a, b) => b.symbolCount - a.symbolCount || compareCodeUnits(String(a.id), String(b.id)),
|
||
);
|
||
}
|
||
|
||
private async overview(
|
||
repo: RepoHandle,
|
||
params: { showClusters?: boolean; showProcesses?: boolean; limit?: number },
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const limit = params.limit || 20;
|
||
const result: any = {
|
||
repo: repo.name,
|
||
repoPath: repo.repoPath,
|
||
stats: repo.stats,
|
||
indexedAt: repo.indexedAt,
|
||
lastCommit: repo.lastCommit,
|
||
};
|
||
|
||
if (params.showClusters !== false) {
|
||
try {
|
||
// Fetch more raw communities than the display limit so aggregation has enough data
|
||
const rawLimit = Math.max(limit * 5, 200);
|
||
const clusters = await executeQuery(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (c:Community)
|
||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||
ORDER BY c.symbolCount DESC, c.id
|
||
LIMIT ${rawLimit}
|
||
`,
|
||
);
|
||
const rawClusters = clusters.map((c: any) => ({
|
||
id: c.id || c[0],
|
||
label: c.label || c[1],
|
||
heuristicLabel: c.heuristicLabel || c[2],
|
||
cohesion: c.cohesion || c[3],
|
||
symbolCount: c.symbolCount || c[4],
|
||
}));
|
||
result.clusters = this.aggregateClusters(rawClusters).slice(0, limit);
|
||
} catch {
|
||
result.clusters = [];
|
||
}
|
||
}
|
||
|
||
if (params.showProcesses !== false) {
|
||
try {
|
||
const processes = await executeQuery(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:Process)
|
||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||
ORDER BY p.stepCount DESC, p.id
|
||
LIMIT ${limit}
|
||
`,
|
||
);
|
||
result.processes = processes.map((p: any) => ({
|
||
id: p.id || p[0],
|
||
label: p.label || p[1],
|
||
heuristicLabel: p.heuristicLabel || p[2],
|
||
processType: p.processType || p[3],
|
||
stepCount: p.stepCount || p[4],
|
||
}));
|
||
} catch {
|
||
result.processes = [];
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Patch the `type` field on candidates whose `labels(n)[0]` projection
|
||
* came back empty — a known LadybugDB behaviour for several node types.
|
||
*
|
||
* Uses one scoped UNION query across the priority labels rather than
|
||
* per-candidate round-trips, so cost is a single DB call regardless of how
|
||
* many candidates need enrichment. No-op when every candidate already has a
|
||
* non-empty type.
|
||
*
|
||
* The value labels (`Const` / `Variable` / `Static`) are included because a
|
||
* value candidate otherwise surfaces with `kind: ""` — which reads as
|
||
* "unknown kind" and, worse, makes the `kind` disambiguation hint unable to
|
||
* filter it out (#2687).
|
||
*
|
||
* Failures are swallowed: label enrichment is an optimisation for
|
||
* downstream scoring and #480 Class/Interface BFS seeding; if it fails
|
||
* the symbol still resolves, just without the kind-priority bonus.
|
||
*/
|
||
private async enrichCandidateLabels(
|
||
repo: RepoHandle,
|
||
candidates: Array<{ id: string; type: string }>,
|
||
): Promise<void> {
|
||
const ids = candidates.filter((c) => c.type === '' && c.id).map((c) => c.id);
|
||
if (ids.length === 0) return;
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n:\`Class\`) WHERE n.id IN $ids RETURN n.id AS id, 'Class' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Interface\`) WHERE n.id IN $ids RETURN n.id AS id, 'Interface' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Function\`) WHERE n.id IN $ids RETURN n.id AS id, 'Function' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Method\`) WHERE n.id IN $ids RETURN n.id AS id, 'Method' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Constructor\`) WHERE n.id IN $ids RETURN n.id AS id, 'Constructor' AS label
|
||
UNION ALL
|
||
MATCH (n:\`CodeElement\`) WHERE n.id IN $ids RETURN n.id AS id, 'CodeElement' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Const\`) WHERE n.id IN $ids RETURN n.id AS id, 'Const' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Variable\`) WHERE n.id IN $ids RETURN n.id AS id, 'Variable' AS label
|
||
UNION ALL
|
||
MATCH (n:\`Static\`) WHERE n.id IN $ids RETURN n.id AS id, 'Static' AS label
|
||
`,
|
||
{ ids },
|
||
);
|
||
const labelById = new Map<string, string>();
|
||
for (const r of rows as any[]) {
|
||
const id = (r.id ?? r[0]) as string;
|
||
const label = (r.label ?? r[1]) as string;
|
||
if (id && label && !labelById.has(id)) labelById.set(id, label);
|
||
}
|
||
for (const c of candidates) {
|
||
if (c.type === '' && labelById.has(c.id)) c.type = labelById.get(c.id) as string;
|
||
}
|
||
} catch {
|
||
/* best-effort — downstream resolvers still work without the label */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Score a symbol candidate for disambiguation ranking.
|
||
*
|
||
* Deterministic, no DB round-trip:
|
||
* - base 0.50
|
||
* - +0.40 when file_path hint matches (substring, case-insensitive)
|
||
* - +0.20 when kind hint exactly matches the candidate's kind
|
||
* - when no kind hint, a small priority bonus (Class > Interface >
|
||
* Function > Method > Constructor) to preserve the intuition that
|
||
* class-level names are usually what the user wanted.
|
||
*
|
||
* Capped at 1.0. Intentionally simple and inspectable — a future v2 can
|
||
* plug in BM25/embedding signals here without changing the surrounding
|
||
* resolver shape.
|
||
*/
|
||
private scoreCandidate(
|
||
c: { kind: string; filePath: string },
|
||
hints: { file_path?: string; kind?: string },
|
||
): number {
|
||
let s = 0.5;
|
||
if (hints.file_path && c.filePath && typeof c.filePath === 'string') {
|
||
if (c.filePath.toLowerCase().includes(hints.file_path.toLowerCase())) {
|
||
s += 0.4;
|
||
}
|
||
}
|
||
if (hints.kind && c.kind === hints.kind) {
|
||
s += 0.2;
|
||
}
|
||
if (!hints.kind) {
|
||
const priority: Record<string, number> = {
|
||
Class: 5,
|
||
Interface: 4,
|
||
Function: 3,
|
||
Method: 2,
|
||
Constructor: 1,
|
||
};
|
||
s += (priority[c.kind] ?? 0) * 0.02;
|
||
}
|
||
return Math.min(1.0, s);
|
||
}
|
||
|
||
/**
|
||
* Shared symbol resolver used by `context` and `impact`.
|
||
*
|
||
* Returns one of:
|
||
* - `{ kind: 'ok', symbol, resolvedLabel }` — single confident match
|
||
* (either direct UID, only one candidate after filtering, Class/
|
||
* Constructor collapse, or a top-scoring candidate with a clear gap
|
||
* to the runner-up).
|
||
* - `{ kind: 'ambiguous', candidates }` — multiple viable matches,
|
||
* sorted by score desc. Each candidate carries a relevance score.
|
||
* - `{ kind: 'not_found' }` — no matches at all.
|
||
*
|
||
* Preserves the #480 Class/Constructor preference: when the only
|
||
* ambiguity is between a Class and its own Constructor (same name,
|
||
* same filePath), the Class wins silently.
|
||
*/
|
||
private async resolveSymbolCandidates(
|
||
repo: RepoHandle,
|
||
query: { uid?: string; name?: string; include_content?: boolean },
|
||
hints: { file_path?: string; kind?: string },
|
||
): Promise<
|
||
| {
|
||
kind: 'ok';
|
||
symbol: {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
filePath: string;
|
||
startLine: number;
|
||
endLine: number;
|
||
content?: string;
|
||
};
|
||
resolvedLabel: string;
|
||
}
|
||
| {
|
||
kind: 'ambiguous';
|
||
candidates: Array<{
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
filePath: string;
|
||
startLine: number;
|
||
endLine: number;
|
||
score: number;
|
||
}>;
|
||
/**
|
||
* TRUE number of symbols matching the name, which is NOT
|
||
* `candidates.length` — the window below is capped at CANDIDATE_WINDOW.
|
||
* Callers must report this, never the array length, or the cap
|
||
* masquerades as the match count ("Found 20 symbols" when 92 exist).
|
||
*/
|
||
total: number;
|
||
/**
|
||
* Set when the COUNT leg failed, so `total` fell back to the window
|
||
* length and is a LOWER BOUND, not the exact count. Callers must pass
|
||
* it through next to `totalCandidates`: without it a failed COUNT is
|
||
* indistinguishable from a genuine 20-match result (#2787 review F3).
|
||
*/
|
||
totalIsLowerBound?: boolean;
|
||
}
|
||
| { kind: 'not_found' }
|
||
> {
|
||
const { uid, name, include_content } = query;
|
||
const selectClause = `n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}`;
|
||
|
||
// Direct UID — zero-ambiguity path.
|
||
if (uid) {
|
||
// determinism: probe — PK-anchored singleton. $uid is a node primary key, so at most one row can match and
|
||
// the LIMIT never chooses between rows.
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (n {id: $uid}) RETURN ${selectClause} LIMIT 1`,
|
||
{ uid },
|
||
);
|
||
if (rows.length === 0) return { kind: 'not_found' };
|
||
const r = rows[0] as any;
|
||
const symbol = {
|
||
id: (r.id ?? r[0]) as string,
|
||
name: (r.name ?? r[1]) as string,
|
||
type: (r.type ?? r[2] ?? '') as string,
|
||
filePath: (r.filePath ?? r[3]) as string,
|
||
startLine: (r.startLine ?? r[4]) as number,
|
||
endLine: (r.endLine ?? r[5]) as number,
|
||
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
|
||
};
|
||
// Same LadybugDB label-enrichment as the name-based path: a UID
|
||
// pointing at a Class must still surface `type: 'Class'` so impact's
|
||
// Class/Interface BFS seed fires. No-op when type is already set.
|
||
await this.enrichCandidateLabels(repo, [symbol]);
|
||
return { kind: 'ok', symbol, resolvedLabel: symbol.type };
|
||
}
|
||
|
||
if (!name) return { kind: 'not_found' };
|
||
|
||
const isQualified = name.includes('/') || name.includes(':');
|
||
let whereClause: string;
|
||
const queryParams: Record<string, any> = { symName: name };
|
||
if (hints.file_path) {
|
||
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
|
||
queryParams.filePath = hints.file_path;
|
||
} else if (isQualified) {
|
||
// Parenthesised because the kind filter below is appended with AND, which
|
||
// binds tighter than OR.
|
||
whereClause = `WHERE (n.id = $symName OR n.name = $symName)`;
|
||
} else {
|
||
whereClause = `WHERE n.name = $symName`;
|
||
}
|
||
|
||
// A `kind` hint FILTERS, it does not merely score (#2787 review F5). Node
|
||
// ids are `Label:filePath:qualifiedName`, so the ORDER BY below is a
|
||
// label-major sort: `Class` < `Const` < `Constructor` < `Function` <
|
||
// `Interface` < `Method`. A caller asking for kind:'Method' on a name with
|
||
// many Function/Const homonyms could therefore have every Method sorted out
|
||
// of the window (`run` has 7 Methods in this repo's index; 2 survive the
|
||
// ordered page), and scoreCandidate's +0.20 kind bonus can only rank rows
|
||
// that came back — it can never recover one the LIMIT dropped. The tool
|
||
// schema already calls this a "Kind filter". Same label-prefix invariant
|
||
// the ORDER BY depends on, so it costs nothing extra.
|
||
const kindClause = hints.kind ? `${whereClause} AND n.id STARTS WITH $kindPrefix` : whereClause;
|
||
const kindParams: Record<string, any> = hints.kind
|
||
? { ...queryParams, kindPrefix: `${hints.kind}:` }
|
||
: queryParams;
|
||
|
||
// LIMIT CANDIDATE_WINDOW (20; was 10) — scoring is the point now, so give
|
||
// the ranker headroom instead of arbitrary truncation.
|
||
//
|
||
// ORDER BY n.id is load-bearing, not cosmetic (#2787). A bare `LIMIT`
|
||
// hands back an ARBITRARY subset when more nodes share the name than the
|
||
// cap (`constructor` = 92 in this repo's own index, `get` = 34), and
|
||
// LadybugDB picks a different subset from one process to the next — so
|
||
// `impact`/`context` resolved a different symbol on every invocation and
|
||
// the HIGH/CRITICAL warning the agent workflow depends on fired at random.
|
||
// `n.id` is the PRIMARY KEY on every node table: non-null, unique, and a
|
||
// total order, which is what pins WHICH rows come back.
|
||
//
|
||
// `labels(n)[0]` is not an alternative ordering key: it comes back empty for
|
||
// Class nodes (see enrichCandidateLabels below), so ordering on the
|
||
// projected type would sort the HIGHEST-priority kind into the empty bucket.
|
||
//
|
||
// The COUNT reports the TRUE total, which a LIMIT-capped page's row count
|
||
// cannot stand in for — the same rule `_explainImpl` and `_pdgQueryImpl`
|
||
// already follow (#2084 review P2-4). Without it every "Found N symbols
|
||
// matching 'x'" message and the `totalCandidates` field report the cap, so
|
||
// 92 collisions read as 20 — and now that the window is ordered, that
|
||
// undercount is stable, which makes it look authoritative rather than flaky.
|
||
//
|
||
// It runs AFTER the window rather than alongside it because a SHORT page
|
||
// proves the LIMIT never bound, which makes `COUNT(*)` identically
|
||
// `rows.length`. The COUNT is unlabeled and `n.name` is unindexed, so it is
|
||
// a full scan of every node table — skipping it halves the cost of the
|
||
// hottest step in `context`/`impact`/`trace`/`rename`/`pdg_query` (`trace`
|
||
// resolves twice). Trade-off: the full-window (>= CANDIDATE_WINDOW
|
||
// homonyms) minority path is now sequential instead of concurrent.
|
||
const fetchWindow = async (where: string, params: Record<string, any>) => {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (n) ${where} RETURN ${selectClause} ORDER BY n.id LIMIT ${CANDIDATE_WINDOW}`,
|
||
params,
|
||
);
|
||
if (rows.length < CANDIDATE_WINDOW) return { rows, countedTotal: rows.length };
|
||
const countRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (n) ${where} RETURN COUNT(*) AS total`,
|
||
params,
|
||
).catch((e) => {
|
||
// Never swallowed (#2787 review F3): a failed COUNT falls through to
|
||
// the window-length floor below, and without a signal the response
|
||
// ships `totalCandidates: 20` with no `candidatesTruncated` — byte
|
||
// identical to a genuine 20-match result. `totalIsLowerBound` is the
|
||
// caller-visible degradation this log is contracted to accompany.
|
||
logQueryError('resolve:candidate-count', e);
|
||
return [] as any[];
|
||
});
|
||
return {
|
||
rows,
|
||
countedTotal: Number(
|
||
(countRows[0] as any)?.total ?? (countRows[0] as any)?.[0] ?? Number.NaN,
|
||
),
|
||
};
|
||
};
|
||
|
||
let { rows, countedTotal } = await fetchWindow(kindClause, kindParams);
|
||
if (rows.length === 0 && hints.kind) {
|
||
// `kind` is a free-form string on the tool schema, so a hint that matches
|
||
// no label prefix (wrong case, or a kind this repo has no nodes for) must
|
||
// not turn a real name into `not_found`. Fall back to the unfiltered
|
||
// window and let scoreCandidate treat the hint as a ranking term, exactly
|
||
// as it did before the filter existed.
|
||
({ rows, countedTotal } = await fetchWindow(whereClause, queryParams));
|
||
}
|
||
|
||
if (rows.length === 0) return { kind: 'not_found' };
|
||
|
||
// Normalise row shape across object / tuple returns from LadybugDB.
|
||
const normalized = rows.map((r: any) => ({
|
||
id: (r.id ?? r[0]) as string,
|
||
name: (r.name ?? r[1]) as string,
|
||
type: (r.type ?? r[2] ?? '') as string,
|
||
filePath: (r.filePath ?? r[3]) as string,
|
||
startLine: (r.startLine ?? r[4]) as number,
|
||
endLine: (r.endLine ?? r[5]) as number,
|
||
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
|
||
}));
|
||
|
||
// The COUNT can never legitimately be below the page it accompanies, so a
|
||
// value under `normalized.length` means the count leg failed or returned an
|
||
// unreadable shape. Keep the window size as the floor — reporting zero would
|
||
// be worse — but mark the number a LOWER BOUND so no consumer treats it as
|
||
// the exact match count this PR otherwise promises (#2787 review F3).
|
||
const totalIsExact = Number.isFinite(countedTotal) && countedTotal >= normalized.length;
|
||
const totalMatches = totalIsExact ? countedTotal : normalized.length;
|
||
|
||
// Enrich labels for any candidates where `labels(n)[0]` came back empty.
|
||
// LadybugDB returns an empty string for that projection on certain node
|
||
// types (notably Class), which left downstream consumers (impact's
|
||
// Class/Interface BFS seed, the kind-priority scoring bonus) unable to
|
||
// distinguish a Class target from "unknown kind". One scoped UNION
|
||
// across the priority labels patches the type in-place without
|
||
// per-candidate round-trips.
|
||
await this.enrichCandidateLabels(repo, normalized);
|
||
|
||
// Preserve #480 Class/Constructor collapse: if we have exactly one
|
||
// Class (or Interface) candidate and one Constructor sharing name +
|
||
// filePath, fold into the Class. This used to require a follow-up
|
||
// label query because LadybugDB sometimes returns an empty labels()[0]
|
||
// for Class nodes — enrichment above handles the empty-type case, but
|
||
// the `type === 'Constructor'` gate still correctly triggers when a
|
||
// Class and its Constructor share the name.
|
||
if (!hints.kind && normalized.length > 1) {
|
||
// A value candidate (`Const`/`Variable`/`Static`) used to reach here with
|
||
// `type === ''`, which is what kept this gate true for a `class Foo` +
|
||
// `const Foo` pair and let the collapse resolve it to the Class. Label
|
||
// enrichment now fills those in (#2687), so they must be named explicitly
|
||
// or the collapse silently stops firing and confident resolutions become
|
||
// `ambiguous` across every resolver-backed tool.
|
||
const ambiguousType = normalized.some(
|
||
(s) => s.type === '' || s.type === 'Constructor' || VALUE_CANDIDATE_TYPES.has(s.type),
|
||
);
|
||
// Collapsing is a CONFIDENT resolution — it returns `kind: 'ok'` and the
|
||
// caller never sees the scorer or the ambiguity report — so it may only
|
||
// fire when the label match is genuinely unique (#2787 review F4).
|
||
// Ordering the probe made the wrong pick repeatable; it did not make it
|
||
// right. Two same-named classes in two files is routine, and this is the
|
||
// only confident path a bare name can take (scoreCandidate tops out at
|
||
// 0.60 without a file_path hint, the confident gate below needs >= 0.95),
|
||
// so an arbitrary winner here is `context`/`impact` silently analysing the
|
||
// wrong file. A window that was itself truncated is disqualifying for the
|
||
// same reason: a candidate outside the page could carry the label too.
|
||
const windowIsComplete =
|
||
normalized.length < CANDIDATE_WINDOW || (totalIsExact && totalMatches <= normalized.length);
|
||
if (ambiguousType && windowIsComplete) {
|
||
const candidateIds = normalized.map((s) => s.id).filter(Boolean);
|
||
for (const label of ['Class', 'Interface']) {
|
||
// LIMIT 2, not 1: the second row is the uniqueness check. One row back
|
||
// means exactly one candidate carries the label and the collapse is
|
||
// safe; two means fall through to normal ambiguity scoring.
|
||
const labelRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (n:\`${label}\`) WHERE n.id IN $candidateIds RETURN n.id AS id ORDER BY n.id LIMIT 2`,
|
||
{ candidateIds },
|
||
).catch(() => []);
|
||
if (labelRows.length === 1) {
|
||
const preferredId = (labelRows[0] as any).id ?? (labelRows[0] as any)[0];
|
||
const preferred = normalized.find((s) => s.id === preferredId);
|
||
if (preferred) {
|
||
return {
|
||
kind: 'ok',
|
||
symbol: preferred,
|
||
resolvedLabel: label,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (normalized.length === 1) {
|
||
return {
|
||
kind: 'ok',
|
||
symbol: normalized[0],
|
||
resolvedLabel: '',
|
||
};
|
||
}
|
||
|
||
// Score, sort desc, stable tiebreak on shorter filePath then lex uid.
|
||
const scored = normalized.map((s) => ({
|
||
...s,
|
||
score: this.scoreCandidate({ kind: s.type, filePath: s.filePath || '' }, hints),
|
||
}));
|
||
scored.sort((a, b) => {
|
||
if (b.score !== a.score) return b.score - a.score;
|
||
const fpA = (a.filePath || '').length;
|
||
const fpB = (b.filePath || '').length;
|
||
if (fpA !== fpB) return fpA - fpB;
|
||
return compareCodeUnits(String(a.id), String(b.id));
|
||
});
|
||
|
||
// Confident single-result: top score ≥ 0.95 AND beats runner-up by a
|
||
// clear margin. This lets a very strong file_path/kind hint resolve
|
||
// cleanly instead of forcing the caller through a disambiguation
|
||
// round-trip.
|
||
//
|
||
// The gap threshold uses `> 0.09` rather than `>= 0.10` on purpose:
|
||
// IEEE754 addition of the scoring terms (0.50 + 0.40 + 0.20 - 0.90
|
||
// yields 0.09999999999999998, not exactly 0.10) would otherwise break
|
||
// the comparison for legitimate "top is 1.00, runner is 0.90" cases.
|
||
// The intent is a clearly-dominant winner; 0.09 is a large enough
|
||
// margin to mean that unambiguously.
|
||
//
|
||
// The `scored.length >= 2` guard is defensive. The `normalized.length === 1`
|
||
// early return above already handles the single-candidate path, so in
|
||
// practice `scored` always has at least two elements by the time we get
|
||
// here — keeping the guard means changes to the upstream early-return
|
||
// logic cannot accidentally index out of bounds at `scored[1]`.
|
||
if (scored.length >= 2 && scored[0].score >= 0.95 && scored[0].score - scored[1].score > 0.09) {
|
||
return { kind: 'ok', symbol: scored[0], resolvedLabel: scored[0].type };
|
||
}
|
||
|
||
return {
|
||
kind: 'ambiguous',
|
||
candidates: scored,
|
||
total: totalMatches,
|
||
...(totalIsExact ? {} : { totalIsLowerBound: true }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Context tool — 360-degree symbol view with categorized refs.
|
||
* Disambiguation (ranked) when multiple symbols share a name.
|
||
* UID-based direct lookup. No cluster in output.
|
||
*/
|
||
private async context(
|
||
repo: RepoHandle,
|
||
params: {
|
||
name?: string;
|
||
uid?: string;
|
||
file_path?: string;
|
||
kind?: string;
|
||
include_content?: boolean;
|
||
},
|
||
): Promise<any> {
|
||
try {
|
||
return await this._contextImpl(repo, params);
|
||
} catch (err: any) {
|
||
const msg = (err instanceof Error ? err.message : String(err)) || 'Context query failed';
|
||
if (isWalCorruptionError(err)) {
|
||
return {
|
||
error: msg,
|
||
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
|
||
};
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
private async _contextImpl(
|
||
repo: RepoHandle,
|
||
params: {
|
||
name?: string;
|
||
uid?: string;
|
||
file_path?: string;
|
||
kind?: string;
|
||
include_content?: boolean;
|
||
},
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const { name, uid, file_path, kind, include_content } = params;
|
||
|
||
if (!name && !uid) {
|
||
return { error: 'Either "name" or "uid" parameter is required.' };
|
||
}
|
||
|
||
const outcome = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid, name, include_content },
|
||
{ file_path, kind },
|
||
);
|
||
|
||
if (outcome.kind === 'not_found') {
|
||
return { error: `Symbol '${name || uid}' not found` };
|
||
}
|
||
|
||
if (outcome.kind === 'ambiguous') {
|
||
const { atLeast, showing, fields } = ambiguityReport(outcome, outcome.candidates.length);
|
||
return {
|
||
status: 'ambiguous',
|
||
message: `Found ${atLeast}${outcome.total} symbols matching '${name}'${showing}. Use uid, file_path, or kind to disambiguate.`,
|
||
...fields,
|
||
candidates: outcome.candidates.map((c) => ({
|
||
uid: c.id,
|
||
name: c.name,
|
||
kind: c.type,
|
||
filePath: c.filePath,
|
||
line: toDisplayLine(c.startLine),
|
||
score: Number(c.score.toFixed(2)),
|
||
})),
|
||
};
|
||
}
|
||
|
||
// Step 3: Build full context
|
||
const sym = outcome.symbol;
|
||
const resolvedLabel = outcome.resolvedLabel;
|
||
const symId = sym.id;
|
||
|
||
// Categorized incoming refs.
|
||
//
|
||
// ORDER BY uid, relType — the CATEGORY column must NOT lead (#2787 review
|
||
// F1). With `relType` first the 30-row window fills in alphabetical
|
||
// category order, so any category whose alphabetical predecessors already
|
||
// total 30 is dropped in 100% of runs: a symbol with {ACCESSES:3, CALLS:35,
|
||
// USES:1, HAS_METHOD:1} came back as {ACCESSES:3, CALLS:27} — HAS_METHOD
|
||
// (which names the owning class) and USES simply gone, and categorize()
|
||
// below emits whatever buckets it is handed with no truncation flag, so the
|
||
// loss is silent. Leading with `uid` (a node primary key) is still a total
|
||
// order and still deterministic, but it spreads the window across
|
||
// categories the way the unordered scan incidentally did.
|
||
const [incomingRows, incomingAdvisedRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||
ORDER BY uid, relType
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
// Keep high-fan-in advice edges out of the legacy 30-row context window.
|
||
// A broad pointcut can advise hundreds of methods; sharing that LIMIT
|
||
// would make CALLS/HAS_METHOD/etc. disappear nondeterministically.
|
||
// Splitting the window bounded that; ORDER BY finishes the job (#2787) —
|
||
// an unordered LIMIT still let the surviving 30 change per process, and
|
||
// these rows reach the response in query order via categorize().
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (caller)-[r:CodeRelation {type: 'ADVISED_BY'}]->(n {id: $symId})
|
||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||
ORDER BY uid
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
]);
|
||
incomingRows.push(...incomingAdvisedRows);
|
||
let typedPropertyRows: any[] = [];
|
||
|
||
// Fix #480: Class/Interface nodes have no direct CALLS/IMPORTS edges —
|
||
// those point to Constructor and File nodes respectively. Fetch those
|
||
// extra incoming refs and merge them in so context() shows real callers.
|
||
//
|
||
// Determine if this is a Class/Interface node. If resolvedLabel was set
|
||
// during disambiguation (Step 2), use it directly — no extra round-trip.
|
||
// Otherwise fall back to a single label check only when the type field is
|
||
// empty (LadybugDB labels(n)[0] limitation).
|
||
const symRawType = sym.type || sym[2] || '';
|
||
let isClassLike = resolvedLabel === 'Class' || resolvedLabel === 'Interface';
|
||
if (!isClassLike && symRawType === '') {
|
||
try {
|
||
// Single UNION query instead of two serial round-trips.
|
||
// determinism: probe — existence only, and each UNION branch is PK-anchored on $symId. Only typeCheck.length
|
||
// > 0 is read; the projected label is discarded.
|
||
const typeCheck = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n:Class) WHERE n.id = $symId RETURN 'Class' AS label LIMIT 1
|
||
UNION ALL
|
||
MATCH (n:Interface) WHERE n.id = $symId RETURN 'Interface' AS label LIMIT 1
|
||
`,
|
||
{ symId },
|
||
);
|
||
isClassLike = typeCheck.length > 0;
|
||
} catch {
|
||
/* not a Class/Interface node */
|
||
}
|
||
} else if (!isClassLike) {
|
||
isClassLike = symRawType === 'Class' || symRawType === 'Interface';
|
||
}
|
||
|
||
if (isClassLike) {
|
||
try {
|
||
// Run incoming-ref queries in parallel — they are independent.
|
||
const [ctorIncoming, fileIncoming, typedPropertyIncoming, typedProperties] =
|
||
await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[hm:CodeRelation]->(ctor:Constructor)
|
||
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
||
MATCH (caller)-[r:CodeRelation]->(ctor)
|
||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES']
|
||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||
ORDER BY uid, relType
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (f:File)-[rel:CodeRelation]->(n)
|
||
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
||
MATCH (caller)-[r:CodeRelation]->(f)
|
||
WHERE r.type IN ['CALLS', 'IMPORTS']
|
||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||
ORDER BY uid, relType
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:\`Property\`)
|
||
WHERE p.declaredType = $name
|
||
OR p.declaredType STARTS WITH $genericPrefix
|
||
OR p.declaredType CONTAINS $genericArg
|
||
MATCH (caller)-[r:CodeRelation]->(p)
|
||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'ACCESSES']
|
||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||
ORDER BY uid, relType
|
||
LIMIT 30
|
||
`,
|
||
{
|
||
name: sym.name,
|
||
genericPrefix: `${sym.name}<`,
|
||
genericArg: `<${sym.name}>`,
|
||
},
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:\`Property\`)
|
||
WHERE p.declaredType = $name
|
||
OR p.declaredType STARTS WITH $genericPrefix
|
||
OR p.declaredType CONTAINS $genericArg
|
||
RETURN p.id AS uid, p.name AS name, p.filePath AS filePath, labels(p)[0] AS kind,
|
||
p.declaredType AS declaredType
|
||
ORDER BY uid
|
||
LIMIT 30
|
||
`,
|
||
{
|
||
name: sym.name,
|
||
genericPrefix: `${sym.name}<`,
|
||
genericArg: `<${sym.name}>`,
|
||
},
|
||
),
|
||
]);
|
||
typedPropertyRows = typedProperties;
|
||
|
||
// Deduplicate by (relType, uid) — a caller can have multiple relation
|
||
// types to the same target (e.g. both IMPORTS and CALLS), and each
|
||
// must be preserved so every category appears in the output.
|
||
const seenKeys = new Set(
|
||
incomingRows.map((r: any) => `${r.relType || r[0]}:${r.uid || r[1]}`),
|
||
);
|
||
for (const r of [...ctorIncoming, ...fileIncoming, ...typedPropertyIncoming]) {
|
||
const key = `${r.relType || r[0]}:${r.uid || r[1]}`;
|
||
if (!seenKeys.has(key)) {
|
||
seenKeys.add(key);
|
||
incomingRows.push(r);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logQueryError('context:class-incoming-expansion', e);
|
||
}
|
||
}
|
||
|
||
// Categorized outgoing refs. uid-major for the same reason as the incoming
|
||
// window above — a category-major key starves whole buckets (#2787 F1).
|
||
const [outgoingRows, outgoingAdvisedRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'USES', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||
ORDER BY uid, relType
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n {id: $symId})-[r:CodeRelation {type: 'ADVISED_BY'}]->(target)
|
||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||
ORDER BY uid
|
||
LIMIT 30
|
||
`,
|
||
{ symId },
|
||
),
|
||
]);
|
||
outgoingRows.push(...outgoingAdvisedRows);
|
||
|
||
// Process participation.
|
||
//
|
||
// MIN(r.step) enforces one row per process (#2787). It is behaviour
|
||
// preserving on today's data — a full scan of this repo's index puts the
|
||
// maximum STEP_IN_PROCESS edge count for any (symbol, process) pair at 1 —
|
||
// but nothing in the schema caps it there, and `processes` is the only
|
||
// uncapped number in this response, so a symbol that ever picks up a second
|
||
// step edge would silently report an edge count instead of a process count.
|
||
// Aggregating makes the one-row-per-process invariant explicit rather than
|
||
// inherited from the data, and matches _runImpactBFS's twin query.
|
||
let processRows: any[] = [];
|
||
try {
|
||
processRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
RETURN p.id AS pid, p.heuristicLabel AS label, MIN(r.step) AS step, p.stepCount AS stepCount
|
||
ORDER BY pid
|
||
`,
|
||
{ symId },
|
||
);
|
||
} catch (e) {
|
||
logQueryError('context:process-participation', e);
|
||
}
|
||
|
||
// Helper to categorize refs
|
||
const categorize = (rows: any[]) => {
|
||
const cats: Record<string, any[]> = {};
|
||
for (const row of rows) {
|
||
const relType = (row.relType || row[0] || '').toLowerCase();
|
||
const entry = {
|
||
uid: row.uid || row[1],
|
||
name: row.name || row[2],
|
||
filePath: row.filePath || row[3],
|
||
kind: row.kind || row[4],
|
||
};
|
||
if (!cats[relType]) cats[relType] = [];
|
||
cats[relType].push(entry);
|
||
}
|
||
return cats;
|
||
};
|
||
|
||
// Method/Function/Constructor enrichment: fetch method-specific properties
|
||
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
|
||
const isMethodLike =
|
||
symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
|
||
|
||
// #1858 review F2 — start the epistemic boundary probe here (right after
|
||
// `symKind` is known) so it runs CONCURRENTLY with the methodMetadata fetch
|
||
// below, mirroring how _runImpactBFS overlaps it with the BFS. It is awaited
|
||
// at result assembly. (It cannot start earlier — `symKind` is only computed
|
||
// on this line, after the incoming/outgoing round-trips.)
|
||
//
|
||
// #1858 review F3 — pass an interface-preserving type, NOT `symKind`.
|
||
// `symKind` collapses a single-resolved Interface to 'Class' (resolvedLabel
|
||
// is '' on the single-candidate path), which would skip computeEpistemicBoundary's
|
||
// `symType === 'Interface'` self-boundary branch and under-report a leaf
|
||
// interface as 'exact'. `enrichCandidateLabels` runs BEFORE the single-candidate
|
||
// early return and patches `sym.type` from '' to 'Interface' (LadybugDB returns
|
||
// '' for labels()[0] on Interface/Class), so `sym.type` is the reliable signal
|
||
// here — mirroring impact()'s `resolvedLabel || symbol.type` derivation. Do not
|
||
// "fix" enrichment ordering; F3 depends on enrichment-before-early-return.
|
||
const epistemicSymType = (resolvedLabel || sym.type || symKind || '') as string;
|
||
const epistemicPromise = this.computeEpistemicBoundary(
|
||
repo,
|
||
symId,
|
||
epistemicSymType,
|
||
(sym.name || sym[1]) as string,
|
||
);
|
||
const beanMetadataPromise = queryClassBeanMetadata(repo.lbugPath, symId, epistemicSymType);
|
||
const aopMetadataPromise = querySpringAopMetadata(repo.lbugPath, symId, epistemicSymType);
|
||
|
||
let methodMetadata: Record<string, unknown> | undefined;
|
||
if (isMethodLike) {
|
||
try {
|
||
// determinism: probe — PK-anchored singleton. $symId is a node primary key, so at most one row of method
|
||
// metadata can match.
|
||
const metaRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n {id: $symId})
|
||
RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract,
|
||
n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride,
|
||
n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType,
|
||
n.parameterCount AS parameterCount, n.isVariadic AS isVariadic,
|
||
n.requiredParameterCount AS requiredParameterCount,
|
||
n.parameterTypes AS parameterTypes, n.annotations AS annotations
|
||
LIMIT 1
|
||
`,
|
||
{ symId },
|
||
);
|
||
if (metaRows.length > 0) {
|
||
const row = metaRows[0];
|
||
const meta: Record<string, unknown> = {};
|
||
// Only include defined properties to distinguish "not applicable" from "not enriched"
|
||
for (const key of Object.keys(row)) {
|
||
const val = row[key];
|
||
if (val !== null && val !== undefined) meta[key] = val;
|
||
}
|
||
if (Object.keys(meta).length > 0) methodMetadata = meta;
|
||
}
|
||
} catch {
|
||
/* method metadata unavailable — omit silently */
|
||
}
|
||
}
|
||
|
||
// #1858 — same epistemic boundary signal as impact(): when this symbol sits
|
||
// behind an interface / indirection boundary, callers binding via DI or
|
||
// dynamic dispatch are not reflected in `incoming`, so the view is a lower
|
||
// bound. Additive; never suppresses a field. Resolved from the probe started
|
||
// above (concurrent with methodMetadata).
|
||
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
||
epistemicPromise,
|
||
beanMetadataPromise,
|
||
aopMetadataPromise,
|
||
]);
|
||
|
||
return {
|
||
status: 'found',
|
||
symbol: {
|
||
uid: sym.id || sym[0],
|
||
name: sym.name || sym[1],
|
||
kind: symKind,
|
||
filePath: sym.filePath || sym[3],
|
||
startLine: toDisplayLine(sym.startLine ?? sym[4]),
|
||
endLine: toDisplayLine(sym.endLine ?? sym[5]),
|
||
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||
...(methodMetadata ? { methodMetadata } : {}),
|
||
...(beanMetadata ? { bean: beanMetadata } : {}),
|
||
...(aopMetadata ? { aop: aopMetadata } : {}),
|
||
},
|
||
...epistemic,
|
||
incoming: categorize(incomingRows),
|
||
outgoing: categorize(outgoingRows),
|
||
...(typedPropertyRows.length > 0
|
||
? {
|
||
typed_properties: typedPropertyRows.map((r: any) => ({
|
||
uid: r.uid || r[0],
|
||
name: r.name || r[1],
|
||
filePath: r.filePath || r[2],
|
||
kind: r.kind || r[3],
|
||
declaredType: r.declaredType || r[4],
|
||
})),
|
||
}
|
||
: {}),
|
||
processes: processRows.map((r: any) => ({
|
||
id: r.pid || r[0],
|
||
name: r.label || r[1],
|
||
step_index: r.step || r[2],
|
||
step_count: r.stepCount || r[3],
|
||
})),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve a `target` (file path OR symbol/function name) into a BasicBlock
|
||
* SOURCE-block anchor, shared by `explain` (TAINTED) and `pdg_query`
|
||
* (CDG/REACHING_DEF) — both reconstruct the symbol↔block join the same way
|
||
* (there is no Function→BasicBlock edge). #2188 review: extracted from two
|
||
* near-identical copies that had DRIFTED — `_explainImpl` used a 0-based,
|
||
* un-widened span window that dropped a function's final-line block and could
|
||
* leak a neighbor's line-above block; this single resolver applies the correct
|
||
* `[symStart+1, symEnd+1]` window (1-based BasicBlock startLine vs 0-based
|
||
* symbol span) to BOTH callers.
|
||
*
|
||
* Returns a BARE `anchorClause` (no leading `AND`) so each caller composes its
|
||
* own `WHERE`; `early` carries the not-found/ambiguous payload (caller returns
|
||
* it verbatim). `target` / symbol names flow only through `queryParams` bind
|
||
* params — never interpolated into Cypher.
|
||
*/
|
||
private async resolveBlockAnchor(
|
||
repo: RepoHandle,
|
||
target: string,
|
||
toolName: 'explain' | 'pdg_query' | 'impact',
|
||
): Promise<{
|
||
anchorClause: string;
|
||
queryParams: Record<string, unknown>;
|
||
anchor: { file: string; symbol?: string; startLine?: number; endLine?: number };
|
||
early?: Record<string, unknown>;
|
||
}> {
|
||
if (looksLikeFilePath(target)) {
|
||
return {
|
||
anchorClause:
|
||
'(a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)',
|
||
queryParams: {
|
||
idPrefix: `BasicBlock:${target}:`,
|
||
targetPath: target,
|
||
targetSuffix: `/${target}`,
|
||
},
|
||
anchor: { file: target },
|
||
};
|
||
}
|
||
const outcome = await this.resolveSymbolCandidates(repo, { name: target }, {});
|
||
if (outcome.kind === 'not_found') {
|
||
return {
|
||
anchorClause: '',
|
||
queryParams: {},
|
||
anchor: { file: '' },
|
||
early: { error: `Symbol '${target}' not found` },
|
||
};
|
||
}
|
||
if (outcome.kind === 'ambiguous') {
|
||
const { atLeast, showing, fields } = ambiguityReport(outcome, outcome.candidates.length);
|
||
return {
|
||
anchorClause: '',
|
||
queryParams: {},
|
||
anchor: { file: '' },
|
||
early: {
|
||
status: 'ambiguous',
|
||
message: `Found ${atLeast}${outcome.total} symbols matching '${target}'${showing}. Re-call ${toolName} with the file path, or disambiguate via context() first.`,
|
||
...fields,
|
||
candidates: outcome.candidates.map((c) => ({
|
||
uid: c.id,
|
||
name: c.name,
|
||
kind: c.type,
|
||
filePath: c.filePath,
|
||
line: toDisplayLine(c.startLine),
|
||
score: Number(c.score.toFixed(2)),
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
const sym = outcome.symbol;
|
||
const idPrefix = `BasicBlock:${sym.filePath}:`;
|
||
if (
|
||
typeof sym.startLine === 'number' &&
|
||
typeof sym.endLine === 'number' &&
|
||
sym.endLine >= sym.startLine
|
||
) {
|
||
// BasicBlock startLine is 1-based; the symbol span is 0-based. Shift BOTH
|
||
// bounds +1 so the window is the function's true block span: the lower +1
|
||
// excludes a neighbor's block on the line directly above, the upper +1
|
||
// keeps a guard/def/use on the final line (#2188 review).
|
||
return {
|
||
anchorClause:
|
||
'a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd',
|
||
queryParams: { idPrefix, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 },
|
||
// Display anchor is 1-based, matching the ambiguous-candidate branch and
|
||
// the context/query/impact tools (#2380). This is display-only — the
|
||
// BasicBlock join above uses the raw `sym.startLine + 1` in `symStart`.
|
||
anchor: {
|
||
file: sym.filePath,
|
||
symbol: sym.name,
|
||
startLine: toDisplayLine(sym.startLine),
|
||
endLine: toDisplayLine(sym.endLine),
|
||
},
|
||
};
|
||
}
|
||
// No usable span — degrade to the file-level filter (documented).
|
||
return {
|
||
anchorClause: 'a.id STARTS WITH $idPrefix',
|
||
queryParams: { idPrefix },
|
||
anchor: { file: sym.filePath, symbol: sym.name },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
|
||
* WAL-aware wrapper mirroring `context`.
|
||
*/
|
||
private async explain(
|
||
repo: RepoHandle,
|
||
params: { target?: string; limit?: number },
|
||
): Promise<any> {
|
||
try {
|
||
return await this._explainImpl(repo, params);
|
||
} catch (err: any) {
|
||
const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
|
||
if (isWalCorruptionError(err)) {
|
||
return {
|
||
error: msg,
|
||
recoverySuggestion: WAL_RECOVERY_SUGGESTION,
|
||
};
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Taint findings are persisted as `TAINTED` rows in CodeRelation whose
|
||
* endpoints are BOTH BasicBlock nodes — the label anchor restricts every
|
||
* query here to the BasicBlock→BasicBlock partition of the rel table
|
||
* (which holds only the sparse, per-function-capped pdg layers), never a
|
||
* global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
|
||
* index, so the label anchor IS the bound).
|
||
*
|
||
* Anchoring granularity:
|
||
* - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
|
||
* shared `basicBlockId` template) with an exact-or-suffix path match so
|
||
* `vuln.ts` finds `src/vuln.ts`.
|
||
* - symbol target → resolved via `resolveSymbolCandidates` (the context()
|
||
* path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
|
||
* file id-prefix PLUS source-block startLine within the symbol's
|
||
* [startLine, endLine] span. Findings are intra-procedural, so filtering
|
||
* the SOURCE endpoint is sufficient — both endpoints share the function.
|
||
* Symbols without a line span degrade to the file-level filter.
|
||
*
|
||
* The per-finding `sinkKind` and hop path decode from the persisted
|
||
* `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
|
||
* with the same module — `;<kind>` header + ordered `variable:line` hops).
|
||
*/
|
||
private async _explainImpl(
|
||
repo: RepoHandle,
|
||
params: { target?: string; limit?: number },
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
|
||
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
|
||
return {
|
||
error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
|
||
};
|
||
}
|
||
const limit = rawLimit;
|
||
|
||
const NO_TAINT_NOTE =
|
||
'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
|
||
|
||
// Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
|
||
// `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
|
||
// `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
|
||
// exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
|
||
// not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
|
||
// a seeded test DB) falls through to the row-existence probe below.
|
||
let pdgStamped: boolean | undefined;
|
||
try {
|
||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||
if (meta) pdgStamped = meta.pdg?.taintModelVersion !== undefined;
|
||
} catch {
|
||
/* meta unreadable — decide from the DB below */
|
||
}
|
||
if (pdgStamped === false) {
|
||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||
}
|
||
|
||
// Resolve the optional anchor into a WHERE clause on the SOURCE block.
|
||
const target = typeof params.target === 'string' ? params.target.trim() : '';
|
||
let anchorClause = '';
|
||
let queryParams: Record<string, unknown> = {};
|
||
let anchor: { file: string; symbol?: string; startLine?: number; endLine?: number } | undefined;
|
||
|
||
// Bounded by construction: the BasicBlock→BasicBlock partition holds only
|
||
// the sparse pdg layers, TAINTED rows are per-function-capped at analyze
|
||
// time, and the page is LIMIT-bounded (the limit is a validated integer —
|
||
// interpolated because LadybugDB does not parameterize LIMIT).
|
||
const runAnchoredQuery = async (): Promise<{ rows: unknown[]; totalFindings: number }> => {
|
||
const matchClause = `
|
||
MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
|
||
WHERE r.type = 'TAINTED'${anchorClause ? ` AND ${anchorClause}` : ''}`;
|
||
const [qRows, countRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`${matchClause}
|
||
RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
|
||
b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
|
||
ORDER BY sourceBlockId, sinkBlockId, reason
|
||
LIMIT ${limit}`,
|
||
queryParams,
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`${matchClause}
|
||
RETURN COUNT(*) AS total`,
|
||
queryParams,
|
||
),
|
||
]);
|
||
return {
|
||
rows: qRows,
|
||
totalFindings: Number((countRows[0] as any)?.total ?? (countRows[0] as any)?.[0] ?? 0),
|
||
};
|
||
};
|
||
|
||
if (target) {
|
||
// Shared symbol↔block anchor resolver (#2188): file id-prefix OR symbol
|
||
// span, with the corrected [symStart+1, symEnd+1] window. A bare/dotted
|
||
// symbol name resolves as a symbol rather than silently file-anchoring.
|
||
const resolved = await this.resolveBlockAnchor(repo, target, 'explain');
|
||
if (resolved.early) return resolved.early;
|
||
anchorClause = resolved.anchorClause;
|
||
queryParams = resolved.queryParams;
|
||
anchor = resolved.anchor;
|
||
}
|
||
|
||
const { rows, totalFindings } = await runAnchoredQuery();
|
||
|
||
// M4 (#2084 U7): cross-function findings ride TAINT_PATH edges (Function/
|
||
// Method → Function/Method), separate from the intra-procedural TAINTED
|
||
// BasicBlock rows above. Enumerate them too so `explain` is the discovery
|
||
// surface for interprocedural flows (TAINT_PATH stays out of
|
||
// VALID_RELATION_TYPES + the web schema, like TAINTED). File-anchored:
|
||
// filter on the source function's file; symbol-anchored: either endpoint
|
||
// matches the symbol name; anchorless: all (bounded by LIMIT). Computed
|
||
// BEFORE the no-taint early returns — a repo with ONLY cross-function
|
||
// findings (no intra-procedural TAINTED rows) must not look empty.
|
||
const runInterprocQuery = async (): Promise<{ findings: any[]; total: number }> => {
|
||
const where: string[] = [`r.type = 'TAINT_PATH'`];
|
||
const p: Record<string, unknown> = {};
|
||
if (anchor?.symbol) {
|
||
where.push('(a.name = $ipSym OR b.name = $ipSym)');
|
||
p.ipSym = anchor.symbol;
|
||
} else if (anchor?.file) {
|
||
// Match EITHER endpoint's file — a cross-function flow anchored on the
|
||
// SINK's file (b) is as relevant as one anchored on the source's (a).
|
||
where.push(
|
||
'(a.filePath = $ipFile OR a.filePath ENDS WITH $ipSuffix OR ' +
|
||
'b.filePath = $ipFile OR b.filePath ENDS WITH $ipSuffix)',
|
||
);
|
||
p.ipFile = anchor.file;
|
||
p.ipSuffix = `/${anchor.file}`;
|
||
}
|
||
const matchClause = `MATCH (a)-[r:CodeRelation]->(b)\n WHERE ${where.join(' AND ')}`;
|
||
// Page query + a separate COUNT (#2084 review P2-4): the page is
|
||
// LIMIT-capped, so its row count cannot stand in for the true total —
|
||
// run a COUNT with the same WHERE (no LIMIT) like the intra layer does.
|
||
const [ipRows, ipCountRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`${matchClause}
|
||
RETURN a.filePath AS file, a.name AS sourceFn, a.startLine AS sourceLine,
|
||
b.name AS sinkFn, b.startLine AS sinkLine, r.reason AS reason
|
||
ORDER BY sourceFn, sinkFn, reason, a.id, b.id
|
||
LIMIT ${limit}`,
|
||
p,
|
||
),
|
||
executeParameterized(repo.lbugPath, `${matchClause}\n RETURN COUNT(*) AS total`, p),
|
||
]);
|
||
const total = Number((ipCountRows[0] as any)?.total ?? (ipCountRows[0] as any)?.[0] ?? 0);
|
||
const findings = ipRows.map((r: any) => {
|
||
const decoded = decodeTaintPath(r.reason ?? r[5]);
|
||
const hops = decoded.ok
|
||
? decoded.hops.map((h) => ({ function: h.variable, line: h.line }))
|
||
: [];
|
||
return {
|
||
interprocedural: true,
|
||
file: String(r.file ?? r[0] ?? ''),
|
||
sinkKind: decoded.ok ? (decoded.kind ?? 'unknown') : 'unknown',
|
||
source: { function: String(r.sourceFn ?? r[1] ?? ''), line: r.sourceLine ?? r[2] },
|
||
sink: { function: String(r.sinkFn ?? r[3] ?? ''), line: r.sinkLine ?? r[4] },
|
||
hops,
|
||
...(decoded.ok && decoded.truncated ? { pathIncomplete: true } : {}),
|
||
};
|
||
});
|
||
return { findings, total };
|
||
};
|
||
const { findings: interprocFindings, total: interprocTotal } = await runInterprocQuery();
|
||
|
||
if (
|
||
totalFindings === 0 &&
|
||
interprocFindings.length === 0 &&
|
||
pdgStamped === undefined &&
|
||
!target
|
||
) {
|
||
// Meta was unreadable and the repo-wide enumerate (both layers) found
|
||
// nothing — the counts above WERE the existence probe; surface the hint.
|
||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||
}
|
||
if (
|
||
totalFindings === 0 &&
|
||
interprocFindings.length === 0 &&
|
||
pdgStamped === undefined &&
|
||
target
|
||
) {
|
||
// Anchored miss with unreadable meta: one extra bounded probe decides
|
||
// "no findings for this anchor" vs "no taint layer at all". Probe BOTH
|
||
// intra (TAINTED) and inter (TAINT_PATH) existence.
|
||
// determinism: probe — layer existence. Only probe.length is read; r.reason never leaves this block.
|
||
const probe = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`,
|
||
{},
|
||
);
|
||
// determinism: probe — layer existence. Only ipProbe.length is read; r.reason is discarded.
|
||
const ipProbe =
|
||
probe.length === 0
|
||
? await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (a)-[r:CodeRelation]->(b) WHERE r.type = 'TAINT_PATH' RETURN r.reason AS reason LIMIT 1`,
|
||
{},
|
||
)
|
||
: [];
|
||
if (probe.length === 0 && ipProbe.length === 0) {
|
||
return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
|
||
}
|
||
}
|
||
|
||
const findings = rows.map((r: any) => {
|
||
const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
|
||
const file = String(r.file ?? r[1] ?? '');
|
||
const sourceStart = (r.sourceStart ?? r[2]) as number | undefined;
|
||
const sinkStart = (r.sinkStart ?? r[3]) as number | undefined;
|
||
const reason = r.reason ?? r[4];
|
||
// basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
|
||
// split from the RIGHT (the filePath may itself contain ':').
|
||
const idParts = sourceBlockId.split(':');
|
||
const fnLine = Number(idParts[idParts.length - 3]);
|
||
const decoded = decodeTaintPath(reason);
|
||
if (!decoded.ok) {
|
||
// Unreadable reason (foreign/corrupt row): surface the finding's
|
||
// existence with its block anchors, never throw.
|
||
return {
|
||
file,
|
||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||
sinkKind: 'unknown',
|
||
source: { line: sourceStart },
|
||
sink: { line: sinkStart },
|
||
hops: [],
|
||
pathIncomplete: true,
|
||
};
|
||
}
|
||
const hops = decoded.hops.map((h) => ({
|
||
variable: h.variable,
|
||
line: h.line,
|
||
...(h.viaCall ? { viaCall: true } : {}),
|
||
}));
|
||
const first = hops[0];
|
||
const last = hops[hops.length - 1];
|
||
return {
|
||
file,
|
||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||
sinkKind: decoded.kind ?? 'unknown',
|
||
source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
|
||
sink: { line: last?.line ?? sinkStart },
|
||
hops,
|
||
...(decoded.truncated ? { pathIncomplete: true } : {}),
|
||
};
|
||
});
|
||
|
||
// Combine both layers and re-apply the page LIMIT to the union — each
|
||
// layer was queried with its own LIMIT, so the union can hold up to 2×;
|
||
// cap it so `findings.length` honours the caller's `limit`. `truncated`
|
||
// reflects EITHER layer overflowing OR the union being trimmed here, and
|
||
// `totalFindings` counts both layers' matched rows (the intra COUNT plus
|
||
// the interproc rows returned — interproc has no separate COUNT, so a
|
||
// capped interproc layer is reflected via `truncated`, never undercounted
|
||
// into a false "complete" signal). Review: code-review #2/#4 (explain
|
||
// accounting + sink-file anchoring) — both layers now accounted.
|
||
const combined = [...findings, ...interprocFindings];
|
||
const pageFindings = combined.length > limit ? combined.slice(0, limit) : combined;
|
||
// Truncated iff EITHER layer overflowed its own LIMIT (strict `>` — exactly
|
||
// `limit` rows is not truncated), OR the combined union was trimmed to the
|
||
// page (#2084 review P2-4). `totalFindings` uses the interproc COUNT, not
|
||
// the capped slice length, so it never undercounts.
|
||
const truncated =
|
||
totalFindings > findings.length ||
|
||
interprocTotal > interprocFindings.length ||
|
||
combined.length > pageFindings.length;
|
||
|
||
return {
|
||
...(anchor ? { anchor } : {}),
|
||
findings: pageFindings,
|
||
totalFindings: totalFindings + interprocTotal,
|
||
...(truncated ? { truncated: true } : {}),
|
||
note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled. Closure/callback, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
|
||
};
|
||
}
|
||
|
||
private async pdgQuery(
|
||
repo: RepoHandle,
|
||
params: { mode?: string; target?: string; variable?: string; limit?: number },
|
||
): Promise<any> {
|
||
try {
|
||
return await this._pdgQueryImpl(repo, params);
|
||
} catch (err: any) {
|
||
const msg = (err instanceof Error ? err.message : String(err)) || 'pdg_query failed';
|
||
if (isWalCorruptionError(err)) {
|
||
return { error: msg, recoverySuggestion: WAL_RECOVERY_SUGGESTION };
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Query the persisted PDG (#2086 M6) — the control/data-dependence analog of
|
||
* `explain`. `controls` reads CDG ("under what condition does X run?", branch
|
||
* sense 'T'|'F' in `reason`); `flows` reads REACHING_DEF (def→use, variable
|
||
* name in `reason`). Intra-procedural, basic-block granular.
|
||
*
|
||
* Bounded by construction: the BasicBlock→BasicBlock partition holds only the
|
||
* sparse, per-function-capped pdg layers, the query is anchored to one file/
|
||
* symbol, and the page is LIMIT-bounded (validated integer, interpolated
|
||
* because LadybugDB does not parameterize LIMIT). LadybugDB has no rel-
|
||
* property index, so the anchor IS the bound — there is no anchorless mode.
|
||
*
|
||
* Symbol↔block join: there is no Function→BasicBlock edge; the SOURCE block
|
||
* (`a` — controller for CDG, def for REACHING_DEF) is filtered by the
|
||
* BasicBlock id-prefix (`basicBlockId` template) plus its `startLine` within
|
||
* the symbol's span. BasicBlock `startLine` is 1-based while symbol-node
|
||
* `startLine`/`endLine` are 0-based, so BOTH bounds are shifted +1
|
||
* (`[symStart+1, symEnd+1]`) onto the block basis: the upper +1 keeps a
|
||
* guard/def/use on the function's final line, and the lower +1 excludes an
|
||
* adjacent function's block on the line directly above (#2188 review). Both
|
||
* endpoints share the function (intra-procedural), so filtering the source
|
||
* endpoint suffices.
|
||
*/
|
||
private async _pdgQueryImpl(
|
||
repo: RepoHandle,
|
||
params: { mode?: string; target?: string; variable?: string; limit?: number } = {},
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
// Mode validation — the JSON-schema enum is advisory for MCP clients, so
|
||
// the backend enforces it (an unhandled mode would otherwise fall through).
|
||
const mode = params.mode;
|
||
if (mode !== 'controls' && mode !== 'flows') {
|
||
return {
|
||
error: `Invalid "mode": expected "controls" or "flows", got ${JSON.stringify(params.mode)}.`,
|
||
};
|
||
}
|
||
|
||
const rawLimit = params.limit ?? PDG_QUERY_DEFAULT_LIMIT;
|
||
if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > PDG_QUERY_MAX_LIMIT) {
|
||
return {
|
||
error: `Invalid "limit": expected an integer in [1, ${PDG_QUERY_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
|
||
};
|
||
}
|
||
const limit = rawLimit;
|
||
|
||
// PDG queries are always anchored (no rel-property index ⇒ an unanchored
|
||
// basic-block path scan is unbounded). `target` is required.
|
||
const target = typeof params.target === 'string' ? params.target.trim() : '';
|
||
if (!target) {
|
||
return {
|
||
error:
|
||
'pdg_query requires a "target" (a file path or symbol/function name) — PDG queries are always anchored.',
|
||
};
|
||
}
|
||
|
||
const edgeType = mode === 'controls' ? 'CDG' : 'REACHING_DEF';
|
||
// Definitive: the meta stamp says this layer was never recorded.
|
||
const NO_PDG_NOTE = `no PDG layer — run gitnexus analyze --pdg to record ${edgeType} edges for this repo`;
|
||
// Inconclusive: meta is unreadable AND a global probe found zero rows of this
|
||
// edge type — but a genuinely edge-free layer (all-linear functions) looks
|
||
// identical to a missing one, so don't assert absence (#2188 review).
|
||
const PDG_LAYER_UNKNOWN_NOTE = `no ${edgeType} edges found for this target; PDG layer status unknown — was this repo indexed with gitnexus analyze --pdg?`;
|
||
|
||
// Cheap meta probe: the layer exists iff the pdg stamp carries the
|
||
// mode-relevant cap (maxCdgEdgesPerFunction for CDG, maxReachingDef…
|
||
// for REACHING_DEF). Absent ⇒ the no-layer hint without a DB scan.
|
||
// `pdgStampForMode` is the shared meta read (the both-caps `pdgLayerStatus`
|
||
// helper consumes the same underlying read for impact); here we project it
|
||
// down to this one mode's cap, preserving the tri-state `boolean | undefined`
|
||
// contract byte-for-byte: `false` ⇒ definitive no-layer (short-circuit
|
||
// below), `true` ⇒ proceed, `undefined` ⇒ meta unreadable, defer to the
|
||
// post-anchored-query probe (Feasibility Issue 4).
|
||
const pdgStamped = await pdgStampForMode(repo.lbugPath, mode);
|
||
if (pdgStamped === false) {
|
||
return { mode, results: [], total: 0, note: NO_PDG_NOTE };
|
||
}
|
||
|
||
// Resolve the anchor on the SOURCE block via the shared resolver also used
|
||
// by explain (#2188): file id-prefix OR symbol span on the corrected
|
||
// [symStart+1, symEnd+1] window. `target` is required, so the early cases
|
||
// (not-found/ambiguous) return here and `anchor`/`anchorClause` are always
|
||
// set below (anchor stays non-optional — no `| undefined` — #2188 CodeQL).
|
||
const resolved = await this.resolveBlockAnchor(repo, target, 'pdg_query');
|
||
if (resolved.early) return resolved.early;
|
||
const { anchorClause, anchor } = resolved;
|
||
const queryParams = resolved.queryParams;
|
||
|
||
// Optional variable filter (flows mode) — REACHING_DEF stores the variable
|
||
// name in `reason`. FU-B-2 prefixes the name with a `<name>|1:<def>:<use>`
|
||
// annotation (name FIRST), so match BOTH a legacy bare-name reason (`=`) AND
|
||
// an annotated one (`STARTS WITH <name>|`). Source identifiers never contain
|
||
// `|`, so the `<name>|` prefix is exact (it cannot collide with a longer name
|
||
// — `ab|…` is not a prefix of `abc|…`).
|
||
let reasonClause = '';
|
||
if (mode === 'flows' && typeof params.variable === 'string' && params.variable.trim()) {
|
||
reasonClause = ' AND (r.reason = $variable OR r.reason STARTS WITH $variablePrefix)';
|
||
const variable = params.variable.trim();
|
||
queryParams.variable = variable;
|
||
queryParams.variablePrefix = `${variable}|`;
|
||
}
|
||
|
||
// edgeType is a hardcoded per-mode literal (never user input); `target` /
|
||
// `variable` flow only through bind params (no Cypher interpolation).
|
||
const matchClause = `
|
||
MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
|
||
WHERE r.type = '${edgeType}' AND ${anchorClause}${reasonClause}`;
|
||
const [rows, countRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`${matchClause}
|
||
RETURN a.id AS srcId, a.startLine AS srcLine, b.startLine AS dstLine, b.text AS dstText, r.reason AS reason
|
||
ORDER BY srcId, dstLine, reason, b.id
|
||
LIMIT ${limit}`,
|
||
queryParams,
|
||
),
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`${matchClause}\n RETURN COUNT(*) AS total`,
|
||
queryParams,
|
||
),
|
||
]);
|
||
const total = Number((countRows[0] as any)?.total ?? (countRows[0] as any)?.[0] ?? 0);
|
||
|
||
// Unreadable meta + anchored miss: one bounded probe distinguishes "no rows
|
||
// for this anchor" from "no rows of this edge type at all". With meta
|
||
// unreadable we cannot tell a missing layer from an edge-free one, so the
|
||
// note is the inconclusive "status unknown" form, not the definitive
|
||
// NO_PDG_NOTE (which is reserved for the meta-stamped absence above).
|
||
if (total === 0 && pdgStamped === undefined) {
|
||
// determinism: probe — layer existence for one edge type. Only probe.length === 0 is read; r.reason is discarded.
|
||
const probe = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type = '${edgeType}' RETURN r.reason AS reason LIMIT 1`,
|
||
{},
|
||
);
|
||
if (probe.length === 0) return { mode, results: [], total: 0, note: PDG_LAYER_UNKNOWN_NOTE };
|
||
}
|
||
|
||
// basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` — split
|
||
// from the RIGHT (filePath may contain ':'). Shared module-scope `fnLineOf`.
|
||
const results =
|
||
mode === 'controls'
|
||
? rows.map((r: any) => {
|
||
const fnLine = fnLineOf(String(r.srcId ?? r[0] ?? ''));
|
||
const dstText = String(r.dstText ?? r[3] ?? '');
|
||
// A CDG edge into an early-exit block is a guard clause (subsumes
|
||
// #559): the controller predicate gates the dependent via `label`.
|
||
const isGuardExit = /^\s*(return|throw|continue|break)\b/.test(dstText);
|
||
return {
|
||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||
controller: { line: (r.srcLine ?? r[1]) as number | undefined },
|
||
dependent: { line: (r.dstLine ?? r[2]) as number | undefined, text: dstText },
|
||
label: String(r.reason ?? r[4] ?? ''),
|
||
...(isGuardExit ? { guard: true } : {}),
|
||
};
|
||
})
|
||
: rows.map((r: any) => {
|
||
const fnLine = fnLineOf(String(r.srcId ?? r[0] ?? ''));
|
||
// FU-B-2: REACHING_DEF `reason` is `<name>` (legacy) or
|
||
// `<name>|1:<defLine>:<useLine>` — decode to surface the bare
|
||
// variable name, not the encoded annotation.
|
||
const variable = decodeReachingDefReason(r.reason ?? r[4] ?? '').name;
|
||
return {
|
||
...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
|
||
variable,
|
||
def: { line: (r.srcLine ?? r[1]) as number | undefined },
|
||
use: {
|
||
line: (r.dstLine ?? r[2]) as number | undefined,
|
||
text: String(r.dstText ?? r[3] ?? ''),
|
||
},
|
||
};
|
||
});
|
||
|
||
return {
|
||
mode,
|
||
anchor,
|
||
results,
|
||
total,
|
||
...(total > results.length ? { truncated: true } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Legacy explore — kept for backwards compatibility with resources.ts.
|
||
* Routes cluster/process types to direct graph queries.
|
||
*/
|
||
private async explore(
|
||
repo: RepoHandle,
|
||
params: { name: string; type: 'symbol' | 'cluster' | 'process' },
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
const { name, type } = params;
|
||
|
||
if (type === 'symbol') {
|
||
return this.context(repo, { name });
|
||
}
|
||
|
||
if (type === 'cluster') {
|
||
const clusters = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (c:Community)
|
||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||
`,
|
||
{ clusterName: name },
|
||
);
|
||
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
|
||
|
||
const rawClusters = clusters.map((c: any) => ({
|
||
id: c.id || c[0],
|
||
label: c.label || c[1],
|
||
heuristicLabel: c.heuristicLabel || c[2],
|
||
cohesion: c.cohesion || c[3],
|
||
symbolCount: c.symbolCount || c[4],
|
||
}));
|
||
|
||
let totalSymbols = 0,
|
||
weightedCohesion = 0;
|
||
for (const c of rawClusters) {
|
||
const s = c.symbolCount || 0;
|
||
totalSymbols += s;
|
||
weightedCohesion += (c.cohesion || 0) * s;
|
||
}
|
||
|
||
const members = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||
ORDER BY filePath, name, type
|
||
LIMIT 30
|
||
`,
|
||
{ clusterName: name },
|
||
);
|
||
|
||
return {
|
||
cluster: {
|
||
id: rawClusters[0].id,
|
||
label: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
||
heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
||
cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
|
||
symbolCount: totalSymbols,
|
||
subCommunities: rawClusters.length,
|
||
},
|
||
members: members.map((m: any) => ({
|
||
name: m.name || m[0],
|
||
type: m.type || m[1],
|
||
filePath: m.filePath || m[2],
|
||
})),
|
||
};
|
||
}
|
||
|
||
if (type === 'process') {
|
||
const processes = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:Process)
|
||
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||
ORDER BY id
|
||
LIMIT 1
|
||
`,
|
||
{ processName: name },
|
||
);
|
||
if (processes.length === 0) return { error: `Process '${name}' not found` };
|
||
|
||
const proc = processes[0];
|
||
const procId = proc.id || proc[0];
|
||
const steps = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
||
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
||
ORDER BY r.step
|
||
`,
|
||
{ procId },
|
||
);
|
||
|
||
return {
|
||
process: {
|
||
id: procId,
|
||
label: proc.label || proc[1],
|
||
heuristicLabel: proc.heuristicLabel || proc[2],
|
||
processType: proc.processType || proc[3],
|
||
stepCount: proc.stepCount || proc[4],
|
||
},
|
||
steps: steps.map((s: any) => ({
|
||
step: s.step || s[3],
|
||
name: s.name || s[0],
|
||
type: s.type || s[1],
|
||
filePath: s.filePath || s[2],
|
||
})),
|
||
};
|
||
}
|
||
|
||
return { error: 'Invalid type. Use: symbol, cluster, or process' };
|
||
}
|
||
|
||
/**
|
||
* Detect changes — git-diff based impact analysis.
|
||
* Maps changed lines to indexed symbols, then finds affected processes.
|
||
*/
|
||
private async detectChanges(
|
||
repo: RepoHandle,
|
||
params: {
|
||
scope?: string;
|
||
base_ref?: string;
|
||
worktree?: string;
|
||
},
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const scope = params.scope || 'unstaged';
|
||
const { execFileSync } = await import('child_process');
|
||
|
||
// Build git diff args based on scope (using execFileSync to avoid shell injection)
|
||
let diffArgs: string[];
|
||
switch (scope) {
|
||
case 'staged':
|
||
diffArgs = ['diff', '--staged', '-U0'];
|
||
break;
|
||
case 'all':
|
||
diffArgs = ['diff', 'HEAD', '-U0'];
|
||
break;
|
||
case 'compare':
|
||
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
|
||
diffArgs = ['diff', params.base_ref, '-U0'];
|
||
break;
|
||
case 'unstaged':
|
||
default:
|
||
diffArgs = ['diff', '-U0'];
|
||
break;
|
||
}
|
||
|
||
let diffOutput: string;
|
||
try {
|
||
// Resolve the cwd for git diff.
|
||
//
|
||
// In a linked worktree (e.g. /repo/wt-feature/), the user's staged and
|
||
// unstaged changes live in that worktree's separate working directory and
|
||
// index. Running `git diff` from the canonical repo root sees a different
|
||
// working tree and returns empty output.
|
||
//
|
||
// Resolution order (see resolveWorktreeCwd for details):
|
||
// 1. params.worktree — explicit override, validated against the
|
||
// registered repo's canonical root.
|
||
// 2. Auto-detect — if the server's launch cwd (process.cwd()) is a
|
||
// linked worktree of the same canonical repo, use its git root.
|
||
// 3. repo.repoPath — fallback (original behaviour, handled inside
|
||
// resolveWorktreeCwd when no worktree is detected).
|
||
//
|
||
// Start with the auto-detected value; override with the validated
|
||
// explicit param when provided. This avoids a dead initial assignment.
|
||
let diffCwd = resolveWorktreeCwd(repo.repoPath, process.cwd());
|
||
if (params.worktree) {
|
||
if (!path.isAbsolute(params.worktree)) {
|
||
return {
|
||
error: `worktree must be an absolute path, got: "${params.worktree}"`,
|
||
};
|
||
}
|
||
const providedResolved = path.resolve(params.worktree);
|
||
const repoCanonical = getCanonicalRepoRoot(repo.repoPath);
|
||
if (!repoCanonical) {
|
||
return {
|
||
error: `Could not determine canonical root for repo "${repo.repoPath}". Is git available?`,
|
||
};
|
||
}
|
||
const worktreeCanonical = getCanonicalRepoRoot(providedResolved);
|
||
if (!worktreeCanonical || tryRealpath(worktreeCanonical) !== tryRealpath(repoCanonical)) {
|
||
return {
|
||
error: `worktree "${params.worktree}" is not a worktree of repo "${repo.repoPath}". Ensure the path is inside the same git repository.`,
|
||
};
|
||
}
|
||
diffCwd = providedResolved;
|
||
}
|
||
|
||
// maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
|
||
// repos with large unstaged/untracked diffs (e.g. unignored build folders).
|
||
// See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
|
||
diffOutput = execFileSync('git', diffArgs, {
|
||
cwd: diffCwd,
|
||
encoding: 'utf-8',
|
||
maxBuffer: 256 * 1024 * 1024,
|
||
windowsHide: true,
|
||
});
|
||
} catch (err: any) {
|
||
return { error: `Git diff failed: ${err.message}` };
|
||
}
|
||
|
||
const fileDiffs: FileDiff[] = parseDiffHunks(diffOutput);
|
||
|
||
if (fileDiffs.length === 0) {
|
||
return {
|
||
summary: {
|
||
changed_count: 0,
|
||
affected_count: 0,
|
||
risk_level: 'none',
|
||
message: 'No changes detected.',
|
||
},
|
||
changed_symbols: [],
|
||
affected_processes: [],
|
||
};
|
||
}
|
||
|
||
// Map diff hunks to indexed symbols via range overlap
|
||
const changedSymbols: any[] = [];
|
||
// Set if a swallowed graph query fails below — surfaces `partial:true` so a
|
||
// degraded run cannot report a false-clean `risk_level:'low'` (#2283).
|
||
let queryDegraded = false;
|
||
for (const fileDiff of fileDiffs) {
|
||
if (fileDiff.hunks.length === 0) continue;
|
||
|
||
// Build range overlap conditions for all hunks in this file
|
||
const overlapConditions = fileDiff.hunks
|
||
.map((_, i) => `(n.startLine <= $hunkEnd${i} AND n.endLine >= $hunkStart${i})`)
|
||
.join(' OR ');
|
||
|
||
const queryParams: Record<string, any> = { filePath: fileDiff.filePath };
|
||
fileDiff.hunks.forEach((hunk, i) => {
|
||
queryParams[`hunkStart${i}`] = hunk.startLine;
|
||
queryParams[`hunkEnd${i}`] = hunk.endLine;
|
||
});
|
||
|
||
// Exclude BasicBlock rows by id prefix: on a --pdg index every edited
|
||
// function otherwise contributes N nameless BasicBlock pseudo-"symbols"
|
||
// (they carry filePath/start/end but no name), inflating changed_count
|
||
// and risk level with rows no consumer can act on (#2082 U7). Blocks
|
||
// are implementation substrate, not symbols — the owning Function row
|
||
// already represents the change. The id prefix (`BasicBlock:<file>:…`,
|
||
// cfg/emit.ts basicBlockId) beats a label predicate (`labels(n)[0]` is
|
||
// known to come back empty for several node types — see
|
||
// enrichCandidateLabels) AND beats `n.name IS NOT NULL` (which would
|
||
// also drop legitimate symbols whose name loaded as NULL, e.g.
|
||
// quoted-empty CSV fields for anonymous constructs).
|
||
const symbolQuery = `
|
||
MATCH (n) WHERE n.filePath ENDS WITH $filePath
|
||
AND NOT n.id STARTS WITH 'BasicBlock:'
|
||
AND n.startLine IS NOT NULL AND n.endLine IS NOT NULL
|
||
AND (${overlapConditions})
|
||
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type,
|
||
n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||
`;
|
||
|
||
try {
|
||
const rows = await executeParameterized(repo.lbugPath, symbolQuery, queryParams);
|
||
for (const sym of rows) {
|
||
changedSymbols.push({
|
||
id: sym.id || sym[0],
|
||
name: sym.name || sym[1],
|
||
type: sym.type || sym[2],
|
||
filePath: sym.filePath || sym[3],
|
||
change_type: 'touched',
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logQueryError('detect-changes:file-symbols', e);
|
||
// The symbol query failed: changedSymbols stays empty and the result
|
||
// would otherwise look like a clean no-op (`changed_count:0`,
|
||
// `risk_level:'low'`). detect_changes is the pre-commit safety gate, so
|
||
// flag the result `partial` rather than let a swallowed failure
|
||
// masquerade as "nothing changed" (#2283).
|
||
queryDegraded = true;
|
||
}
|
||
}
|
||
|
||
// Find affected processes -- single batched query instead of N+1
|
||
const affectedProcesses = new Map<string, any>();
|
||
if (changedSymbols.length > 0) {
|
||
const symIds = changedSymbols.map((s) => s.id);
|
||
const symNameById = new Map(changedSymbols.map((s) => [s.id, s.name]));
|
||
try {
|
||
const procs = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
WHERE n.id IN $ids
|
||
RETURN n.id AS nodeId, p.id AS pid, p.heuristicLabel AS label,
|
||
p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
||
`,
|
||
{ ids: symIds },
|
||
);
|
||
for (const proc of procs) {
|
||
const nodeId = proc.nodeId || proc[0];
|
||
const pid = proc.pid || proc[1];
|
||
if (!affectedProcesses.has(pid)) {
|
||
affectedProcesses.set(pid, {
|
||
id: pid,
|
||
name: proc.label || proc[2],
|
||
process_type: proc.processType || proc[3],
|
||
step_count: proc.stepCount || proc[4],
|
||
changed_steps: [],
|
||
});
|
||
}
|
||
affectedProcesses.get(pid)!.changed_steps.push({
|
||
symbol: symNameById.get(nodeId) ?? nodeId,
|
||
step: proc.step || proc[5],
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logQueryError('detect-changes:process-lookup', e);
|
||
queryDegraded = true;
|
||
}
|
||
}
|
||
|
||
const processCount = affectedProcesses.size;
|
||
const risk =
|
||
processCount === 0
|
||
? 'low'
|
||
: processCount <= 5
|
||
? 'medium'
|
||
: processCount <= 15
|
||
? 'high'
|
||
: 'critical';
|
||
|
||
return {
|
||
summary: {
|
||
changed_count: changedSymbols.length,
|
||
affected_count: processCount,
|
||
changed_files: fileDiffs.length,
|
||
risk_level: risk,
|
||
},
|
||
changed_symbols: changedSymbols,
|
||
affected_processes: Array.from(affectedProcesses.values()),
|
||
// A swallowed query failure makes the counts/risk above incomplete — tell
|
||
// the caller so the safety gate isn't trusted as a clean result (#2283).
|
||
...(queryDegraded && { partial: true }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Rename tool — multi-file coordinated rename using graph + text search.
|
||
* Graph refs are tagged "graph" (high confidence).
|
||
* Additional refs found via text search are tagged "text_search" (lower confidence).
|
||
*/
|
||
private async rename(
|
||
repo: RepoHandle,
|
||
params: {
|
||
symbol_name?: string;
|
||
symbol_uid?: string;
|
||
new_name: string;
|
||
file_path?: string;
|
||
dry_run?: boolean;
|
||
},
|
||
): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const { new_name, file_path } = params;
|
||
const dry_run = params.dry_run ?? true;
|
||
|
||
if (!params.symbol_name && !params.symbol_uid) {
|
||
return { error: 'Either symbol_name or symbol_uid is required.' };
|
||
}
|
||
|
||
/** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
|
||
const assertSafePath = (filePath: string): string => {
|
||
const full = path.resolve(repo.repoPath, filePath);
|
||
const safePrefix = repo.repoPath.endsWith(path.sep)
|
||
? repo.repoPath
|
||
: repo.repoPath + path.sep;
|
||
if (!full.startsWith(safePrefix) && full !== repo.repoPath) {
|
||
throw new Error(`Path traversal blocked: ${filePath}`);
|
||
}
|
||
return full;
|
||
};
|
||
|
||
// Step 1: Find the target symbol (reuse context's lookup)
|
||
const lookupResult = await this.context(repo, {
|
||
name: params.symbol_name,
|
||
uid: params.symbol_uid,
|
||
file_path,
|
||
});
|
||
|
||
if (lookupResult.status === 'ambiguous') {
|
||
return lookupResult; // pass disambiguation through
|
||
}
|
||
if (lookupResult.error) {
|
||
return lookupResult;
|
||
}
|
||
|
||
const sym = lookupResult.symbol;
|
||
const oldName = sym.name;
|
||
|
||
if (oldName === new_name) {
|
||
return { error: 'New name is the same as the current name.' };
|
||
}
|
||
|
||
// Steps 2+3: Determine the set of files the apply step will rewrite, then
|
||
// enumerate every occurrence in each. The apply step (Step 4) does a
|
||
// whole-file `\boldName\b` global replace on every file in `changes`, so the
|
||
// reported edit list MUST enumerate every matching line in every such file —
|
||
// otherwise the preview under-reports what lands, and the same partial list
|
||
// comes back after apply (#2605). Building `changes` from one file set makes
|
||
// the preview enumerate exactly the files the apply loop rewrites, using the
|
||
// same word-boundary regex. (This is per-call consistency; the apply loop
|
||
// still re-reads each file, so an external write landing between preview and
|
||
// apply is a pre-existing gap this method does not lock against.)
|
||
type RenameEdit = {
|
||
line: number;
|
||
old_text: string;
|
||
new_text: string;
|
||
confidence: 'graph' | 'text_search';
|
||
};
|
||
const escapedOldName = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
|
||
// Classify each file to rewrite by how it was discovered. Definition and
|
||
// graph-ref files carry graph confidence; files found only by text search
|
||
// carry text_search confidence. A graph-classified file is never downgraded.
|
||
const fileConfidence = new Map<string, 'graph' | 'text_search'>();
|
||
|
||
if (sym.filePath) {
|
||
fileConfidence.set(sym.filePath, 'graph');
|
||
}
|
||
|
||
// All incoming refs from graph (callers, importers, etc.)
|
||
const allIncoming = [
|
||
...(lookupResult.incoming.calls || []),
|
||
...(lookupResult.incoming.imports || []),
|
||
...(lookupResult.incoming.extends || []),
|
||
...(lookupResult.incoming.implements || []),
|
||
];
|
||
for (const ref of allIncoming) {
|
||
if (ref.filePath) {
|
||
fileConfidence.set(ref.filePath, 'graph');
|
||
}
|
||
}
|
||
|
||
// Text search for files the graph might have missed entirely.
|
||
try {
|
||
const { execFileSync } = await import('child_process');
|
||
const rgArgs = [
|
||
'-l',
|
||
'--type-add',
|
||
'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
|
||
'-t',
|
||
'code',
|
||
`\\b${oldName}\\b`,
|
||
'.',
|
||
];
|
||
const output = execFileSync('rg', rgArgs, {
|
||
cwd: repo.repoPath,
|
||
encoding: 'utf-8',
|
||
timeout: 5000,
|
||
// Avoid ENOBUFS on large repos: rg -l can list many files.
|
||
maxBuffer: 256 * 1024 * 1024,
|
||
windowsHide: true,
|
||
});
|
||
const files = output
|
||
.trim()
|
||
.split('\n')
|
||
.filter((f) => f.length > 0);
|
||
|
||
for (const file of files) {
|
||
const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
||
// Never downgrade a graph-classified file to text_search.
|
||
if (!fileConfidence.has(normalizedFile)) {
|
||
fileConfidence.set(normalizedFile, 'text_search');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logQueryError('rename:ripgrep', e);
|
||
}
|
||
|
||
// Enumerate every `\boldName\b` line in each file to rewrite, so the previewed
|
||
// file set is exactly the set the apply loop below rewrites. A file with no
|
||
// matching line is dropped (apply would write nothing to it). `wordTest`
|
||
// (non-global) probes each line; `wordReplace` (global) rewrites it and is
|
||
// reused by the apply loop — compiled once each rather than once per line,
|
||
// and one escaping formula serves both passes.
|
||
const wordTest = new RegExp(`\\b${escapedOldName}\\b`);
|
||
const wordReplace = new RegExp(`\\b${escapedOldName}\\b`, 'g');
|
||
const changes = new Map<string, { file_path: string; edits: RenameEdit[] }>();
|
||
|
||
for (const [filePath, confidence] of fileConfidence) {
|
||
try {
|
||
const content = await fs.readFile(assertSafePath(filePath), 'utf-8');
|
||
const lines = content.split('\n');
|
||
const edits: RenameEdit[] = [];
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (!wordTest.test(lines[i])) {
|
||
continue;
|
||
}
|
||
edits.push({
|
||
line: i + 1,
|
||
old_text: lines[i].trim(),
|
||
new_text: lines[i].replace(wordReplace, new_name).trim(),
|
||
confidence,
|
||
});
|
||
}
|
||
if (edits.length > 0) {
|
||
changes.set(filePath, { file_path: filePath, edits });
|
||
}
|
||
} catch (e) {
|
||
logQueryError('rename:enumerate', e);
|
||
}
|
||
}
|
||
|
||
// Step 4: Apply or preview.
|
||
const failedFiles: string[] = [];
|
||
if (!dry_run) {
|
||
for (const change of changes.values()) {
|
||
try {
|
||
const fullPath = assertSafePath(change.file_path);
|
||
const content = await fs.readFile(fullPath, 'utf-8');
|
||
await fs.writeFile(fullPath, content.replace(wordReplace, new_name), 'utf-8');
|
||
} catch (e) {
|
||
// A swallowed write failure must not be reported as success (#2283):
|
||
// record the file so the result degrades to 'partial'.
|
||
logQueryError('rename:apply-edit', e);
|
||
failedFiles.push(change.file_path);
|
||
}
|
||
}
|
||
// A file whose write threw did not land, so drop its edits from the
|
||
// reported result — total_edits/changes must describe what actually
|
||
// reached disk, not what was attempted (#2605: the report matches reality
|
||
// even on partial failure). failed_files still names every dropped file.
|
||
for (const f of failedFiles) {
|
||
changes.delete(f);
|
||
}
|
||
}
|
||
|
||
// Counts derive from the reported set (dry-run: every enumerated file;
|
||
// apply: only files that landed), so the graph/text_search split always
|
||
// sums to total_edits and never overstates a partial apply.
|
||
const reported = Array.from(changes.values());
|
||
let graphEdits = 0;
|
||
let astSearchEdits = 0;
|
||
for (const change of reported) {
|
||
for (const edit of change.edits) {
|
||
if (edit.confidence === 'graph') {
|
||
graphEdits++;
|
||
} else {
|
||
astSearchEdits++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: failedFiles.length > 0 ? 'partial' : 'success',
|
||
old_name: oldName,
|
||
new_name,
|
||
files_affected: reported.length,
|
||
total_edits: graphEdits + astSearchEdits,
|
||
graph_edits: graphEdits,
|
||
text_search_edits: astSearchEdits,
|
||
changes: reported,
|
||
applied: !dry_run,
|
||
...(failedFiles.length > 0 && { failed_files: failedFiles }),
|
||
};
|
||
}
|
||
|
||
private async trace(repo: RepoHandle, params: TraceParams): Promise<any> {
|
||
try {
|
||
return await this._traceImpl(repo, params);
|
||
} catch (err: any) {
|
||
return {
|
||
status: 'error',
|
||
error: (err instanceof Error ? err.message : String(err)) || 'Trace analysis failed',
|
||
from: { name: params.from },
|
||
to: { name: params.to },
|
||
suggestion:
|
||
'The graph query failed — try gitnexus context <symbol> to see connections, ' +
|
||
'or check if an interface bridges them.',
|
||
...(isWalCorruptionError(err) ? { recoverySuggestion: WAL_RECOVERY_SUGGESTION } : {}),
|
||
};
|
||
}
|
||
}
|
||
|
||
private async _traceImpl(repo: RepoHandle, params: TraceParams): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
// resolveSymbolCandidates feeds `from`/`to` into string operations
|
||
// (e.g. name.includes), so a non-string param would surface a low-level
|
||
// "x.includes is not a function". Reject it with a clear message instead.
|
||
const isStringOrAbsent = (v: unknown): boolean => v === undefined || typeof v === 'string';
|
||
if (
|
||
!isStringOrAbsent(params.from) ||
|
||
!isStringOrAbsent(params.to) ||
|
||
!isStringOrAbsent(params.from_uid) ||
|
||
!isStringOrAbsent(params.to_uid)
|
||
) {
|
||
return {
|
||
status: 'error',
|
||
error: "'from', 'to', and their *_uid variants must be strings.",
|
||
suggestion: 'Pass symbol names or UIDs as strings, e.g. trace from="A" to="B".',
|
||
};
|
||
}
|
||
|
||
// A single-repo trace needs a target. Omitting `to` is the destination-trace
|
||
// shorthand, but that only exists for a cross-repo @group trace — reject a
|
||
// to-less single-repo call with an actionable error rather than the opaque
|
||
// "Target symbol 'undefined' not found".
|
||
const hasTo =
|
||
(typeof params.to === 'string' && params.to.trim() !== '') ||
|
||
(typeof params.to_uid === 'string' && params.to_uid.trim() !== '');
|
||
if (!hasTo) {
|
||
return {
|
||
status: 'error',
|
||
error: 'trace requires `to` (or `to_uid`) for a single-repo trace.',
|
||
suggestion:
|
||
'Pass a target symbol, or use repo:"@<group>" and omit `to` to trace `from` to its HTTP destination.',
|
||
};
|
||
}
|
||
|
||
const fromOutcome = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid: params.from_uid, name: params.from },
|
||
{ file_path: params.from_file },
|
||
);
|
||
|
||
if (fromOutcome.kind === 'not_found') {
|
||
return {
|
||
status: 'not_found',
|
||
error: `Source symbol '${params.from_uid ?? params.from}' not found.`,
|
||
suggestion: 'Check the symbol name or use --from-uid for zero-ambiguity.',
|
||
};
|
||
}
|
||
if (fromOutcome.kind === 'ambiguous') {
|
||
const { atLeast, showing, fields } = ambiguityReport(
|
||
fromOutcome,
|
||
fromOutcome.candidates.length,
|
||
);
|
||
return {
|
||
status: 'ambiguous',
|
||
role: 'from',
|
||
message: `Found ${atLeast}${fromOutcome.total} symbols matching '${params.from}'${showing}. Disambiguate with --from-uid.`,
|
||
...fields,
|
||
candidates: fromOutcome.candidates,
|
||
};
|
||
}
|
||
|
||
const toOutcome = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid: params.to_uid, name: params.to },
|
||
{ file_path: params.to_file },
|
||
);
|
||
|
||
if (toOutcome.kind === 'not_found') {
|
||
return {
|
||
status: 'not_found',
|
||
error: `Target symbol '${params.to_uid ?? params.to}' not found.`,
|
||
suggestion: 'Check the symbol name or use --to-uid for zero-ambiguity.',
|
||
};
|
||
}
|
||
if (toOutcome.kind === 'ambiguous') {
|
||
const { atLeast, showing, fields } = ambiguityReport(toOutcome, toOutcome.candidates.length);
|
||
return {
|
||
status: 'ambiguous',
|
||
role: 'to',
|
||
message: `Found ${atLeast}${toOutcome.total} symbols matching '${params.to}'${showing}. Disambiguate with --to-uid.`,
|
||
...fields,
|
||
candidates: toOutcome.candidates,
|
||
};
|
||
}
|
||
|
||
const fromSym = fromOutcome.symbol;
|
||
const toSym = toOutcome.symbol;
|
||
|
||
if (fromSym.id === toSym.id) {
|
||
return {
|
||
status: 'ok',
|
||
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
||
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
||
hopCount: 0,
|
||
hops: [{ name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine }],
|
||
edges: [],
|
||
};
|
||
}
|
||
|
||
// Sanitize maxDepth at the real boundary: the MCP inputSchema's
|
||
// minimum/maximum is advisory only (callTool is reachable directly), so a
|
||
// caller can pass 0, a negative, NaN, or a non-integer. `??` does NOT
|
||
// recover 0/NaN, and Math.min has no lower bound — left unguarded, any of
|
||
// those makes the BFS loop run zero iterations and return a false no_path.
|
||
const DEFAULT_TRACE_DEPTH = 10;
|
||
const MAX_TRACE_DEPTH = 30;
|
||
const requestedDepth =
|
||
Number.isInteger(params.maxDepth) && (params.maxDepth as number) > 0
|
||
? (params.maxDepth as number)
|
||
: DEFAULT_TRACE_DEPTH;
|
||
const maxDepth = Math.min(requestedDepth, MAX_TRACE_DEPTH);
|
||
const includeTests = params.includeTests ?? false;
|
||
// Traversal vocabulary: CALLS for actual calls, HAS_METHOD so a class-rooted
|
||
// trace can descend into its methods. Not "calls only" — per-hop edge type is
|
||
// surfaced in edges[] so containment hops stay distinguishable.
|
||
const TRAVERSAL_EDGE_TYPES = ['CALLS', 'HAS_METHOD'];
|
||
|
||
// Bound the traversal so a high-fanout hub (a logger/util reached by many
|
||
// symbols) can't materialize an unbounded frontier. Per-level rows are
|
||
// capped and the total visited set is capped; either cap sets `truncated`
|
||
// so a resulting no_path is never reported as if the graph was exhausted.
|
||
const PER_NODE_FANOUT_CAP = 200;
|
||
const ABS_ROW_CAP = 5000;
|
||
const MAX_VISITED = 50000;
|
||
let truncated = false;
|
||
|
||
const visited = new Set<string>([fromSym.id]);
|
||
let frontier = [fromSym.id];
|
||
const parent = new Map<
|
||
string,
|
||
{
|
||
from: string;
|
||
name: string;
|
||
filePath: string;
|
||
startLine: number;
|
||
edgeType: string;
|
||
confidence: number;
|
||
}
|
||
>();
|
||
|
||
let found = false;
|
||
// The last node discovered at the deepest reached level — surfaced as
|
||
// `furthest` in the no_path response to hint where the chain breaks.
|
||
let lastReached: {
|
||
name: string;
|
||
filePath: string;
|
||
startLine: number;
|
||
} | null = null;
|
||
let reachedDepth = 0;
|
||
|
||
for (let depth = 1; depth <= maxDepth && frontier.length > 0 && !found; depth++) {
|
||
const nextFrontier: string[] = [];
|
||
// LadybugDB/Kuzu does not support a parameterized LIMIT, so the cap is
|
||
// interpolated (it is a derived integer, not user input).
|
||
//
|
||
// The ORDER BY below is required for two separate reasons (#2787). When a
|
||
// level overflows `rowCap`, an unordered LIMIT decided WHICH neighbours
|
||
// survived per process — so the same trace(from, to) could return a path
|
||
// on one run and `no_path` on the next. And even with no truncation, the
|
||
// `parent` map below is first-writer-wins, so among several equal-length
|
||
// shortest paths the reported hops/edges (and `lastReached`) followed raw
|
||
// row order.
|
||
const rowCap = Math.min(frontier.length * PER_NODE_FANOUT_CAP, ABS_ROW_CAP);
|
||
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (n)-[r:CodeRelation]->(m)
|
||
WHERE n.id IN $frontierIds AND r.type IN $edgeTypes
|
||
RETURN n.id AS sourceId, m.id AS id, m.name AS name, labels(m)[0] AS type,
|
||
m.filePath AS filePath, m.startLine AS startLine,
|
||
r.type AS edgeType, r.confidence AS confidence
|
||
ORDER BY sourceId, id, edgeType
|
||
LIMIT ${rowCap}`,
|
||
{ frontierIds: frontier, edgeTypes: TRAVERSAL_EDGE_TYPES },
|
||
);
|
||
|
||
// A clipped level may have dropped a node that lies on the only shortest
|
||
// path, so any subsequent no_path is not authoritative.
|
||
if (rows.length >= rowCap) truncated = true;
|
||
|
||
for (const row of rows) {
|
||
// Decode once. The `?? row[N]` fallback handles LadybugDB tuple-mode
|
||
// returns; the positional indices mirror the RETURN column order above.
|
||
const nodeId = (row.id ?? row[1]) as string;
|
||
const sourceId = (row.sourceId ?? row[0]) as string;
|
||
const name = (row.name ?? row[2]) as string;
|
||
const filePath = (row.filePath ?? row[4]) as string;
|
||
const startLine = (row.startLine ?? row[5]) as number;
|
||
const edgeType = (row.edgeType ?? row[6]) as string;
|
||
const storedConfidence = row.confidence ?? row[7];
|
||
const confidence =
|
||
typeof storedConfidence === 'number' && storedConfidence > 0
|
||
? storedConfidence
|
||
: confidenceForRelType(edgeType);
|
||
|
||
// Match the explicitly-requested target before the test-file filter.
|
||
// resolveSymbolCandidates does not exclude test-file symbols, so a
|
||
// target (or a required hop) that lives in a test file would otherwise
|
||
// be dropped by the includeTests guard below and produce a false
|
||
// no_path even when a direct edge exists.
|
||
if (nodeId === toSym.id) {
|
||
parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
|
||
found = true;
|
||
break;
|
||
}
|
||
|
||
// Skip non-target nodes that live in test files unless includeTests.
|
||
if (!includeTests && isTestFilePath(filePath)) continue;
|
||
|
||
if (!visited.has(nodeId)) {
|
||
visited.add(nodeId);
|
||
parent.set(nodeId, { from: sourceId, name, filePath, startLine, edgeType, confidence });
|
||
nextFrontier.push(nodeId);
|
||
lastReached = { name, filePath, startLine };
|
||
reachedDepth = depth;
|
||
}
|
||
}
|
||
|
||
frontier = nextFrontier;
|
||
if (visited.size >= MAX_VISITED) {
|
||
truncated = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (found) {
|
||
const path: Array<{ name: string; filePath: string; startLine: number }> = [];
|
||
const edges: Array<{ relType: string; confidence: number }> = [];
|
||
let current = toSym.id;
|
||
|
||
while (current !== fromSym.id) {
|
||
const info = parent.get(current)!;
|
||
path.unshift({ name: info.name, filePath: info.filePath, startLine: info.startLine });
|
||
edges.unshift({ relType: info.edgeType, confidence: info.confidence });
|
||
current = info.from;
|
||
}
|
||
path.unshift({
|
||
name: fromSym.name,
|
||
filePath: fromSym.filePath,
|
||
startLine: fromSym.startLine,
|
||
});
|
||
|
||
return {
|
||
status: 'ok',
|
||
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
||
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
||
hopCount: edges.length,
|
||
hops: path,
|
||
edges,
|
||
};
|
||
}
|
||
|
||
return {
|
||
status: 'no_path',
|
||
from: { name: fromSym.name, filePath: fromSym.filePath, startLine: fromSym.startLine },
|
||
to: { name: toSym.name, filePath: toSym.filePath, startLine: toSym.startLine },
|
||
furthest: lastReached ? { ...lastReached, depth: reachedDepth } : null,
|
||
...(truncated ? { truncated: true } : {}),
|
||
suggestion: truncated
|
||
? 'Search was truncated at a traversal cap before exhausting the graph — a path ' +
|
||
'may still exist. Narrow the search (a lower --depth, or trace from a more ' +
|
||
'specific symbol), or use gitnexus context <symbol> to inspect connections.'
|
||
: 'No directed path found. The call chain likely breaks at dynamic dispatch, ' +
|
||
'reflection, or an external API boundary. Try gitnexus context <symbol> to see ' +
|
||
"both symbols' connections, or check if an interface/abstraction bridges them.",
|
||
};
|
||
}
|
||
|
||
private async impact(repo: RepoHandle, params: ImpactParams): Promise<any> {
|
||
try {
|
||
return await this._impactImpl(repo, params);
|
||
} catch (err: any) {
|
||
// Return structured error instead of crashing (#321)
|
||
const message =
|
||
(err instanceof Error ? err.message : String(err)) || 'Impact analysis failed';
|
||
const suggestion = 'The graph query failed — try gitnexus context <symbol> as a fallback';
|
||
const recoverySuggestion = isWalCorruptionError(err) ? WAL_RECOVERY_SUGGESTION : undefined;
|
||
if (params.mode === 'pdg') {
|
||
// Symbol resolution never reached the catch with a resolved symbol (the
|
||
// throw can originate before/within resolution), so the envelope carries
|
||
// the partial-but-typed target — typed as PdgImpactTarget so the partial
|
||
// is type-checked, not an inline literal in a Promise<any> hole.
|
||
const target: PdgImpactTarget = { name: params.target };
|
||
const pdgErr: PdgImpactErrorResult = makePdgImpactErrorResult({
|
||
mode: 'pdg',
|
||
error: message,
|
||
target,
|
||
direction: params.direction,
|
||
suggestion,
|
||
recoverySuggestion,
|
||
});
|
||
return pdgErr;
|
||
}
|
||
return {
|
||
error: message,
|
||
target: { name: params.target },
|
||
direction: params.direction,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
suggestion,
|
||
...(recoverySuggestion ? { recoverySuggestion } : {}),
|
||
};
|
||
}
|
||
}
|
||
|
||
private async _impactImpl(repo: RepoHandle, params: ImpactParams): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const { target, direction } = params;
|
||
|
||
// ── Dispatch order (KTD5) ──────────────────────────────────────────
|
||
// (1) Validate `mode`. Absent/'callgraph' → unchanged path; 'pdg' → the
|
||
// intra-procedural PDG engine; anything else → hard error.
|
||
// This MUST come before resolveSymbolCandidates so the ambiguous branch can
|
||
// fork on the validated mode and never run the callgraph fan-out under pdg.
|
||
const modeResult = validateImpactMode(params.mode);
|
||
if ('error' in modeResult) {
|
||
return {
|
||
error: modeResult.error,
|
||
target: { name: target },
|
||
direction,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
};
|
||
}
|
||
const mode = modeResult.mode;
|
||
|
||
// #2279: some MCP client/agent adapters serialize an *omitted* optional
|
||
// numeric field as `0` rather than dropping it, so callgraph calls arrive
|
||
// carrying a spurious `line: 0`. `line` is meaningless on the callgraph path
|
||
// (the symbol→symbol BFS has no statement notion), so treat a literal `0`
|
||
// there as omitted and let the normal traversal run. The coercion is
|
||
// deliberately narrow — only the literal `0`, only when mode !== 'pdg':
|
||
// a genuine positive `line` on callgraph still errors (real mode mistake),
|
||
// negative/fractional values still error, and pdg mode is untouched (the
|
||
// normalization is an identity there, so `line: 0` is still rejected below —
|
||
// there is no 1-based source line `0` to anchor on).
|
||
const effectiveLine = mode !== 'pdg' && params.line === 0 ? undefined : params.line;
|
||
|
||
// `line` is a PDG-only statement anchor. Reject it on the callgraph path
|
||
// rather than silently ignore (the symbol→symbol BFS has no statement notion).
|
||
if (effectiveLine !== undefined && mode !== 'pdg') {
|
||
return {
|
||
error: `Parameter 'line' is only supported with mode:'pdg' (it anchors the dependence slice on a statement). Remove it or set mode:'pdg'.`,
|
||
target: { name: params.target },
|
||
direction: params.direction,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
};
|
||
}
|
||
// A provided `line` must be a positive integer.
|
||
if (
|
||
effectiveLine !== undefined &&
|
||
(!Number.isInteger(effectiveLine) || (effectiveLine as number) < 1)
|
||
) {
|
||
// Line param fails validation before target resolution → partial-but-typed
|
||
// target on the pdg path (typed PdgImpactTarget, not an inline literal).
|
||
const badLineTarget: PdgImpactTarget = { name: params.target };
|
||
return mode === 'pdg'
|
||
? makePdgImpactErrorResult({
|
||
mode: 'pdg',
|
||
error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`,
|
||
target: badLineTarget,
|
||
direction: params.direction,
|
||
})
|
||
: {
|
||
error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`,
|
||
target: { name: params.target },
|
||
direction: params.direction,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
};
|
||
}
|
||
|
||
if (mode === 'pdg') {
|
||
// PDG mode is now unified inside a single repo: it combines the local
|
||
// CDG/RD statement slice with the same inter-symbol reach used for the
|
||
// option-driven comparison path. Cross-repo fan-out remains a callgraph
|
||
// feature, so crossDepth is still a loud error rather than a silent ignore.
|
||
const incompatible: string[] = [];
|
||
if (params.crossDepth !== undefined) incompatible.push('crossDepth');
|
||
if (incompatible.length > 0) {
|
||
// crossDepth is rejected before target resolution → partial-but-typed
|
||
// target (typed PdgImpactTarget).
|
||
const crossDepthTarget: PdgImpactTarget = { name: target };
|
||
const pdgErr: PdgImpactErrorResult = makePdgImpactErrorResult({
|
||
mode: 'pdg',
|
||
error:
|
||
`Parameter(s) ${incompatible.join(', ')} are not supported with mode:'pdg' ` +
|
||
`(single-repo PDG impact). Remove them or use mode:'callgraph' for cross-repo fan-out.`,
|
||
target: crossDepthTarget,
|
||
direction,
|
||
});
|
||
return pdgErr;
|
||
}
|
||
}
|
||
|
||
const maxDepth = params.maxDepth || 3;
|
||
// Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES)
|
||
const mappedRelTypes = params.relationTypes?.flatMap((t: string) =>
|
||
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
|
||
);
|
||
const hasExplicitRelationTypes = mappedRelTypes !== undefined && mappedRelTypes.length > 0;
|
||
const rawRelTypes =
|
||
mappedRelTypes && mappedRelTypes.length > 0
|
||
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
|
||
: [
|
||
'CALLS',
|
||
'IMPORTS',
|
||
'EXTENDS',
|
||
'IMPLEMENTS',
|
||
'USES',
|
||
'METHOD_OVERRIDES',
|
||
'OVERRIDES',
|
||
'METHOD_IMPLEMENTS',
|
||
];
|
||
const relationTypes =
|
||
rawRelTypes.length > 0
|
||
? rawRelTypes
|
||
: [
|
||
'CALLS',
|
||
'IMPORTS',
|
||
'EXTENDS',
|
||
'IMPLEMENTS',
|
||
'USES',
|
||
'METHOD_OVERRIDES',
|
||
'OVERRIDES',
|
||
'METHOD_IMPLEMENTS',
|
||
];
|
||
const includeTests = params.includeTests ?? false;
|
||
const minConfidence = params.minConfidence ?? 0;
|
||
|
||
// Resolve target via the shared symbol resolver. When the caller passes
|
||
// target_uid we skip the name lookup entirely (zero-ambiguity). Otherwise
|
||
// we rank candidates (#470) and either proceed with a confident single
|
||
// match, or return a structured ambiguous response instead of silently
|
||
// picking the wrong symbol.
|
||
//
|
||
// The resolver preserves the #480 Class/Constructor preference heuristic:
|
||
// when a Class and its Constructor share name + filePath, the Class is
|
||
// selected silently.
|
||
const outcome = await this.resolveSymbolCandidates(
|
||
repo,
|
||
{ uid: params.target_uid, name: target },
|
||
{ file_path: params.file_path, kind: params.kind },
|
||
);
|
||
|
||
if (outcome.kind === 'not_found') {
|
||
const missing = params.target_uid ?? target;
|
||
// not_found = no resolved symbol, so the envelope keeps the partial-but-
|
||
// typed target (typed PdgImpactTarget — there is no id/type/filePath yet).
|
||
const notFoundTarget: PdgImpactTarget = { name: target };
|
||
return mode === 'pdg'
|
||
? makePdgImpactErrorResult({
|
||
mode: 'pdg',
|
||
error: `Target '${missing}' not found`,
|
||
target: notFoundTarget,
|
||
direction,
|
||
})
|
||
: {
|
||
error: `Target '${missing}' not found`,
|
||
target: { name: target },
|
||
direction,
|
||
impactedCount: 0,
|
||
risk: 'UNKNOWN',
|
||
};
|
||
}
|
||
|
||
if (outcome.kind === 'ambiguous') {
|
||
// Shared truncation cap for the ambiguous candidate list — both the pdg
|
||
// branch (shows candidates) and the callgraph branch (probes candidates)
|
||
// bound to this many.
|
||
const AMBIGUOUS_MAX_CANDIDATES = 6;
|
||
// KTD5 ambiguous trap — under mode:'pdg' we MUST NOT fall into the
|
||
// callgraph fan-out below: it runs `_runImpactBFS` per candidate, which
|
||
// would silently execute the call-graph engine under a `pdg` call (the
|
||
// exact silent fallback KTD5 forbids). For U1 the pdg ambiguous path
|
||
// returns the candidate list WITHOUT any callgraph probe; the full pdg
|
||
// ambiguous handling (per-candidate PDG summaries / ranking) lands in U4.
|
||
if (mode === 'pdg') {
|
||
const shown = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
|
||
const { atLeast, showing, fields } = ambiguityReport(outcome, shown.length, true);
|
||
return {
|
||
status: 'ambiguous',
|
||
mode,
|
||
message:
|
||
`Found ${atLeast}${outcome.total} symbols matching '${target}'` +
|
||
showing +
|
||
`. Disambiguate with target_uid (or file_path/kind) for a single ` +
|
||
`authoritative PDG result.`,
|
||
target: { name: target },
|
||
direction,
|
||
...fields,
|
||
// No single resolved symbol → the blast radius is UNDETERMINED, not
|
||
// zero. `null` (not 0) because no callgraph fan-out runs on this path,
|
||
// so there is not even a `maxImpactedCount` to correct a numeric zero
|
||
// against — it would be indistinguishable from a genuine "nothing
|
||
// depends on this" (#2687).
|
||
impactedCount: null,
|
||
risk: 'UNKNOWN',
|
||
candidates: shown.map((c) => ({
|
||
uid: c.id,
|
||
name: c.name,
|
||
kind: c.type,
|
||
filePath: c.filePath,
|
||
line: toDisplayLine(c.startLine),
|
||
score: Number(c.score.toFixed(2)),
|
||
})),
|
||
};
|
||
}
|
||
|
||
// #2129 — a bare name that collides with several symbols must NOT report a
|
||
// bare `impactedCount: 0`. The real blast radius lives under whichever
|
||
// candidate the caller meant; a flat zero here is precisely the silent
|
||
// under-report the "run impact before editing" workflow exists to prevent
|
||
// (the dropped caller calls a *different* same-name node, so it never shows
|
||
// up against the one the resolver happened to pick). Run a bounded,
|
||
// summary-only BFS per candidate so each one's true count + risk is
|
||
// visible, and surface the maximum at the top level so the headline can
|
||
// never read as "safe to refactor". Candidates arrive sorted by score.
|
||
const probed = outcome.candidates.slice(0, AMBIGUOUS_MAX_CANDIDATES);
|
||
// `partialProbe` is intentionally a SECOND incompleteness flag, distinct
|
||
// from the traversal-interrupted `partial` flag used elsewhere: it means
|
||
// one or more per-candidate probes threw, so maxRisk / maxImpactedCount
|
||
// are lower bounds over the probes that succeeded (a failed candidate must
|
||
// not be masked by a benign sibling success).
|
||
let probeFailed = false;
|
||
const candidateSummaries = await Promise.all(
|
||
probed.map(async (c) => {
|
||
const cType = c.type || '';
|
||
const cRelTypes =
|
||
(cType === 'Class' || cType === 'Interface') &&
|
||
!hasExplicitRelationTypes &&
|
||
!relationTypes.includes('ACCESSES')
|
||
? [...relationTypes, 'ACCESSES']
|
||
: relationTypes;
|
||
// #1858/#2129 review F8 — name the shape the probe summary is read
|
||
// through (`_runImpactBFS` returns `Promise<any>`, so this is the
|
||
// narrowing cast) so a future rename of those fields fails tsc instead
|
||
// of silently zeroing candidate counts.
|
||
let summary: {
|
||
impactedCount: number;
|
||
risk: string;
|
||
summary?: { direct: number };
|
||
} | null = null;
|
||
try {
|
||
summary = await this._runImpactBFS(
|
||
repo,
|
||
{ id: c.id, name: c.name, filePath: c.filePath },
|
||
cType,
|
||
direction,
|
||
{
|
||
maxDepth,
|
||
relationTypes: cRelTypes,
|
||
includeTests,
|
||
minConfidence,
|
||
summaryOnly: true,
|
||
skipEpistemic: true,
|
||
skipEnrichment: true,
|
||
},
|
||
);
|
||
} catch (e) {
|
||
probeFailed = true;
|
||
logQueryError('impact:ambiguous-candidate', e);
|
||
}
|
||
return {
|
||
uid: c.id,
|
||
name: c.name,
|
||
kind: c.type,
|
||
filePath: c.filePath,
|
||
line: toDisplayLine(c.startLine),
|
||
score: Number(c.score.toFixed(2)),
|
||
impactedCount: summary?.impactedCount ?? 0,
|
||
risk: summary?.risk ?? 'UNKNOWN',
|
||
direct: summary?.summary?.direct ?? 0,
|
||
};
|
||
}),
|
||
);
|
||
|
||
// Rank by blast radius so the most-impactful interpretation is first, and
|
||
// hoist the maximum count/risk to the top level so the response cannot be
|
||
// misread as "no impact".
|
||
candidateSummaries.sort((a, b) => b.impactedCount - a.impactedCount);
|
||
const maxImpactedCount = candidateSummaries.reduce((m, c) => Math.max(m, c.impactedCount), 0);
|
||
const RISK_ORDER = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
|
||
// If EVERY candidate probe failed (all 'UNKNOWN' — e.g. pool exhaustion
|
||
// under the fan-out), the worst real risk is genuinely unknown, not LOW.
|
||
// Reporting LOW here would re-introduce the false-safe signal. Only fall to
|
||
// the LOW seed when at least one candidate produced a real risk.
|
||
const anyKnownRisk = candidateSummaries.some((c) => RISK_ORDER.includes(c.risk));
|
||
const maxRisk = anyKnownRisk
|
||
? candidateSummaries.reduce(
|
||
(worst, c) => (RISK_ORDER.indexOf(c.risk) > RISK_ORDER.indexOf(worst) ? c.risk : worst),
|
||
'LOW',
|
||
)
|
||
: 'UNKNOWN';
|
||
// `candidateSummaries` is `Promise.all` over `probed`, so the two lengths
|
||
// are the same; `probed` is the one the message and the flag agree on.
|
||
const { atLeast, showing, fields } = ambiguityReport(outcome, probed.length, true);
|
||
|
||
return {
|
||
status: 'ambiguous',
|
||
message:
|
||
`Found ${atLeast}${outcome.total} symbols matching '${target}'` +
|
||
showing +
|
||
`. Blast radius differs per candidate (max ${maxImpactedCount} impacted at risk ${maxRisk}). ` +
|
||
`Disambiguate with target_uid (or file_path/kind) for a single authoritative result.`,
|
||
target: { name: target },
|
||
direction,
|
||
// `totalCandidates` is the resolver's COUNT, not `candidates.length`:
|
||
// that array is truncated to AMBIGUOUS_MAX_CANDIDATES and the resolver
|
||
// window itself caps at CANDIDATE_WINDOW, so consumers (CLI formatter)
|
||
// need the COUNT to report "N of M" honestly (#2129 review F11; the CLI
|
||
// previously read the truncated array length, then the capped window
|
||
// length — both undercounts).
|
||
...fields,
|
||
// `impactedCount` is `null` — UNDETERMINED, not zero — and `risk` stays
|
||
// UNKNOWN, because there is no single resolved symbol. #2129 hoisted
|
||
// `maxImpactedCount` / `maxRisk` here so a real caller could not hide
|
||
// behind the ambiguous zero, but the zero itself remained
|
||
// byte-identical to a genuine "nothing depends on this": a consumer
|
||
// testing `impactedCount === 0` still read a confident all-clear
|
||
// without ever looking at `candidates[]`. `null` cannot be mistaken for
|
||
// a measured zero, while `|| 0` consumers are unchanged (#2687).
|
||
impactedCount: null,
|
||
risk: 'UNKNOWN',
|
||
maxImpactedCount,
|
||
maxRisk,
|
||
...(probeFailed ? { partialProbe: true } : {}),
|
||
candidates: candidateSummaries,
|
||
};
|
||
}
|
||
|
||
const sym = {
|
||
id: outcome.symbol.id,
|
||
name: outcome.symbol.name,
|
||
filePath: outcome.symbol.filePath,
|
||
// Carry the resolved span so the PDG seed anchors on THIS symbol directly,
|
||
// without re-resolving its (possibly ambiguous) name (FIX 1).
|
||
startLine: outcome.symbol.startLine,
|
||
endLine: outcome.symbol.endLine,
|
||
};
|
||
const symType = outcome.resolvedLabel || outcome.symbol.type || '';
|
||
|
||
// (2) PDG-layer presence probe (U2, KTD7) — the four-state degradation
|
||
// contract after target resolution, before traversal. A repo never analyzed
|
||
// with `--pdg` (no-layer), one with only a partial layer (sub-layer-missing
|
||
// — impact needs BOTH CDG and REACHING_DEF), or one whose meta is unreadable
|
||
// (unknown) each returns a distinct guidance note here rather than a
|
||
// confusing empty blast radius. Resolve first so target-known degraded
|
||
// responses keep the same id/type/filePath envelope as successful PDG
|
||
// responses; only `ready` falls through to traversal.
|
||
// Hoisted so the (4) traversal branch can read `layer.hasCallSummary` (FU-C):
|
||
// the same single meta-stamp probe serves both the degradation gate and the
|
||
// ascent-availability note — no second probe.
|
||
let layer: PdgLayerStatus | undefined;
|
||
if (mode === 'pdg') {
|
||
layer = await pdgLayerStatus({
|
||
lbugPath: repo.lbugPath,
|
||
executeParameterized,
|
||
});
|
||
if (isPdgDegradedLayerStatus(layer)) {
|
||
// Degradation occurs AFTER target resolution → thread the FULL typed
|
||
// envelope ({ id, name, type, filePath }) so degraded responses keep the
|
||
// same target shape as a successful PDG result (typed PdgImpactTarget).
|
||
const degradedTarget: PdgImpactTarget = {
|
||
id: sym.id,
|
||
name: sym.name,
|
||
type: symType || 'Function',
|
||
filePath: sym.filePath,
|
||
};
|
||
return makePdgLayerDegradedResult({
|
||
mode,
|
||
layer,
|
||
target: degradedTarget,
|
||
direction,
|
||
});
|
||
}
|
||
}
|
||
|
||
const effectiveRelationTypes =
|
||
(symType === 'Class' || symType === 'Interface') &&
|
||
!hasExplicitRelationTypes &&
|
||
!relationTypes.includes('ACCESSES')
|
||
? [...relationTypes, 'ACCESSES']
|
||
: relationTypes;
|
||
|
||
// (4) single → route the resolved symbol to the engine selected by `mode`.
|
||
if (mode === 'pdg') {
|
||
const pdgResult = await this._runImpactPDG({
|
||
repo,
|
||
sym,
|
||
symType,
|
||
direction,
|
||
maxDepth,
|
||
// Use the normalized line, not raw params.line, so the gate and the
|
||
// engine share one source of truth (#2283). Identity in pdg mode today
|
||
// — effectiveLine === params.line when mode === 'pdg' — but this stays
|
||
// correct if the normalization ever stops being an identity here.
|
||
line: effectiveLine,
|
||
limit: Number.isFinite(params.limit) ? params.limit : 100,
|
||
// KTD2 extraction-seam discipline: hand the engine its DB dependency
|
||
// explicitly rather than `this.`-binding it. LocalBackend owns repo
|
||
// lifecycle; `pdg-impact.ts` owns traversal/projection.
|
||
executeParameterized,
|
||
// FU-C: thread the CALL_SUMMARY layer presence read above (meta stamp) so
|
||
// the engine notes "no return-value ascent (re-index)" on a pre-FU-C (v3)
|
||
// index. `layer.hasCallSummary` is set on every meta-readable state.
|
||
callSummaryAvailable: layer?.hasCallSummary === true,
|
||
});
|
||
|
||
// Statement-precise inter-procedural reach: a first-hop callee is "proven"
|
||
// iff it is invoked in a block of the criterion's dependence slice. The
|
||
// slice = the seed block(s) (the changed line itself) UNION the dependent
|
||
// reachable blocks — both carry the leaf callee names they call
|
||
// (`BasicBlock.callees`). The seed block is included because a callee
|
||
// invoked directly on the changed line is the most-directly-impacted one,
|
||
// yet `reachableBlocks` excludes the seed by the seed-minus-reachable
|
||
// convention. Upstream seeds carry no discriminating slice, so the bridge
|
||
// falls back to preserving callgraph reach.
|
||
// `_runImpactPDG` returns the PdgImpactResult union; only the success/empty
|
||
// slice results carry intraReachableBlocks/seedBlocks (degraded and error
|
||
// results do not). Narrow via the same discriminant the composer uses, then
|
||
// read the typed string[] slices — no `as any`.
|
||
const sliceResult = 'error' in pdgResult || 'pdgLayer' in pdgResult ? null : pdgResult;
|
||
// FIX 6: key the "first-hop proven" set on the INTRA-procedural slice only
|
||
// (seed ∪ intra-reachable), NOT `reachableBlocks` — which the U1 descent now
|
||
// EXPANDS with inter-procedurally-reached callee blocks. Using the expanded
|
||
// superset would mark transitively-reached (2+ hop) callgraph targets as
|
||
// first-hop "proven", silently shifting the established statementPrecision
|
||
// semantics. The interproc-reached blocks are routed into the statement
|
||
// slice / block→symbol projection inside `_runImpactPDG` only.
|
||
const intraReachableBlocks: string[] = sliceResult?.intraReachableBlocks ?? [];
|
||
const seedBlocks: string[] = sliceResult?.seedBlocks ?? [];
|
||
const sliceBlocks = [...seedBlocks, ...intraReachableBlocks];
|
||
const sliceCalleeNames =
|
||
direction === 'downstream' && sliceBlocks.length > 0
|
||
? await this.calleesOfBlocks(repo, sliceBlocks)
|
||
: new Set<string>();
|
||
// Resolved-id slice set (sound primary key, KTD3): unioned from the SAME
|
||
// seed ∪ reachable block set as the names — so a callee invoked only on the
|
||
// seeded line is still provable by id. Absent on a pre-v3 index (no
|
||
// `calleeIds` column) → empty → the bridge falls back to the leaf-name match.
|
||
const sliceCalleeIds =
|
||
direction === 'downstream' && sliceBlocks.length > 0
|
||
? await this.calleeIdsOfBlocks(repo, sliceBlocks)
|
||
: new Set<string>();
|
||
// Build the bridge when EITHER the name fallback or the id key has signal —
|
||
// an id-only index (names empty but ids present) must still seed the bridge.
|
||
const pdgBridge: PdgBridgeOptions | undefined =
|
||
sliceCalleeNames.size > 0 || sliceCalleeIds.size > 0
|
||
? { sliceCalleeNames, sliceCalleeIds }
|
||
: undefined;
|
||
|
||
try {
|
||
const interproceduralResult = await this._runImpactBFS(repo, sym, symType, direction, {
|
||
maxDepth,
|
||
relationTypes: effectiveRelationTypes,
|
||
includeTests,
|
||
minConfidence,
|
||
limit: Number.isFinite(params.limit) ? params.limit : 100,
|
||
offset: Number.isFinite(params.offset) ? params.offset : 0,
|
||
pdgBridge,
|
||
});
|
||
return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
|
||
} catch (e) {
|
||
logQueryError('impact:pdg-interprocedural-reach', e);
|
||
return composeUnifiedPdgImpactResult(pdgResult, null, e);
|
||
}
|
||
}
|
||
|
||
return this._runImpactBFS(repo, sym, symType, direction, {
|
||
maxDepth,
|
||
relationTypes: effectiveRelationTypes,
|
||
includeTests,
|
||
minConfidence,
|
||
limit: Number.isFinite(params.limit) ? params.limit : 100,
|
||
offset: Number.isFinite(params.offset) ? params.offset : 0,
|
||
summaryOnly: params.summaryOnly,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Union of the leaf callee names invoked across a set of dependence-slice
|
||
* blocks (`BasicBlock.callees`, space-joined at emit). Drives statement-precise
|
||
* inter-procedural evidence: a first-hop callee reached from the criterion is
|
||
* "proven" (callgraph-bridge) iff its name is in this set, else unproven-bridge.
|
||
* Empty when the slice blocks call nothing or carry no harvested callees
|
||
* (non-TS/JS or synthetic ENTRY/EXIT blocks) — the bridge then preserves
|
||
* callgraph reach. A query failure is logged and degrades to empty (no proof),
|
||
* never throws (the inter-procedural reach is still returned).
|
||
*/
|
||
private async calleesOfBlocks(repo: RepoHandle, blockIds: string[]): Promise<Set<string>> {
|
||
const names = new Set<string>();
|
||
if (blockIds.length === 0) return names;
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.callees AS callees`,
|
||
{ ids: blockIds },
|
||
);
|
||
for (const r of rows as any[]) {
|
||
const raw = String(r.callees ?? r[0] ?? '');
|
||
for (const n of raw.split(' ')) if (n) names.add(n);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:pdg-slice-callees', e);
|
||
}
|
||
return names;
|
||
}
|
||
|
||
/**
|
||
* Union of the RESOLVED callee symbol ids invoked across a set of
|
||
* dependence-slice blocks (`BasicBlock.calleeIds`, space-joined at emit —
|
||
* sibling of `callees`). This is the SOUND key the bridge prefers: a first-hop
|
||
* callee is proven statement-precise iff its resolved id is in this set, which
|
||
* eliminates the same-leaf-name collision (false-positive) and import-alias
|
||
* (false-negative) the name set cannot distinguish. Empty when the slice blocks
|
||
* carry no captured ids (pre-v3 index without the `calleeIds` column, or
|
||
* non-overloading/synthetic blocks) — the bridge then falls back to the
|
||
* leaf-name match per U5. A query failure is logged and degrades to empty (no
|
||
* proof), never throws (the inter-procedural reach is still returned). Mirrors
|
||
* `calleesOfBlocks` exactly — same shape, same swallow-on-error contract.
|
||
*/
|
||
private async calleeIdsOfBlocks(repo: RepoHandle, blockIds: string[]): Promise<Set<string>> {
|
||
const ids = new Set<string>();
|
||
if (blockIds.length === 0) return ids;
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.calleeIds AS calleeIds`,
|
||
{ ids: blockIds },
|
||
);
|
||
for (const r of rows) {
|
||
// Shared split-and-drop-sentinel logic (`splitCalleeIds`) so this bridge
|
||
// key and the inter-procedural descent cannot diverge. The sentinel marks
|
||
// a capped block (handled by the names-sentinel check in the bridge) and
|
||
// is not a resolved symbol id, so it never enters the `has(realId)` set.
|
||
for (const id of splitCalleeIds(r.calleeIds ?? r[0])) ids.add(id);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:pdg-slice-callee-ids', e);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/**
|
||
* Delegates the PDG impact engine to `pdg-impact.ts`.
|
||
*
|
||
* The private method remains as the LocalBackend dispatch seam so existing
|
||
* tests can keep asserting that `mode:'pdg'` routes through the PDG
|
||
* statement engine before LocalBackend attaches interprocedural symbol reach.
|
||
* The traversal/projection/result assembly lives in the extracted helper
|
||
* module.
|
||
*/
|
||
private async _runImpactPDG(deps: {
|
||
repo: RepoHandle;
|
||
sym: { id: string; name: string; filePath: string; startLine?: number; endLine?: number };
|
||
symType: string;
|
||
direction: 'upstream' | 'downstream';
|
||
maxDepth: number;
|
||
limit: number;
|
||
line?: number;
|
||
executeParameterized: typeof executeParameterized;
|
||
callSummaryAvailable?: boolean;
|
||
}): Promise<PdgImpactResult> {
|
||
return runImpactPDG(deps);
|
||
}
|
||
|
||
/**
|
||
* #1858 — epistemic lower-bound detection.
|
||
*
|
||
* impact()/context() traverse only edges materialized in the graph. When the
|
||
* queried symbol sits on an interface / abstract boundary, callers that bind
|
||
* to the interface via DI, a container, or dynamic dispatch — rather than
|
||
* naming the concrete symbol — are not traced. The reported count is then a
|
||
* lower bound, not an exact figure. Instead of returning a confident count
|
||
* that silently omits those callers, annotate the result with
|
||
* `epistemic: 'lower-bound'` plus a human-readable boundary note. A fully
|
||
* resolved leaf with no indirection stays `epistemic: 'exact'`.
|
||
*
|
||
* Aligns with the numeric confidence model rather than the long-deleted
|
||
* TIER_CONFIDENCE enum: the heritage/indirection edges this keys on
|
||
* (IMPLEMENTS / METHOD_IMPLEMENTS / EXTENDS) carry the 0.85
|
||
* `IMPACT_RELATION_CONFIDENCE` floor — "statically verifiable, but the
|
||
* concrete binding past it is not".
|
||
*
|
||
* Never throws: on query error it returns 'exact', so it can only add signal,
|
||
* never suppress a result.
|
||
*/
|
||
private async computeEpistemicBoundary(
|
||
repo: RepoHandle,
|
||
symId: string,
|
||
symType: string,
|
||
symName: string,
|
||
): Promise<{
|
||
epistemic: 'exact' | 'lower-bound';
|
||
boundaries?: string[];
|
||
causes?: EpistemicCauses;
|
||
}> {
|
||
const HERITAGE_TYPES = EPISTEMIC_HERITAGE_RELATION_TYPES;
|
||
const CONSUMER_TYPES = EPISTEMIC_CONSUMER_RELATION_TYPES;
|
||
// #2744 — call sites dropped for want of a receiver type. Checked BEFORE
|
||
// the heritage probe below and reported even when that probe finds nothing:
|
||
// the two are independent reasons a count can be short, and this one is the
|
||
// reason #2708 was filed. A dropped site's callee is unknown, so the index
|
||
// records the member NAME invoked at the drop; a match on the queried
|
||
// symbol's name means at least one call to something of that name was lost.
|
||
const droppedBoundaries = await this.unresolvedReceiverBoundaries(repo, symName);
|
||
try {
|
||
// Discover the interface / abstract supertypes on the target's boundary.
|
||
// If the target is itself an interface, it is its own boundary node.
|
||
const boundary = new Map<string, { name: string; label: string }>();
|
||
if (symType === 'Interface') {
|
||
boundary.set(symId, { name: symName || '', label: 'Interface' });
|
||
}
|
||
const ifaceRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (x)-[r:CodeRelation]->(iface)
|
||
WHERE x.id = $symId AND r.type IN $heritage
|
||
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
|
||
ORDER BY id
|
||
LIMIT 25`,
|
||
{ symId, heritage: HERITAGE_TYPES },
|
||
).catch(() => []);
|
||
for (const r of ifaceRows) {
|
||
const id = (r.id ?? r[0]) as string;
|
||
if (id && !boundary.has(id)) {
|
||
boundary.set(id, {
|
||
name: (r.name ?? r[1] ?? '') as string,
|
||
label: (r.label ?? r[2] ?? 'Interface') as string,
|
||
});
|
||
}
|
||
}
|
||
if (boundary.size === 0) return epistemicFrom(droppedBoundaries);
|
||
|
||
const ifaceIds = Array.from(boundary.keys());
|
||
// Count per interface id with scalar equality. A parameterized
|
||
// `iface.id IN $ids` combined with `COUNT(DISTINCT ...)` + implicit
|
||
// group-by returns no rows under the LadybugDB cypher subset, so query
|
||
// each boundary node individually (boundary is small — capped at 25).
|
||
const countByType = async (types: readonly string[]): Promise<Map<string, number>> => {
|
||
const m = new Map<string, number>();
|
||
await Promise.all(
|
||
ifaceIds.map(async (ifaceId) => {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`MATCH (other)-[r:CodeRelation]->(iface)
|
||
WHERE iface.id = $ifaceId AND r.type IN $types
|
||
RETURN COUNT(DISTINCT other.id) AS cnt`,
|
||
{ ifaceId, types },
|
||
).catch(() => []);
|
||
const cnt =
|
||
rows.length > 0 ? Number((rows[0] as any).cnt ?? (rows[0] as any)[0] ?? 0) : 0;
|
||
m.set(ifaceId, cnt);
|
||
}),
|
||
);
|
||
return m;
|
||
};
|
||
const [implCounts, consumerCounts] = await Promise.all([
|
||
countByType(HERITAGE_TYPES),
|
||
countByType(CONSUMER_TYPES),
|
||
]);
|
||
|
||
const boundaries: string[] = [];
|
||
// Magnitude, not note count: see `EpistemicCauses.dispatchBoundary`. One
|
||
// note can describe an interface with 40 implementations and hundreds of
|
||
// interface-level consumers, so publishing `boundaries.length` would put
|
||
// `1` next to a `receiverTyping` of `12` and tell a consumer branching on
|
||
// the numbers that receiver typing dominates — the opposite of the truth.
|
||
let dispatchBoundarySymbols = 0;
|
||
for (const [id, info] of boundary) {
|
||
const impls = implCounts.get(id) ?? 0;
|
||
const consumers = consumerCounts.get(id) ?? 0;
|
||
// Flag only a genuine indirection risk: an interface that is actually
|
||
// consumed (callers bind to it) or that has multiple implementations
|
||
// (runtime dispatch is ambiguous). A concrete type implementing an
|
||
// interface nothing references is fully traced → stays exact.
|
||
if (consumers >= 1 || impls >= 2) {
|
||
dispatchBoundarySymbols += impls + consumers;
|
||
const label = (info.label || 'Interface').toLowerCase();
|
||
const name = info.name || '(unnamed)';
|
||
const article = /^[aeiou]/.test(label) ? 'an' : 'a';
|
||
const parts: string[] = [];
|
||
if (impls >= 1)
|
||
parts.push(`${impls} ${impls === 1 ? 'implementation' : 'implementations'}`);
|
||
if (consumers >= 1)
|
||
parts.push(
|
||
`${consumers} interface-level ${consumers === 1 ? 'consumer' : 'consumers'}`,
|
||
);
|
||
boundaries.push(
|
||
`${name} is ${article} ${label} with ${parts.join(' and ')}; callers that bind via the ${label} ` +
|
||
`(e.g. a DI container or dynamic dispatch) are not traced to the concrete symbol — ` +
|
||
`actual impact may be higher.`,
|
||
);
|
||
}
|
||
}
|
||
if (boundaries.length === 0) return epistemicFrom(droppedBoundaries);
|
||
return {
|
||
epistemic: 'lower-bound',
|
||
boundaries: [...droppedBoundaries.notes, ...boundaries],
|
||
causes: {
|
||
receiverTyping: droppedBoundaries.sites,
|
||
dispatchBoundary: dispatchBoundarySymbols,
|
||
externalBoundary: droppedBoundaries.external,
|
||
},
|
||
};
|
||
} catch {
|
||
// Never let the heritage probe's failure suppress a drop we already know
|
||
// about — the whole point is that silence must not read as certainty.
|
||
return epistemicFrom(droppedBoundaries);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Boundary notes for call sites the analyzer dropped because it could not
|
||
* type their receiver, when the queried symbol's name is among them (#2744).
|
||
* Empty when the index records no drops for this name — including every
|
||
* index written before the summary existed, which is why the schema version
|
||
* was bumped rather than treating "absent" as "none".
|
||
*/
|
||
private async unresolvedReceiverBoundaries(
|
||
repo: RepoHandle,
|
||
symName: string,
|
||
): Promise<{ notes: string[]; sites: number; external: number }> {
|
||
if (symName.length === 0) return { notes: [], sites: 0, external: 0 };
|
||
try {
|
||
const meta = await loadMeta(path.dirname(repo.lbugPath));
|
||
const summary = meta?.unresolvedReceiverMembers;
|
||
// Prototype-safe: see `lookupUnresolvedCallCount`. A bare `counts[symName]`
|
||
// returns a Function for `constructor`/`toString`/… and `NaN <= 0` is false,
|
||
// so the old guard let it through into user-facing text.
|
||
const sites = lookupUnresolvedCallCount(summary, symName);
|
||
const external = lookupExternalCallCount(summary, symName) ?? 0;
|
||
if (sites === undefined) return { notes: [], sites: 0, external };
|
||
const notes = [
|
||
`${sites} call ${sites === 1 ? 'site' : 'sites'} invoking \`${symName}\` ${
|
||
sites === 1 ? 'was' : 'were'
|
||
} dropped at index time because the receiver's type could not be ` +
|
||
`established (e.g. an unresolved constructor, factory or chained ` +
|
||
`expression). Those callers are absent from this result — actual ` +
|
||
`impact may be higher.`,
|
||
];
|
||
return { notes, sites, external };
|
||
} catch {
|
||
return { notes: [], sites: 0, external: 0 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
|
||
*/
|
||
private async _runImpactBFS(
|
||
repo: RepoHandle,
|
||
sym: any,
|
||
symType: string,
|
||
direction: 'upstream' | 'downstream',
|
||
opts: {
|
||
maxDepth: number;
|
||
relationTypes: string[];
|
||
includeTests: boolean;
|
||
minConfidence: number;
|
||
limit?: number;
|
||
offset?: number;
|
||
summaryOnly?: boolean;
|
||
// Enrichment/annotation suppression flags (#1858/#2129 review F6). Each
|
||
// suppresses a distinct sub-phase; they compose, and the real call sites are:
|
||
// - full impact()/context(): none set.
|
||
// - group cross-repo fan-out (impactByUid): skipPerSymbolEnrichment +
|
||
// skipEpistemic — the fan-out consumes only byDepth.
|
||
// - ambiguous #2129 per-candidate probe: skipEpistemic + skipEnrichment —
|
||
// needs only count + a count-based risk.
|
||
// skipPerSymbolEnrichment: drop the post-pagination per-symbol
|
||
// STEP_IN_PROCESS pass (keeps byDepth).
|
||
// skipEpistemic: skip the #1858 interface/indirection boundary probe.
|
||
// skipEnrichment: skip the process/module aggregation passes entirely;
|
||
// risk then derives from directCount/total only. NOTE: this also makes
|
||
// skipPerSymbolEnrichment a no-op (affectedProcesses stays empty), which
|
||
// is why the ambiguous probe sets only the two flags above.
|
||
skipPerSymbolEnrichment?: boolean;
|
||
skipEpistemic?: boolean;
|
||
skipEnrichment?: boolean;
|
||
pdgBridge?: PdgBridgeOptions;
|
||
},
|
||
): Promise<any> {
|
||
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
|
||
const skipPerSymbolEnrichment = opts.skipPerSymbolEnrichment ?? false;
|
||
const skipEnrichment = opts.skipEnrichment ?? false;
|
||
const hasExplicitLimit = typeof opts.limit === 'number' && Number.isFinite(opts.limit);
|
||
const paginationLimit = hasExplicitLimit
|
||
? Math.max(1, Math.min(Math.trunc(opts.limit!), 10000))
|
||
: Infinity;
|
||
const rawOffset =
|
||
typeof opts.offset === 'number' && Number.isFinite(opts.offset) ? opts.offset : 0;
|
||
const paginationOffset = Math.max(0, Math.trunc(rawOffset));
|
||
const summaryOnly = opts.summaryOnly ?? false;
|
||
// Bind the BFS frontier query's filters as parameters (#1907 review F5):
|
||
// node ids and relation types as bound lists, the confidence floor as a
|
||
// bound number — no string interpolation reaches the query text. Preserve
|
||
// the original "no confidence clause when minConfidence <= 0" behavior: an
|
||
// unconditional `>= 0` would wrongly exclude NULL-confidence edges that the
|
||
// unfiltered query includes.
|
||
const safeMinConfidence = Number.isFinite(minConfidence) ? minConfidence : 0;
|
||
const confidenceFilter = safeMinConfidence > 0 ? ' AND r.confidence >= $minConfidence' : '';
|
||
|
||
const symId = sym.id || sym[0];
|
||
|
||
// #1858 — kick off the epistemic boundary probe concurrently with the BFS.
|
||
// It depends only on symId/symType/symName (all known now) and touches no
|
||
// shared state, so its extra round-trip overlaps the traversal instead of
|
||
// adding to the serial path. `skipEpistemic` (ambiguous #2129 candidate
|
||
// probes, group fan-out) resolves to no field, preserving prior behavior.
|
||
// #1858/#2129 review F8 — the skip case adds no field, so `epistemic` is
|
||
// optional here (the union's `{}` subtype). computeEpistemicBoundary's own
|
||
// return keeps `epistemic` REQUIRED — only this promise widens to the skip
|
||
// subtype.
|
||
// `causes` is part of the annotation, not just of the runtime value: the
|
||
// spread below is what publishes these fields, and a narrower annotation
|
||
// erases `causes` at the type level while still shipping it at runtime —
|
||
// so every consumer would be reading a field the compiler says is absent.
|
||
const epistemicPromise: Promise<{
|
||
epistemic?: 'exact' | 'lower-bound';
|
||
boundaries?: string[];
|
||
causes?: EpistemicCauses;
|
||
}> = opts.skipEpistemic
|
||
? Promise.resolve({})
|
||
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string);
|
||
const beanMetadataPromise =
|
||
opts.skipEpistemic || summaryOnly
|
||
? Promise.resolve(undefined)
|
||
: queryClassBeanMetadata(repo.lbugPath, symId, symType);
|
||
const aopMetadataPromise =
|
||
opts.skipEpistemic || summaryOnly
|
||
? Promise.resolve(undefined)
|
||
: querySpringAopMetadata(repo.lbugPath, symId, symType);
|
||
const impacted: any[] = [];
|
||
const visited = new Set<string>([symId]);
|
||
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
|
||
let frontier = [symId];
|
||
let traversalComplete = true;
|
||
|
||
// Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
|
||
// point to Constructor nodes and IMPORTS edges point to File nodes — not
|
||
// the Class/Interface itself. Seed the frontier with the Constructor(s)
|
||
// and owning File so the BFS traversal finds those edges naturally.
|
||
// The owning File is kept only as an internal seed (frontier/visited) and
|
||
// is NOT added to impacted — it is the definition container, not an
|
||
// upstream dependent. The BFS will discover IMPORTS edges on it naturally.
|
||
if (symType === 'Class' || symType === 'Interface') {
|
||
try {
|
||
// Run both seed queries in parallel — they are independent.
|
||
const [ctorRows, fileRows] = await Promise.all([
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[hm:CodeRelation]->(c:Constructor)
|
||
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
|
||
RETURN c.id AS id, c.name AS name, labels(c)[0] AS type, c.filePath AS filePath
|
||
`,
|
||
{ symId },
|
||
),
|
||
// Restrict to DEFINES edges only — other File->Class edge types (if
|
||
// any) should not be treated as the owning file relationship.
|
||
executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (f:File)-[rel:CodeRelation]->(n)
|
||
WHERE n.id = $symId AND rel.type = 'DEFINES'
|
||
RETURN f.id AS id, f.name AS name, labels(f)[0] AS type, f.filePath AS filePath
|
||
`,
|
||
{ symId },
|
||
),
|
||
]);
|
||
|
||
for (const r of ctorRows) {
|
||
const rid = r.id || r[0];
|
||
if (rid && !visited.has(rid)) {
|
||
visited.add(rid);
|
||
frontier.push(rid);
|
||
}
|
||
}
|
||
for (const r of fileRows) {
|
||
const rid = r.id || r[0];
|
||
if (rid && !visited.has(rid)) {
|
||
visited.add(rid);
|
||
frontier.push(rid);
|
||
}
|
||
}
|
||
|
||
const typedPropertyRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:\`Property\`)
|
||
WHERE p.declaredType = $name
|
||
OR p.declaredType STARTS WITH $genericPrefix
|
||
OR p.declaredType CONTAINS $genericArg
|
||
RETURN p.id AS id, p.name AS name, labels(p)[0] AS type, p.filePath AS filePath
|
||
`,
|
||
{
|
||
name: sym.name,
|
||
genericPrefix: `${sym.name}<`,
|
||
genericArg: `<${sym.name}>`,
|
||
},
|
||
);
|
||
|
||
for (const r of typedPropertyRows) {
|
||
const rid = r.id || r[0];
|
||
if (rid && !visited.has(rid)) {
|
||
visited.add(rid);
|
||
frontier.push(rid);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:class-node-expansion', e);
|
||
}
|
||
}
|
||
|
||
for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth++) {
|
||
const nextFrontier: string[] = [];
|
||
|
||
// Batch frontier nodes into a single Cypher query per depth level.
|
||
// ids/types/confidence are bound parameters (see above) — no interpolation.
|
||
//
|
||
// Deliberately NO `ORDER BY` (#2787). Every other ordered query in this
|
||
// file pairs its key with a small `LIMIT`, so the engine answers it from a
|
||
// bounded top-k heap and the ordering is nearly free. This one had no such
|
||
// escape: the engine had to materialize and fully sort EVERY neighbour edge
|
||
// of the whole frontier — tens of thousands of rows at depth 2 for a hub
|
||
// symbol — on a four-key comparator led by a long
|
||
// `Label:filePath:qualifiedName` string, dragging the wide `name`/
|
||
// `filePath` columns through the sort, once per depth level, on the tool
|
||
// that runs before every symbol edit.
|
||
//
|
||
// The ordering only ever served three POSITIONAL consumers of `impacted`:
|
||
// the relationType/confidence stamped on a node reached by more than one
|
||
// edge (the first row won); the process/module enrichment, which covers
|
||
// only `impacted.slice(0, MAX_CHUNKS * CHUNK_SIZE)` and feeds the risk
|
||
// thresholds; and byDepth pagination, which slices without re-sorting. All
|
||
// three need strictly less than a total sort of edges, so the ordering now
|
||
// lives in JS below, where the guarantee is STRONGER as well as cheaper:
|
||
// an engine's collation and sort stability are not ours to specify or
|
||
// version-pin, whereas `compareCodeUnits` is exactly UTF-16 code-unit
|
||
// order and `impactEdgeConfidenceRank` pins where a NULL confidence lands.
|
||
// Reproduced there, at O(E) plus a sort of NODES rather than of edges:
|
||
// * per reached id, an argmax under `relType ASC, confidence DESC,
|
||
// sourceId ASC` — what "first row wins" meant once the key led with
|
||
// `id` — via `compareImpactEdgeStrength`, and
|
||
// * `impacted` appended id-ascending over the distinct newly-visited
|
||
// nodes, via `compareImpactFrontierEdges`.
|
||
//
|
||
// `confidence DESC` is part of the key, not decoration (#2787 review F2):
|
||
// (id, relType) is NOT unique — 2181 of ~10020 groups on this repo's index
|
||
// carry more than one distinct confidence, and 0.7 vs 0.85 straddles the
|
||
// `< 0.8 = fuzzy` boundary the tool description publishes. Taking the
|
||
// strongest edge makes the stamped pair fully determined by the key AND
|
||
// retains the strongest evidence, the safer default for a blast-radius
|
||
// tool. `sourceId` closes the order for edges that tie on both.
|
||
const query =
|
||
direction === 'upstream'
|
||
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
|
||
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
|
||
|
||
try {
|
||
const related = await executeParameterized(repo.lbugPath, query, {
|
||
frontierIds: frontier,
|
||
relTypes: relationTypes,
|
||
...(safeMinConfidence > 0 ? { minConfidence: safeMinConfidence } : {}),
|
||
});
|
||
|
||
const edges: ImpactFrontierEdge[] = related.map((rel) => ({
|
||
id: rel.id || rel[1],
|
||
name: rel.name || rel[2],
|
||
type: rel.type || rel[3],
|
||
filePath: rel.filePath || rel[4] || '',
|
||
relType: rel.relType || rel[5],
|
||
confidence: rel.confidence ?? rel[6],
|
||
sourceId: String(rel.sourceId ?? rel[0] ?? ''),
|
||
}));
|
||
|
||
// The pdg bridge is the ONE consumer here that accumulates sequentially
|
||
// rather than per-node: at depth ≥ 2 an edge inherits
|
||
// `pdgBridgeEvidenceById.get(sourceId)` — a LIVE read of the very map
|
||
// this same pass writes under `id` — so an edge from one frontier node
|
||
// to another can upgrade that parent mid-pass, and every later edge out
|
||
// of the upgraded parent then inherits the stronger verdict. Row order
|
||
// is therefore observable on that path alone, and dropping the DB key
|
||
// without replacing it would have changed `mode:'pdg'` output. Give it
|
||
// the same key in JS instead. Bounded to that path on purpose: only
|
||
// `mode:'pdg'` (downstream, line-anchored) ever sets `pdgBridge`, so the
|
||
// hot `impact()`/`context()` traversal pays no sort at all.
|
||
if (opts.pdgBridge) edges.sort(compareImpactFrontierEdges);
|
||
|
||
// Argmax edge per newly-reached node, under the key the DB used to
|
||
// carry. Rebuilt per depth level, exactly like `nextFrontier`.
|
||
const bestEdgeByNode = new Map<string, ImpactFrontierEdge>();
|
||
|
||
for (const edge of edges) {
|
||
if (!includeTests && isTestFilePath(edge.filePath)) continue;
|
||
|
||
// Bridge evidence is computed for EVERY edge (not just the first to
|
||
// reach a node) and the strongest verdict across all parents is kept
|
||
// (`callgraph-bridge` wins). This makes a diamond-reachable node's
|
||
// proven/unproven label order-independent of which parent the BFS
|
||
// visits first; the final label is stamped onto the impacted items
|
||
// after the depth loop.
|
||
if (opts.pdgBridge) {
|
||
const ev = pdgBridgeEvidenceForImpact({
|
||
bridge: opts.pdgBridge,
|
||
depth,
|
||
calleeName: edge.name,
|
||
// Sound primary key (KTD3): the reached callee's RESOLVED id — the
|
||
// same `edge.id` (`rel.id`) the BFS keys its visited/frontier sets on,
|
||
// which equals the CALLS targetId captured into `BasicBlock.calleeIds`.
|
||
// The bridge proves by id ∈ `sliceCalleeIds` first, falling back to
|
||
// `calleeName` only when ids are absent or the block is capped.
|
||
calleeId: edge.id,
|
||
inherited: pdgBridgeEvidenceById.get(edge.sourceId),
|
||
});
|
||
pdgBridgeEvidenceById.set(
|
||
String(edge.id),
|
||
betterBridgeEvidence(pdgBridgeEvidenceById.get(String(edge.id)), ev),
|
||
);
|
||
}
|
||
|
||
// Nodes seeded or reached at an EARLIER depth contribute no new
|
||
// `impacted` entry. `visited` no longer grows inside this pass — it
|
||
// used to double as the "first row wins" argmax, a job
|
||
// `bestEdgeByNode` now does explicitly — so the guard reads the same
|
||
// set for every edge of the level, which is what it always meant.
|
||
if (visited.has(edge.id)) continue;
|
||
|
||
const incumbent = bestEdgeByNode.get(edge.id);
|
||
if (incumbent === undefined || compareImpactEdgeStrength(edge, incumbent) < 0) {
|
||
bestEdgeByNode.set(edge.id, edge);
|
||
}
|
||
}
|
||
|
||
for (const edge of [...bestEdgeByNode.values()].sort(compareImpactFrontierEdges)) {
|
||
visited.add(edge.id);
|
||
nextFrontier.push(edge.id);
|
||
const storedConfidence = edge.confidence;
|
||
const relationType = edge.relType;
|
||
// Prefer the stored confidence from the graph (set at analysis time);
|
||
// fall back to the per-type floor for edges without a stored value.
|
||
const effectiveConfidence =
|
||
typeof storedConfidence === 'number' && storedConfidence > 0
|
||
? storedConfidence
|
||
: confidenceForRelType(relationType);
|
||
// pdgEvidence is stamped after the depth loop from the finalized,
|
||
// order-independent pdgBridgeEvidenceById map.
|
||
impacted.push({
|
||
depth,
|
||
id: edge.id,
|
||
name: edge.name,
|
||
type: edge.type,
|
||
filePath: edge.filePath,
|
||
relationType,
|
||
confidence: effectiveConfidence,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:depth-traversal', e);
|
||
// Break out of depth loop on query failure but return partial results
|
||
// collected so far, rather than silently swallowing the error (#321)
|
||
traversalComplete = false;
|
||
break;
|
||
}
|
||
|
||
frontier = nextFrontier;
|
||
}
|
||
|
||
// Stamp the finalized, order-independent bridge evidence (strongest across
|
||
// all parents) onto each impacted item. Deferred from the BFS loop so a
|
||
// diamond-reachable node reflects a proven parent regardless of visit order.
|
||
if (opts.pdgBridge) {
|
||
for (const item of impacted as Array<Record<string, unknown>>) {
|
||
const ev = pdgBridgeEvidenceById.get(String(item.id));
|
||
if (ev) {
|
||
item.pdgEvidence = ev.evidence;
|
||
item.pdgBridgeBasis = ev.basis;
|
||
}
|
||
}
|
||
}
|
||
|
||
const grouped: Record<number, any[]> = {};
|
||
for (const item of impacted) {
|
||
if (!grouped[item.depth]) grouped[item.depth] = [];
|
||
grouped[item.depth].push(item);
|
||
}
|
||
|
||
// ── Enrichment: affected processes, modules, risk ──────────────
|
||
const directCount = (grouped[1] || []).length;
|
||
let affectedProcesses: any[] = [];
|
||
let affectedModules: any[] = [];
|
||
// Per-symbol process membership: maps impacted symbol id -> list of processes
|
||
// it participates in. Populated by a second chunked Cypher pass below when
|
||
// any process is affected at all. Surfaced as `processes: [...]` on each
|
||
// byDepth item so consumers can tell which caller belongs to which cron/
|
||
// webhook/route without a follow-up query.
|
||
const perSymbolProcesses = new Map<
|
||
string,
|
||
Array<{ id: string; label: string; processType: string; step: number }>
|
||
>();
|
||
|
||
// Chunking bounds for batched DB round-trips. Declared at function scope so
|
||
// both the in-block enrichment passes and the post-pagination per-symbol
|
||
// process enrichment can reference them.
|
||
const CHUNK_SIZE = 100;
|
||
// Max number of chunks to process to avoid unbounded DB round-trips.
|
||
// Configurable via env IMPACT_MAX_CHUNKS, default 10 => max items = 1000
|
||
const MAX_CHUNKS = parseInt(process.env.IMPACT_MAX_CHUNKS || '10', 10);
|
||
|
||
// `skipEnrichment` (ambiguous #2129 per-candidate probes) bypasses the
|
||
// process/module aggregation passes entirely — those probes need only the
|
||
// count + a count-based risk, so paying the bounded-but-real enrichment cost
|
||
// ~6× per ambiguous call is wasted. risk then derives from directCount /
|
||
// total only (processCount/moduleCount stay 0), an acceptable approximation
|
||
// for a disambiguation aid.
|
||
if (impacted.length > 0 && !skipEnrichment) {
|
||
// ── Process enrichment: batched chunking (bounded by MAX_CHUNKS) ─
|
||
// Uses merged Cypher query (WITH + OPTIONAL MATCH) to fetch
|
||
// process + entry point info in 1 round-trip per chunk. Converted to
|
||
// parameterized queries to avoid manual string escaping and long query strings.
|
||
const entryPointMap = new Map<
|
||
string,
|
||
{
|
||
name: string;
|
||
type: string;
|
||
filePath: string;
|
||
affected_process_count: number;
|
||
total_hits: number;
|
||
earliest_broken_step: number;
|
||
}
|
||
>();
|
||
|
||
// Map process id -> entryPointId to allow fixing missing minStep values later
|
||
const processToEntryPoint = new Map<string, string>();
|
||
// Collect process ids where MIN(r.step) returned null so we can retry in batch
|
||
const processesMissingMinStep = new Set<string>();
|
||
|
||
let chunksProcessed = 0;
|
||
for (
|
||
let i = 0;
|
||
i < impacted.length && chunksProcessed < MAX_CHUNKS;
|
||
i += CHUNK_SIZE, chunksProcessed++
|
||
) {
|
||
const chunk = impacted.slice(i, i + CHUNK_SIZE);
|
||
const ids = chunk.map((item) => String(item.id ?? ''));
|
||
|
||
try {
|
||
// Use parameterized list to avoid building long query strings
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
WHERE s.id IN $ids
|
||
WITH p, COUNT(DISTINCT s.id) AS hits, MIN(r.step) AS minStep
|
||
OPTIONAL MATCH (ep {id: p.entryPointId})
|
||
RETURN p.id AS pId, p.heuristicLabel AS name, p.processType AS processType,
|
||
p.entryPointId AS entryPointId, hits, minStep, p.stepCount AS stepCount,
|
||
ep.name AS epName, labels(ep)[0] AS epType, ep.filePath AS epFilePath
|
||
ORDER BY pId
|
||
`,
|
||
{ ids },
|
||
).catch(() => []);
|
||
|
||
for (const row of rows) {
|
||
const pId = row.pId ?? row[0];
|
||
const epId = row.entryPointId ?? row[3] ?? row.pId ?? row[0];
|
||
// Track mapping from process -> entryPoint so we can backfill missing minStep
|
||
if (pId) processToEntryPoint.set(String(pId), String(epId));
|
||
|
||
// Normalize epName: prefer epName, fall back to other columns, and
|
||
// ensure we don't keep an empty string (labels(...) can return "").
|
||
const epNameRaw = row.epName ?? row[7] ?? row.name ?? row[1] ?? 'unknown';
|
||
const epName =
|
||
typeof epNameRaw === 'string' && epNameRaw.trim().length > 0
|
||
? epNameRaw.trim()
|
||
: 'unknown';
|
||
|
||
// Normalize epType: labels(ep)[0] can return an empty string in
|
||
// some DBs (LadybugDB). Using nullish coalescing (??) preserves
|
||
// empty strings, which results in empty `type` values being
|
||
// propagated. Treat empty-string labels as missing and fall back
|
||
// to the next candidate or a sensible default.
|
||
const epTypeRaw = row.epType ?? row[8] ?? '';
|
||
const epType =
|
||
typeof epTypeRaw === 'string' && epTypeRaw.trim().length > 0
|
||
? epTypeRaw.trim()
|
||
: 'Function';
|
||
|
||
const epFilePath = row.epFilePath ?? row[9] ?? '';
|
||
const hits = row.hits ?? row[4] ?? 0;
|
||
const minStep = row.minStep ?? row[5];
|
||
// If the DB returned null for minStep, note the process id so we
|
||
// can run a follow-up query using a different aggregation strategy.
|
||
if (minStep === null || minStep === undefined) {
|
||
if (pId) processesMissingMinStep.add(String(pId));
|
||
}
|
||
if (!entryPointMap.has(epId)) {
|
||
entryPointMap.set(epId, {
|
||
name: epName,
|
||
type: epType,
|
||
filePath: epFilePath,
|
||
affected_process_count: 0,
|
||
total_hits: 0,
|
||
earliest_broken_step: Infinity,
|
||
});
|
||
}
|
||
const ep = entryPointMap.get(epId)!;
|
||
ep.affected_process_count += 1;
|
||
ep.total_hits += hits;
|
||
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep ?? Infinity);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:process-chunk', e);
|
||
}
|
||
}
|
||
|
||
// If some processes returned null minStep, try a batched follow-up query
|
||
// using the full impacted id set. This handles older indexes or DBs
|
||
// where MIN(r.step) can come back null even when step properties exist.
|
||
if (processesMissingMinStep.size > 0) {
|
||
try {
|
||
const pIds = Array.from(processesMissingMinStep);
|
||
const allImpactedIds = impacted.map((it) => String(it.id ?? ''));
|
||
const missingRows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
WHERE p.id IN $pIds AND s.id IN $ids
|
||
RETURN p.id AS pid, MIN(r.step) AS minStep
|
||
`,
|
||
{ pIds, ids: allImpactedIds },
|
||
).catch(() => []);
|
||
|
||
for (const mr of missingRows) {
|
||
const pid = mr.pid ?? mr[0];
|
||
const minStep = mr.minStep ?? mr[1];
|
||
const epId = processToEntryPoint.get(String(pid));
|
||
if (!epId) continue;
|
||
const ep = entryPointMap.get(epId);
|
||
if (!ep) continue;
|
||
if (typeof minStep === 'number') {
|
||
ep.earliest_broken_step = Math.min(ep.earliest_broken_step, minStep);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:process-chunk-backfill', e);
|
||
}
|
||
}
|
||
|
||
// If we capped chunks, mark traversal incomplete so caller knows results are partial
|
||
if (chunksProcessed * CHUNK_SIZE < impacted.length) {
|
||
traversalComplete = false;
|
||
}
|
||
|
||
// (total_hits, filePath, name) is NOT unique across distinct entry points —
|
||
// two of them collide on all three in this repo alone (`step`, same file,
|
||
// gitnexus-web/src/hooks/useSigma.ts) and equal `total_hits` is the norm —
|
||
// so ties fell through to `Map` insertion order, i.e. raw row order (#2787
|
||
// review F6). Sort the ENTRIES so the entry-point id (the map key) can close
|
||
// the order, then project: the id stays out of the response payload.
|
||
affectedProcesses = Array.from(entryPointMap.entries())
|
||
.sort(
|
||
([aId, a], [bId, b]) =>
|
||
b.total_hits - a.total_hits ||
|
||
compareCodeUnits(a.filePath, b.filePath) ||
|
||
compareCodeUnits(a.name, b.name) ||
|
||
compareCodeUnits(aId, bId),
|
||
)
|
||
.map(([, ep]) => ({
|
||
...ep,
|
||
earliest_broken_step:
|
||
ep.earliest_broken_step === Infinity ? null : ep.earliest_broken_step,
|
||
}));
|
||
|
||
// Per-symbol process membership is populated post-pagination (see below)
|
||
// so it covers exactly the symbols returned in byDepth, not a pre-capped
|
||
// flat slice that could miss depth-2+ symbols when depth-1 is large.
|
||
|
||
// ── Module enrichment: use same cap as process enrichment and parameterized queries
|
||
const maxItems = Math.min(impacted.length, MAX_CHUNKS * CHUNK_SIZE);
|
||
const cappedImpacted = impacted.slice(0, maxItems);
|
||
const allIdsArr = cappedImpacted.map((i: any) => String(i.id ?? ''));
|
||
const d1Items = (grouped[1] || []).slice(0, maxItems);
|
||
const d1IdsArr = d1Items.map((i: any) => String(i.id ?? ''));
|
||
|
||
// Chunked module enrichment: run the MEMBER_OF queries in chunks
|
||
// to avoid large single queries or concurrent Kuzu calls that can
|
||
// crash (SIGSEGV) on arm64 macOS; behavior preserves existing maxItems cap and returns equivalent aggregated results.
|
||
const moduleHitsMap = new Map<string, number>();
|
||
const directModuleSet = new Set<string>();
|
||
|
||
// Helper to run a single module chunk and accumulate hits by name
|
||
const runModuleChunk = async (idsChunk: string[]) => {
|
||
if (!idsChunk || idsChunk.length === 0) return;
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||
WHERE s.id IN $ids
|
||
RETURN c.heuristicLabel AS name, COUNT(DISTINCT s.id) AS hits
|
||
ORDER BY hits DESC, name
|
||
LIMIT 20
|
||
`,
|
||
{ ids: idsChunk },
|
||
).catch(() => []);
|
||
|
||
for (const r of rows) {
|
||
const name = r.name ?? r[0] ?? null;
|
||
const hits = (r.hits ?? r[1]) || 0;
|
||
if (!name) continue;
|
||
moduleHitsMap.set(name, (moduleHitsMap.get(name) || 0) + hits);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:module-chunk', e);
|
||
}
|
||
};
|
||
|
||
// Run module query chunks sequentially (safe on arm64 macOS)
|
||
for (let i = 0; i < allIdsArr.length; i += CHUNK_SIZE) {
|
||
const chunkIds = allIdsArr.slice(i, i + CHUNK_SIZE);
|
||
await runModuleChunk(chunkIds);
|
||
}
|
||
|
||
// Run direct module query similarly (distinct heuristic labels for depth-1 items)
|
||
const runDirectModuleChunk = async (idsChunk: string[]) => {
|
||
if (!idsChunk || idsChunk.length === 0) return;
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||
WHERE s.id IN $ids
|
||
RETURN DISTINCT c.heuristicLabel AS name
|
||
`,
|
||
{ ids: idsChunk },
|
||
).catch(() => []);
|
||
for (const r of rows) {
|
||
const name = r.name ?? r[0] ?? null;
|
||
if (name) directModuleSet.add(name);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:direct-module-chunk', e);
|
||
}
|
||
};
|
||
|
||
for (let i = 0; i < d1IdsArr.length; i += CHUNK_SIZE) {
|
||
const chunkIds = d1IdsArr.slice(i, i + CHUNK_SIZE);
|
||
await runDirectModuleChunk(chunkIds);
|
||
}
|
||
|
||
// Build final moduleRows array from aggregated hits map, sorted & limited
|
||
const moduleRows = Array.from(moduleHitsMap.entries())
|
||
.map(([name, hits]) => ({ name, hits }))
|
||
.sort((a, b) => b.hits - a.hits || compareCodeUnits(String(a.name), String(b.name)))
|
||
.slice(0, 20);
|
||
|
||
const directModuleRows = Array.from(directModuleSet).map((name) => ({ name }));
|
||
|
||
// Build affectedModules in the same shape as original implementation
|
||
const directModuleNameSet = new Set(directModuleRows.map((r: any) => r.name || r[0]));
|
||
affectedModules = moduleRows.map((r: any) => {
|
||
const name = r.name ?? r[0];
|
||
const hits = r.hits ?? r[1] ?? 0;
|
||
return {
|
||
name,
|
||
hits,
|
||
impact: directModuleNameSet.has(name) ? 'direct' : 'indirect',
|
||
};
|
||
});
|
||
}
|
||
|
||
// Risk scoring
|
||
const processCount = affectedProcesses.length;
|
||
const moduleCount = affectedModules.length;
|
||
let risk = 'LOW';
|
||
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impacted.length >= 200) {
|
||
risk = 'CRITICAL';
|
||
} else if (
|
||
directCount >= 15 ||
|
||
processCount >= 3 ||
|
||
moduleCount >= 3 ||
|
||
impacted.length >= 100
|
||
) {
|
||
risk = 'HIGH';
|
||
} else if (directCount >= 5 || impacted.length >= 30) {
|
||
risk = 'MEDIUM';
|
||
}
|
||
|
||
// Build per-depth counts (always included, even in summaryOnly mode)
|
||
const byDepthCounts: Record<number, number> = {};
|
||
for (const [depth, items] of Object.entries(grouped)) {
|
||
byDepthCounts[Number(depth)] = items.length;
|
||
}
|
||
|
||
// #1858 — await the epistemic boundary probe kicked off alongside the BFS
|
||
// above. Additive: leaves impactedCount and every existing field untouched.
|
||
const [epistemic, beanMetadata, aopMetadata] = await Promise.all([
|
||
epistemicPromise,
|
||
beanMetadataPromise,
|
||
aopMetadataPromise,
|
||
]);
|
||
|
||
const base = {
|
||
target: {
|
||
id: symId,
|
||
name: sym.name || sym[1],
|
||
type: symType,
|
||
filePath: sym.filePath || sym[2],
|
||
...(beanMetadata ? { bean: beanMetadata } : {}),
|
||
...(aopMetadata ? { aop: aopMetadata } : {}),
|
||
},
|
||
direction,
|
||
impactedCount: impacted.length,
|
||
risk,
|
||
...epistemic,
|
||
...(!traversalComplete && { partial: true }),
|
||
summary: {
|
||
direct: directCount,
|
||
processes_affected: processCount,
|
||
modules_affected: moduleCount,
|
||
},
|
||
byDepthCounts,
|
||
affected_processes: affectedProcesses,
|
||
affected_modules: affectedModules,
|
||
};
|
||
|
||
if (summaryOnly) {
|
||
return base;
|
||
}
|
||
|
||
// Apply limit/offset pagination per depth level.
|
||
const paginatedGrouped: Record<number, any[]> = {};
|
||
let anyTruncated = false;
|
||
for (const [depth, items] of Object.entries(grouped)) {
|
||
const total = items.length;
|
||
const sliced = items.slice(paginationOffset, paginationOffset + paginationLimit);
|
||
paginatedGrouped[Number(depth)] = sliced;
|
||
if (paginationOffset > 0 || paginationOffset + paginationLimit < total) {
|
||
anyTruncated = true;
|
||
}
|
||
}
|
||
|
||
// ── Per-symbol process membership enrichment (post-pagination) ───────
|
||
// Runs after paginatedGrouped is built so we enrich only the IDs that
|
||
// actually appear in the response. This eliminates the false-empty
|
||
// processes:[] case where a depth-2+ symbol's flat position in `impacted`
|
||
// exceeded MAX_CHUNKS*CHUNK_SIZE even though it is returned by byDepth.
|
||
// Also uses DISTINCT + MIN(r.step) per (symbol, process) pair to avoid
|
||
// duplicate entries when a symbol has multiple STEP_IN_PROCESS edges.
|
||
// Skipped entirely when `skipPerSymbolEnrichment` is set (group cross-repo
|
||
// fan-out, which consumes byDepth but not byDepth[].processes); the
|
||
// attach-loop below still stamps an empty processes:[] for shape stability.
|
||
let perSymbolEnrichmentCapped = false;
|
||
if (affectedProcesses.length > 0 && !skipPerSymbolEnrichment) {
|
||
// Collect unique IDs from the paginated result in one pass.
|
||
const pageIds = new Set<string>();
|
||
for (const items of Object.values(paginatedGrouped)) {
|
||
for (const it of items) {
|
||
const id = String(it.id ?? '');
|
||
if (id) pageIds.add(id);
|
||
}
|
||
}
|
||
// Bound the enrichment to the same ceiling as the aggregation pass
|
||
// (MAX_CHUNKS * CHUNK_SIZE) so a large paginated page cannot trigger
|
||
// unbounded DB round-trips (DoD 2.6). When capped, mark the result
|
||
// partial so callers know some returned symbols may carry an empty
|
||
// processes:[] that is a cap artifact, not a true absence.
|
||
const maxPageIds = MAX_CHUNKS * CHUNK_SIZE;
|
||
let pageIdArr = Array.from(pageIds);
|
||
if (pageIdArr.length > maxPageIds) {
|
||
pageIdArr = pageIdArr.slice(0, maxPageIds);
|
||
perSymbolEnrichmentCapped = true;
|
||
}
|
||
for (let i = 0; i < pageIdArr.length; i += CHUNK_SIZE) {
|
||
const chunkIds = pageIdArr.slice(i, i + CHUNK_SIZE);
|
||
try {
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||
WHERE s.id IN $ids
|
||
RETURN s.id AS sid, p.id AS pid, p.heuristicLabel AS pName,
|
||
p.processType AS pType, MIN(r.step) AS step
|
||
`,
|
||
{ ids: chunkIds },
|
||
).catch(() => []);
|
||
for (const row of rows) {
|
||
const sid = row.sid ?? row[0];
|
||
if (!sid) continue;
|
||
const procEntry = {
|
||
id: String(row.pid ?? row[1] ?? ''),
|
||
label: String(row.pName ?? row[2] ?? ''),
|
||
processType: String(row.pType ?? row[3] ?? ''),
|
||
step: Number(row.step ?? row[4] ?? -1),
|
||
};
|
||
const list = perSymbolProcesses.get(String(sid));
|
||
if (list) list.push(procEntry);
|
||
else perSymbolProcesses.set(String(sid), [procEntry]);
|
||
}
|
||
} catch (e) {
|
||
logQueryError('impact:per-symbol-process-chunk', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Attach processes field to each paginated item.
|
||
for (const items of Object.values(paginatedGrouped)) {
|
||
for (const it of items) {
|
||
it.processes = perSymbolProcesses.get(String(it.id)) ?? [];
|
||
}
|
||
}
|
||
|
||
return {
|
||
...base,
|
||
// Surface partial if the per-symbol enrichment was capped, even when the
|
||
// BFS traversal itself completed — some returned symbols may carry an
|
||
// empty processes:[] that is a cap artifact rather than a true absence.
|
||
...(perSymbolEnrichmentCapped && { partial: true }),
|
||
...(anyTruncated && {
|
||
pagination: {
|
||
...(Number.isFinite(paginationLimit) && { limit: paginationLimit }),
|
||
offset: paginationOffset,
|
||
truncated: true,
|
||
},
|
||
}),
|
||
byDepth: paginatedGrouped,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* UID-based impact for cross-repo fan-out. Same result shape as `impact`.
|
||
* Returns null if the repo is unknown, the UID is missing, or analysis fails.
|
||
*/
|
||
async impactByUid(
|
||
repoId: string,
|
||
uid: string,
|
||
direction: string,
|
||
opts: {
|
||
maxDepth: number;
|
||
relationTypes: string[];
|
||
minConfidence: number;
|
||
includeTests: boolean;
|
||
signal?: AbortSignal;
|
||
},
|
||
): Promise<any | null> {
|
||
// Honor an already-aborted signal at the entry boundary as a fast
|
||
// path. Cooperative cancellation inside _runImpactBFS is out of
|
||
// scope — the caller's Promise.race against the same signal
|
||
// resolves the await regardless of how long this body runs.
|
||
if (opts.signal?.aborted) return null;
|
||
let repo: RepoHandle | undefined;
|
||
try {
|
||
await this.refreshRepos();
|
||
// Fetch the resolved handle BEFORE init and pass it through, so a
|
||
// concurrent refresh can't remap the id to a different clone (#2067).
|
||
repo = this.repos.get(repoId);
|
||
if (repo) await this.ensureInitialized(repo);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!repo) return null; // unknown repo → null (preserves contract)
|
||
|
||
const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream';
|
||
|
||
let rows: any[];
|
||
try {
|
||
// determinism: probe — PK-anchored singleton. $uid is a node primary key, so at most one row can match.
|
||
rows = await executeParameterized(
|
||
repo.lbugPath, // pool keyed by the resolved clone's path, not the id
|
||
`MATCH (n) WHERE n.id = $uid
|
||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type
|
||
LIMIT 1`,
|
||
{ uid },
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!rows?.length) return null;
|
||
|
||
const sym = rows[0];
|
||
const labelRaw = sym.type ?? sym[3];
|
||
const symType =
|
||
typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : '';
|
||
|
||
// Map legacy relation type names (backward compat for OVERRIDES → METHOD_OVERRIDES)
|
||
const mappedRelTypes = opts.relationTypes?.flatMap((t: string) =>
|
||
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
|
||
);
|
||
const rawRelTypes =
|
||
mappedRelTypes && mappedRelTypes.length > 0
|
||
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
|
||
: [
|
||
'CALLS',
|
||
'IMPORTS',
|
||
'EXTENDS',
|
||
'IMPLEMENTS',
|
||
'METHOD_OVERRIDES',
|
||
'OVERRIDES',
|
||
'METHOD_IMPLEMENTS',
|
||
];
|
||
const relationTypes =
|
||
rawRelTypes.length > 0
|
||
? rawRelTypes
|
||
: [
|
||
'CALLS',
|
||
'IMPORTS',
|
||
'EXTENDS',
|
||
'IMPLEMENTS',
|
||
'METHOD_OVERRIDES',
|
||
'OVERRIDES',
|
||
'METHOD_IMPLEMENTS',
|
||
];
|
||
|
||
try {
|
||
// skipPerSymbolEnrichment suppresses ONLY the per-symbol STEP_IN_PROCESS
|
||
// enrichment pass while preserving byDepth. Group-mode cross-repo fan-out
|
||
// may fan across many repos; the per-symbol pass adds up to MAX_CHUNKS
|
||
// extra round-trips per repo, which is unacceptable at group scale. But
|
||
// cross-impact fan-out DOES consume byDepth (cross-impact.ts reads
|
||
// fan.byDepth to populate group by_depth), so summaryOnly would wrongly
|
||
// drop it. Group callers do not consume byDepth[].processes, so skipping
|
||
// only that enrichment is the correct, targeted suppression.
|
||
return await this._runImpactBFS(repo, sym, symType, dir, {
|
||
maxDepth: opts.maxDepth,
|
||
relationTypes,
|
||
includeTests: opts.includeTests,
|
||
minConfidence: opts.minConfidence,
|
||
skipPerSymbolEnrichment: true,
|
||
// Group cross-repo fan-out consumes only byDepth (cross-impact.ts), not
|
||
// the #1858 epistemic/boundaries fields — computing them per neighbor is
|
||
// dead work on the highest-volume path, so suppress them here too.
|
||
skipEpistemic: true,
|
||
});
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private handleGroupTool(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||
switch (method) {
|
||
case 'group_list':
|
||
return this.groupList(params);
|
||
case 'group_sync':
|
||
return this.groupSync(params);
|
||
default:
|
||
throw new Error(
|
||
`Unknown group tool: ${method}. Removed tools: use repo "@<groupName>" on impact, query, or context (optional "/<memberPath>"), or MCP resources.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dispatch impact/query/context when `repo` is `@groupName` or `@groupName/memberPath`
|
||
* (group mode — not the global indexed-repo `repo` parameter).
|
||
*/
|
||
private async callToolAtGroupRepo(
|
||
method: string,
|
||
params: Record<string, unknown>,
|
||
): Promise<unknown> {
|
||
await this.refreshRepos();
|
||
|
||
if (
|
||
params.service !== undefined &&
|
||
params.service !== null &&
|
||
String(params.service).trim() === ''
|
||
) {
|
||
return { error: 'service must not be an empty string' };
|
||
}
|
||
|
||
const raw = String(params.repo).slice(1);
|
||
const slash = raw.indexOf('/');
|
||
const groupName = (slash === -1 ? raw : raw.slice(0, slash)).trim();
|
||
const memberRest = slash === -1 ? undefined : raw.slice(slash + 1).trim() || undefined;
|
||
|
||
const resolved = await resolveAtGroupMemberRepoPath(groupName, memberRest);
|
||
if (resolved.ok === false) return { error: resolved.error };
|
||
|
||
const svc = this.getGroupService();
|
||
if (method === 'trace') {
|
||
// Cross-repo trace resolves `from`/`to` across ALL members (it does not
|
||
// anchor on a single member like impact/query/context), so the member
|
||
// path in `@group/path` is advisory here — `resolved` above still
|
||
// validates that the group exists. groupTrace owns cross-member
|
||
// resolution and the single-boundary bridge crossing.
|
||
const traceArgs: Record<string, unknown> = { name: groupName };
|
||
if (params.from !== undefined) traceArgs.from = params.from;
|
||
if (params.to !== undefined) traceArgs.to = params.to;
|
||
if (params.from_uid !== undefined) traceArgs.from_uid = params.from_uid;
|
||
if (params.to_uid !== undefined) traceArgs.to_uid = params.to_uid;
|
||
if (params.from_file !== undefined) traceArgs.from_file = params.from_file;
|
||
if (params.to_file !== undefined) traceArgs.to_file = params.to_file;
|
||
if (params.maxDepth !== undefined) traceArgs.maxDepth = params.maxDepth;
|
||
if (params.crossDepth !== undefined) traceArgs.crossDepth = params.crossDepth;
|
||
if (params.includeTests !== undefined) traceArgs.includeTests = params.includeTests;
|
||
if (params.pdg !== undefined) traceArgs.pdg = params.pdg;
|
||
if (params.limit !== undefined) traceArgs.limit = params.limit;
|
||
return svc.groupTrace(traceArgs);
|
||
}
|
||
if (method === 'impact') {
|
||
// KTD5/KTD12 — validate `mode` at the group-forward boundary too (the
|
||
// JSON-schema enum is advisory). An invalid mode errors; `mode:'pdg'` is
|
||
// rejected for @group targets because PDG impact is single-repo and
|
||
// intra-procedural — there is no cross-repo dependence graph to walk.
|
||
// Rejecting here (before groupImpact) is the KTD12 @group hard error.
|
||
const groupModeResult = validateImpactMode(params.mode);
|
||
if ('error' in groupModeResult) return { error: groupModeResult.error };
|
||
if (groupModeResult.mode === 'pdg') {
|
||
// @group reject: no single-repo symbol is ever resolved on the group path,
|
||
// so the envelope carries the partial-but-typed target (PdgImpactTarget).
|
||
// Routed through the typed builder so this exit is a PdgImpactResult union
|
||
// member, never a bare { error } object.
|
||
const groupRejectTarget: PdgImpactTarget = { name: String(params.target ?? '') };
|
||
const pdgErr: PdgImpactErrorResult = makePdgImpactErrorResult({
|
||
mode: 'pdg',
|
||
error:
|
||
"mode:'pdg' is not supported for @group targets — PDG impact is " +
|
||
'single-repo and intra-procedural. Run pdg impact against an ' +
|
||
'individual indexed repository instead.',
|
||
target: groupRejectTarget,
|
||
direction: (params.direction === 'downstream' ? 'downstream' : 'upstream') as
|
||
| 'upstream'
|
||
| 'downstream',
|
||
});
|
||
return pdgErr;
|
||
}
|
||
const impactArgs: Record<string, unknown> = {
|
||
name: groupName,
|
||
repo: resolved.repoPath,
|
||
target: params.target,
|
||
direction: params.direction,
|
||
};
|
||
if (params.maxDepth !== undefined) impactArgs.maxDepth = params.maxDepth;
|
||
if (params.crossDepth !== undefined) impactArgs.crossDepth = params.crossDepth;
|
||
if (params.relationTypes !== undefined) impactArgs.relationTypes = params.relationTypes;
|
||
if (params.includeTests !== undefined) impactArgs.includeTests = params.includeTests;
|
||
if (params.minConfidence !== undefined) impactArgs.minConfidence = params.minConfidence;
|
||
if (params.service !== undefined && params.service !== null)
|
||
impactArgs.service = params.service;
|
||
if (typeof params.subgroup === 'string') impactArgs.subgroup = params.subgroup;
|
||
if (params.timeoutMs !== undefined) impactArgs.timeoutMs = params.timeoutMs;
|
||
if (params.timeout !== undefined) impactArgs.timeout = params.timeout;
|
||
// limit/offset/summaryOnly are not forwarded to group-mode impact:
|
||
// runGroupImpact uses GROUP_LOCAL_PHASE_LIMIT internally for UID
|
||
// collection and does not re-paginate the local result yet.
|
||
return svc.groupImpact(impactArgs);
|
||
}
|
||
if (method === 'query') {
|
||
const queryArgs: Record<string, unknown> = {
|
||
name: groupName,
|
||
// #2175: resolve the search_query alias here (new name wins, same rule as the
|
||
// local query() handler) so the group path is self-contained and does not depend
|
||
// on params being normalized upstream. groupQuery() reads `query`.
|
||
query: resolveAliasString(params.search_query, params.query),
|
||
};
|
||
if (typeof params.task_context === 'string') queryArgs.task_context = params.task_context;
|
||
if (typeof params.goal === 'string') queryArgs.goal = params.goal;
|
||
if (typeof params.limit === 'number') queryArgs.limit = params.limit;
|
||
if (typeof params.max_symbols === 'number') queryArgs.max_symbols = params.max_symbols;
|
||
if (params.include_content !== undefined) queryArgs.include_content = params.include_content;
|
||
if (params.service !== undefined && params.service !== null)
|
||
queryArgs.service = params.service;
|
||
if (memberRest !== undefined) {
|
||
queryArgs.subgroup = memberRest;
|
||
queryArgs.subgroupExact = true;
|
||
}
|
||
return svc.groupQuery(queryArgs);
|
||
}
|
||
if (method === 'context') {
|
||
const targetSym =
|
||
typeof params.target === 'string' && params.target.trim() !== ''
|
||
? params.target.trim()
|
||
: typeof params.name === 'string' && params.name.trim() !== ''
|
||
? params.name.trim()
|
||
: undefined;
|
||
const contextArgs: Record<string, unknown> = {
|
||
name: groupName,
|
||
target: targetSym,
|
||
};
|
||
if (typeof params.uid === 'string') contextArgs.uid = params.uid;
|
||
if (typeof params.file_path === 'string') contextArgs.file_path = params.file_path;
|
||
if (params.include_content !== undefined)
|
||
contextArgs.include_content = params.include_content;
|
||
if (params.service !== undefined && params.service !== null)
|
||
contextArgs.service = params.service;
|
||
if (memberRest !== undefined) {
|
||
contextArgs.subgroup = memberRest;
|
||
contextArgs.subgroupExact = true;
|
||
}
|
||
return svc.groupContext(contextArgs);
|
||
}
|
||
throw new Error(`Internal: unsupported group-repo tool ${method}`);
|
||
}
|
||
|
||
private async groupList(params: Record<string, unknown>): Promise<unknown> {
|
||
return this.getGroupService().groupList(params);
|
||
}
|
||
|
||
private async groupSync(params: Record<string, unknown>): Promise<unknown> {
|
||
return this.getGroupService().groupSync(params);
|
||
}
|
||
|
||
/**
|
||
* MCP resource body for `gitnexus://group/{name}/contracts` (Issue #794).
|
||
*/
|
||
async readGroupContractsResource(
|
||
groupName: string,
|
||
filter: { type?: string; repo?: string; unmatchedOnly?: boolean },
|
||
): Promise<string> {
|
||
try {
|
||
const params: Record<string, unknown> = { name: groupName };
|
||
if (filter.type !== undefined) params.type = filter.type;
|
||
if (filter.repo !== undefined) params.repo = filter.repo;
|
||
if (filter.unmatchedOnly === true) params.unmatchedOnly = true;
|
||
const raw = await this.getGroupService().groupContracts(params);
|
||
return LocalBackend.formatGroupResourcePayload(raw);
|
||
} catch (e) {
|
||
return `error: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* MCP resource body for `gitnexus://group/{name}/status` (Issue #794).
|
||
*/
|
||
async readGroupStatusResource(groupName: string): Promise<string> {
|
||
try {
|
||
const raw = await this.getGroupService().groupStatus({ name: groupName });
|
||
return LocalBackend.formatGroupResourcePayload(raw);
|
||
} catch (e) {
|
||
return `error: ${e instanceof Error ? e.message : String(e)}`;
|
||
}
|
||
}
|
||
|
||
private static formatGroupResourcePayload(raw: unknown): string {
|
||
if (raw && typeof raw === 'object' && 'error' in raw) {
|
||
const err = (raw as { error?: unknown }).error;
|
||
if (typeof err === 'string' && err.length > 0) {
|
||
return `error: ${err}`;
|
||
}
|
||
}
|
||
return JSON.stringify(raw, null, 2);
|
||
}
|
||
|
||
/**
|
||
* Fetch Route nodes with their consumers in a single query.
|
||
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
|
||
*/
|
||
private async fetchRoutesWithConsumers(
|
||
repoId: string,
|
||
routeFilter: string,
|
||
params: Record<string, string>,
|
||
): Promise<
|
||
Array<{
|
||
id: string;
|
||
name: string;
|
||
method: string | null;
|
||
filePath: string;
|
||
responseKeys: string[] | null;
|
||
errorKeys: string[] | null;
|
||
middleware: string[] | null;
|
||
consumers: Array<{
|
||
name: string;
|
||
filePath: string;
|
||
accessedKeys?: string[];
|
||
fetchCount?: number;
|
||
}>;
|
||
}>
|
||
> {
|
||
const rows = await executeParameterized(
|
||
repoId,
|
||
`
|
||
MATCH (n:Route)
|
||
WHERE n.id STARTS WITH 'Route:' ${routeFilter}
|
||
OPTIONAL MATCH (consumer)-[r:CodeRelation]->(n)
|
||
WHERE r.type = 'FETCHES'
|
||
RETURN n.id AS routeId, n.name AS routeName, n.filePath AS handlerFile,
|
||
n.responseKeys AS responseKeys, n.errorKeys AS errorKeys, n.middleware AS middleware,
|
||
consumer.name AS consumerName, consumer.filePath AS consumerFile,
|
||
r.reason AS fetchReason, n.method AS method
|
||
`,
|
||
params,
|
||
);
|
||
|
||
// Strip wrapping quotes from DB array elements — CSV COPY stores ['key'] which
|
||
// LadybugDB may return as "'key'" rather than "key"
|
||
const stripQuotes = (keys: string[] | null): string[] | null =>
|
||
keys ? keys.map((k) => k.replace(/^['"]|['"]$/g, '')) : null;
|
||
|
||
const routeMap = new Map<
|
||
string,
|
||
{
|
||
id: string;
|
||
name: string;
|
||
method: string | null;
|
||
filePath: string;
|
||
responseKeys: string[] | null;
|
||
errorKeys: string[] | null;
|
||
middleware: string[] | null;
|
||
consumers: Array<{
|
||
name: string;
|
||
filePath: string;
|
||
accessedKeys?: string[];
|
||
fetchCount?: number;
|
||
}>;
|
||
}
|
||
>();
|
||
for (const row of rows) {
|
||
const id = row.routeId ?? row[0];
|
||
const name = row.routeName ?? row[1];
|
||
const filePath = row.handlerFile ?? row[2];
|
||
const responseKeys = stripQuotes(row.responseKeys ?? row[3] ?? null);
|
||
const errorKeys = stripQuotes(row.errorKeys ?? row[4] ?? null);
|
||
const middleware = stripQuotes(row.middleware ?? row[5] ?? null);
|
||
const consumerName = row.consumerName ?? row[6];
|
||
const consumerFile = row.consumerFile ?? row[7];
|
||
const fetchReason: string | null = row.fetchReason ?? row[8] ?? null;
|
||
// Verb is the literal '*' for method-agnostic routes (Django function
|
||
// views) and absent (null) for method-less routes (filesystem, Laravel
|
||
// resource). Appended last in RETURN so positional fallbacks for the
|
||
// consumer/reason columns above stay stable.
|
||
const method: string | null = row.method ?? row[9] ?? null;
|
||
|
||
if (!routeMap.has(id)) {
|
||
routeMap.set(id, {
|
||
id,
|
||
name,
|
||
method,
|
||
filePath,
|
||
responseKeys,
|
||
errorKeys,
|
||
middleware,
|
||
consumers: [],
|
||
});
|
||
}
|
||
if (consumerName && consumerFile) {
|
||
// Parse accessed keys from reason field: "fetch-url-match|keys:data,pagination|fetches:3"
|
||
let accessedKeys: string[] | undefined;
|
||
let fetchCount: number | undefined;
|
||
if (fetchReason) {
|
||
const keysMatch = fetchReason.match(/\|keys:([^|]+)/);
|
||
if (keysMatch) {
|
||
accessedKeys = keysMatch[1].split(',').filter((k) => k.length > 0);
|
||
}
|
||
const fetchesMatch = fetchReason.match(/\|fetches:(\d+)/);
|
||
if (fetchesMatch) {
|
||
fetchCount = parseInt(fetchesMatch[1], 10);
|
||
}
|
||
}
|
||
routeMap.get(id)!.consumers.push({
|
||
name: consumerName,
|
||
filePath: consumerFile,
|
||
...(accessedKeys ? { accessedKeys } : {}),
|
||
...(fetchCount && fetchCount > 1 ? { fetchCount } : {}),
|
||
});
|
||
}
|
||
}
|
||
|
||
return [...routeMap.values()];
|
||
}
|
||
|
||
/**
|
||
* Batch-fetch execution flows linked to a set of Route or Tool nodes.
|
||
* Single query instead of N+1.
|
||
*/
|
||
private async fetchLinkedFlowsBatch(
|
||
repoId: string,
|
||
nodeIds: string[],
|
||
): Promise<Map<string, string[]>> {
|
||
const result = new Map<string, string[]>();
|
||
if (nodeIds.length === 0) return result;
|
||
try {
|
||
// Use list_contains to filter at DB level instead of fetching all and filtering in memory
|
||
const rows = await executeParameterized(
|
||
repoId,
|
||
`
|
||
MATCH (source)-[r:CodeRelation]->(proc:Process)
|
||
WHERE r.type = 'ENTRY_POINT_OF'
|
||
AND list_contains($nodeIds, source.id)
|
||
RETURN source.id AS sourceId, proc.label AS name
|
||
`,
|
||
{ nodeIds },
|
||
);
|
||
for (const row of rows) {
|
||
const sourceId = row.sourceId ?? row[0];
|
||
const name = row.name ?? row[1];
|
||
if (!name) continue;
|
||
let list = result.get(sourceId);
|
||
if (!list) {
|
||
list = [];
|
||
result.set(sourceId, list);
|
||
}
|
||
list.push(name);
|
||
}
|
||
} catch {
|
||
/* no ENTRY_POINT_OF edges yet */
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private async routeMap(repo: RepoHandle, params: { route?: string }): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
||
const queryParams = params.route ? { route: params.route } : {};
|
||
const routes = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
||
|
||
if (routes.length === 0) {
|
||
return {
|
||
routes: [],
|
||
total: 0,
|
||
message: params.route
|
||
? `No routes matching "${params.route}"`
|
||
: 'No routes found in this project.',
|
||
};
|
||
}
|
||
|
||
const flowMap = await this.fetchLinkedFlowsBatch(
|
||
repo.lbugPath,
|
||
routes.map((r) => r.id),
|
||
);
|
||
|
||
return {
|
||
routes: routes.map((r) => ({
|
||
route: r.name,
|
||
method: r.method,
|
||
handler: r.filePath,
|
||
middleware: r.middleware || [],
|
||
consumers: r.consumers,
|
||
flows: flowMap.get(r.id) || [],
|
||
})),
|
||
total: routes.length,
|
||
};
|
||
}
|
||
|
||
private async shapeCheck(repo: RepoHandle, params: { route?: string }): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const routeFilter = params.route ? `AND n.name CONTAINS $route` : '';
|
||
const queryParams = params.route ? { route: params.route } : {};
|
||
const allRoutes = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
||
|
||
const results = allRoutes
|
||
.filter(
|
||
(r) =>
|
||
((r.responseKeys && r.responseKeys.length > 0) ||
|
||
(r.errorKeys && r.errorKeys.length > 0)) &&
|
||
r.consumers.length > 0,
|
||
)
|
||
.map((r) => {
|
||
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
||
const responseKeys = r.responseKeys ?? [];
|
||
const errorKeys = r.errorKeys ?? [];
|
||
// Combined set: consumer accessing either success or error keys is valid
|
||
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
||
|
||
// Check each consumer's accessed keys against the route's response shape
|
||
const responseKeySet = new Set(responseKeys);
|
||
const consumers = r.consumers.map((c) => {
|
||
if (!c.accessedKeys || c.accessedKeys.length === 0) {
|
||
return { name: c.name, filePath: c.filePath };
|
||
}
|
||
const mismatched = c.accessedKeys.filter((k) => !allKnownKeys.has(k));
|
||
// Keys in allKnownKeys but not in responseKeys — error-path access (e.g., .error from errorKeys)
|
||
const errorPathKeys = c.accessedKeys.filter(
|
||
(k) => allKnownKeys.has(k) && !responseKeySet.has(k),
|
||
);
|
||
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
||
return {
|
||
name: c.name,
|
||
filePath: c.filePath,
|
||
accessedKeys: c.accessedKeys,
|
||
...(mismatched.length > 0
|
||
? {
|
||
mismatched,
|
||
mismatchConfidence: isMultiFetch ? ('low' as const) : ('high' as const),
|
||
}
|
||
: {}),
|
||
...(errorPathKeys.length > 0 ? { errorPathKeys } : {}),
|
||
...(isMultiFetch
|
||
? {
|
||
attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`,
|
||
}
|
||
: {}),
|
||
};
|
||
});
|
||
|
||
const hasMismatches = consumers.some(
|
||
(c) => 'mismatched' in c && (c as any).mismatched.length > 0,
|
||
);
|
||
|
||
return {
|
||
route: r.name,
|
||
method: r.method,
|
||
handler: r.filePath,
|
||
...(responseKeys.length > 0 ? { responseKeys } : {}),
|
||
...(errorKeys.length > 0 ? { errorKeys } : {}),
|
||
consumers,
|
||
...(hasMismatches ? { status: 'MISMATCH' as const } : {}),
|
||
};
|
||
});
|
||
|
||
const mismatchCount = results.filter((r) => r.status === 'MISMATCH').length;
|
||
|
||
return {
|
||
routes: results,
|
||
total: results.length,
|
||
routesWithShapes: results.length,
|
||
...(mismatchCount > 0 ? { mismatches: mismatchCount } : {}),
|
||
message:
|
||
results.length === 0
|
||
? 'No routes with both response shapes and consumers found.'
|
||
: mismatchCount > 0
|
||
? `Found ${results.length} route(s) with response shape data. ${mismatchCount} route(s) have consumer/shape mismatches.`
|
||
: `Found ${results.length} route(s) with response shape data and consumers.`,
|
||
};
|
||
}
|
||
|
||
private async toolMap(repo: RepoHandle, params: { tool?: string }): Promise<any> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
const toolFilter = params.tool ? `AND n.name CONTAINS $tool` : '';
|
||
const queryParams = params.tool ? { tool: params.tool } : {};
|
||
|
||
const rows = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n:Tool)
|
||
WHERE n.id STARTS WITH 'Tool:' ${toolFilter}
|
||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.description AS description
|
||
`,
|
||
queryParams,
|
||
);
|
||
|
||
if (rows.length === 0) {
|
||
return {
|
||
tools: [],
|
||
total: 0,
|
||
message: params.tool ? `No tools matching "${params.tool}"` : 'No tool definitions found.',
|
||
};
|
||
}
|
||
|
||
const toolIds = rows.map((r: any) => r.id ?? r[0]);
|
||
const flowMap = await this.fetchLinkedFlowsBatch(repo.lbugPath, toolIds);
|
||
|
||
return {
|
||
tools: rows.map((r: any) => {
|
||
const id = r.id ?? r[0];
|
||
return {
|
||
name: r.name ?? r[1],
|
||
filePath: r.filePath ?? r[2],
|
||
description: (r.description ?? r[3] ?? '').slice(0, 200),
|
||
flows: flowMap.get(id) || [],
|
||
};
|
||
}),
|
||
total: rows.length,
|
||
};
|
||
}
|
||
|
||
private async apiImpact(
|
||
repo: RepoHandle,
|
||
params: { route?: string; file?: string; method?: unknown },
|
||
): Promise<ApiImpactResult> {
|
||
await this.ensureInitialized(repo);
|
||
|
||
if (!params.route && !params.file) {
|
||
return { error: 'Either "route" or "file" parameter is required.' };
|
||
}
|
||
|
||
// If file is provided but route is not, look up the route by file path
|
||
let routeFilter = '';
|
||
const queryParams: Record<string, string> = {};
|
||
|
||
if (params.route) {
|
||
routeFilter = `AND n.name CONTAINS $route`;
|
||
queryParams.route = params.route;
|
||
} else if (params.file) {
|
||
routeFilter = `AND n.filePath CONTAINS $file`;
|
||
queryParams.file = params.file;
|
||
}
|
||
|
||
// After #2302 the same URL/handler can expose one Route node per HTTP verb.
|
||
// An optional `method` narrows to that one verb so the response collapses to
|
||
// the singular shape. A method-agnostic route (method `'*'`, e.g. a Django
|
||
// function view) matches any selector; verbless routes (null method) never do.
|
||
// `method` arrives unvalidated from the MCP envelope (the JSON schema is
|
||
// advisory), so reject a non-string verb with a structured error instead of
|
||
// throwing on `.toUpperCase()`; empty/whitespace collapses to no selector.
|
||
const rawMethod = params.method;
|
||
if (rawMethod !== undefined && typeof rawMethod !== 'string') {
|
||
return { error: '"method" must be a string (e.g. "GET", "POST").' };
|
||
}
|
||
const wantedMethod =
|
||
typeof rawMethod === 'string' ? rawMethod.trim().toUpperCase() || undefined : undefined;
|
||
const matched = await this.fetchRoutesWithConsumers(repo.lbugPath, routeFilter, queryParams);
|
||
const routes = matched.filter(
|
||
(r) => !wantedMethod || r.method === '*' || r.method?.toUpperCase() === wantedMethod,
|
||
);
|
||
|
||
if (routes.length === 0) {
|
||
const target = params.route || params.file;
|
||
// Only append the verb when the URL/file matched routes but none used it;
|
||
// a non-existent URL/file gets the plain "no routes found" message.
|
||
const verb = wantedMethod && matched.length > 0 ? ` with method "${wantedMethod}"` : '';
|
||
return { error: `No routes found matching "${target}"${verb}.` };
|
||
}
|
||
|
||
const flowMap = await this.fetchLinkedFlowsBatch(
|
||
repo.lbugPath,
|
||
routes.map((r) => r.id),
|
||
);
|
||
|
||
// Count verbs per handler from the FULL match (before the method filter) so a
|
||
// method-scoped query still flags a multi-verb handler's partial middleware.
|
||
const routeCountByHandler = new Map<string, number>();
|
||
for (const r of matched) {
|
||
if (r.filePath) {
|
||
routeCountByHandler.set(r.filePath, (routeCountByHandler.get(r.filePath) ?? 0) + 1);
|
||
}
|
||
}
|
||
|
||
const results: ApiImpactRoute[] = routes.map((r) => {
|
||
// Keys already normalized by fetchRoutesWithConsumers (quotes stripped)
|
||
const responseKeys = r.responseKeys ?? [];
|
||
const errorKeys = r.errorKeys ?? [];
|
||
const allKnownKeys = new Set([...responseKeys, ...errorKeys]);
|
||
|
||
// Build consumer list with mismatch detection
|
||
const consumers = r.consumers.map((c) => ({
|
||
name: c.name,
|
||
file: c.filePath,
|
||
accesses: c.accessedKeys ?? [],
|
||
...(c.fetchCount && c.fetchCount > 1
|
||
? {
|
||
attributionNote: `This file fetches ${c.fetchCount} routes — accessed keys may belong to a different route.`,
|
||
}
|
||
: {}),
|
||
}));
|
||
|
||
// Detect mismatches: consumer accesses keys not in response shape
|
||
const mismatches: Array<{
|
||
consumer: string;
|
||
field: string;
|
||
reason: string;
|
||
confidence: 'high' | 'low';
|
||
}> = [];
|
||
if (allKnownKeys.size > 0) {
|
||
for (const c of r.consumers) {
|
||
if (!c.accessedKeys) continue;
|
||
const isMultiFetch = (c.fetchCount ?? 1) > 1;
|
||
for (const key of c.accessedKeys) {
|
||
if (!allKnownKeys.has(key)) {
|
||
mismatches.push({
|
||
consumer: c.filePath,
|
||
field: key,
|
||
reason: 'accessed but not in response shape',
|
||
confidence: isMultiFetch ? 'low' : 'high',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const flows = flowMap.get(r.id) || [];
|
||
const consumerCount = r.consumers.length;
|
||
|
||
// Risk level heuristic
|
||
let riskLevel: 'LOW' | 'MEDIUM' | 'HIGH';
|
||
if (consumerCount >= 10) {
|
||
riskLevel = 'HIGH';
|
||
} else if (consumerCount >= 4) {
|
||
riskLevel = 'MEDIUM';
|
||
} else {
|
||
riskLevel = 'LOW';
|
||
}
|
||
// Bump up one level if mismatches exist
|
||
if (mismatches.length > 0) {
|
||
if (riskLevel === 'LOW') riskLevel = 'MEDIUM';
|
||
else if (riskLevel === 'MEDIUM') riskLevel = 'HIGH';
|
||
}
|
||
|
||
const warning =
|
||
consumerCount > 0
|
||
? `Changing response shape will affect ${consumerCount} component${consumerCount === 1 ? '' : 's'}`
|
||
: undefined;
|
||
|
||
// Flag when middleware was detected but handler exports multiple HTTP methods
|
||
// (middleware chain may only reflect one export)
|
||
const middlewareArr = r.middleware || [];
|
||
const handlerRouteCount = r.filePath ? (routeCountByHandler.get(r.filePath) ?? 1) : 1;
|
||
const middlewarePartial = middlewareArr.length > 0 && handlerRouteCount > 1;
|
||
|
||
return {
|
||
route: r.name,
|
||
method: r.method,
|
||
handler: r.filePath,
|
||
responseShape: {
|
||
success: responseKeys,
|
||
error: errorKeys,
|
||
},
|
||
middleware: middlewareArr,
|
||
...(middlewarePartial
|
||
? {
|
||
middlewareDetection: 'partial' as const,
|
||
middlewareNote:
|
||
'Middleware captured from the first route export only — other route exports in this handler may use different middleware chains.',
|
||
}
|
||
: {}),
|
||
consumers,
|
||
...(mismatches.length > 0 ? { mismatches } : {}),
|
||
executionFlows: flows,
|
||
impactSummary: {
|
||
directConsumers: consumerCount,
|
||
affectedFlows: flows.length,
|
||
riskLevel,
|
||
...(warning ? { warning } : {}),
|
||
},
|
||
};
|
||
});
|
||
|
||
// If a single route was targeted, return it directly (not wrapped in array)
|
||
if (results.length === 1) {
|
||
return results[0];
|
||
}
|
||
|
||
return { routes: results, total: results.length };
|
||
}
|
||
|
||
// ─── Direct Graph Queries (for resources.ts) ────────────────────
|
||
|
||
/**
|
||
* Query clusters (communities) directly from graph.
|
||
* Used by getClustersResource — avoids legacy overview() dispatch.
|
||
*/
|
||
async queryClusters(repoName?: string, limit = 100): Promise<{ clusters: any[] }> {
|
||
const repo = await this.resolveRepo(repoName);
|
||
await this.ensureInitialized(repo);
|
||
|
||
try {
|
||
const rawLimit = Math.max(limit * 5, 200);
|
||
const clusters = await executeQuery(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (c:Community)
|
||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||
ORDER BY c.symbolCount DESC, c.id
|
||
LIMIT ${rawLimit}
|
||
`,
|
||
);
|
||
const rawClusters = clusters.map((c: any) => ({
|
||
id: c.id || c[0],
|
||
label: c.label || c[1],
|
||
heuristicLabel: c.heuristicLabel || c[2],
|
||
cohesion: c.cohesion || c[3],
|
||
symbolCount: c.symbolCount || c[4],
|
||
}));
|
||
return { clusters: this.aggregateClusters(rawClusters).slice(0, limit) };
|
||
} catch {
|
||
return { clusters: [] };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Query processes directly from graph.
|
||
* Used by getProcessesResource — avoids legacy overview() dispatch.
|
||
*/
|
||
async queryProcesses(repoName?: string, limit = 50): Promise<{ processes: any[] }> {
|
||
const repo = await this.resolveRepo(repoName);
|
||
await this.ensureInitialized(repo);
|
||
|
||
try {
|
||
const processes = await executeQuery(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:Process)
|
||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||
ORDER BY p.stepCount DESC, p.id
|
||
LIMIT ${limit}
|
||
`,
|
||
);
|
||
return {
|
||
processes: processes.map((p: any) => ({
|
||
id: p.id || p[0],
|
||
label: p.label || p[1],
|
||
heuristicLabel: p.heuristicLabel || p[2],
|
||
processType: p.processType || p[3],
|
||
stepCount: p.stepCount || p[4],
|
||
})),
|
||
};
|
||
} catch {
|
||
return { processes: [] };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Query cluster detail (members) directly from graph.
|
||
* Used by getClusterDetailResource.
|
||
*/
|
||
async queryClusterDetail(name: string, repoName?: string): Promise<any> {
|
||
const repo = await this.resolveRepo(repoName);
|
||
await this.ensureInitialized(repo);
|
||
|
||
const clusters = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (c:Community)
|
||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
|
||
`,
|
||
{ clusterName: name },
|
||
);
|
||
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
|
||
|
||
const rawClusters = clusters.map((c: any) => ({
|
||
id: c.id || c[0],
|
||
label: c.label || c[1],
|
||
heuristicLabel: c.heuristicLabel || c[2],
|
||
cohesion: c.cohesion || c[3],
|
||
symbolCount: c.symbolCount || c[4],
|
||
}));
|
||
|
||
let totalSymbols = 0,
|
||
weightedCohesion = 0;
|
||
for (const c of rawClusters) {
|
||
const s = c.symbolCount || 0;
|
||
totalSymbols += s;
|
||
weightedCohesion += (c.cohesion || 0) * s;
|
||
}
|
||
|
||
const members = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
|
||
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
|
||
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
|
||
ORDER BY filePath, name, type
|
||
LIMIT 30
|
||
`,
|
||
{ clusterName: name },
|
||
);
|
||
|
||
return {
|
||
cluster: {
|
||
id: rawClusters[0].id,
|
||
label: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
||
heuristicLabel: rawClusters[0].heuristicLabel || rawClusters[0].label,
|
||
cohesion: totalSymbols > 0 ? weightedCohesion / totalSymbols : 0,
|
||
symbolCount: totalSymbols,
|
||
subCommunities: rawClusters.length,
|
||
},
|
||
members: members.map((m: any) => ({
|
||
name: m.name || m[0],
|
||
type: m.type || m[1],
|
||
filePath: m.filePath || m[2],
|
||
})),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Query process detail (steps) directly from graph.
|
||
* Used by getProcessDetailResource.
|
||
*/
|
||
async queryProcessDetail(name: string, repoName?: string): Promise<any> {
|
||
const repo = await this.resolveRepo(repoName);
|
||
await this.ensureInitialized(repo);
|
||
|
||
const processes = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (p:Process)
|
||
WHERE p.label = $processName OR p.heuristicLabel = $processName
|
||
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
|
||
ORDER BY id
|
||
LIMIT 1
|
||
`,
|
||
{ processName: name },
|
||
);
|
||
if (processes.length === 0) return { error: `Process '${name}' not found` };
|
||
|
||
const proc = processes[0];
|
||
const procId = proc.id || proc[0];
|
||
const steps = await executeParameterized(
|
||
repo.lbugPath,
|
||
`
|
||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
|
||
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
|
||
ORDER BY r.step
|
||
`,
|
||
{ procId },
|
||
);
|
||
|
||
return {
|
||
process: {
|
||
id: procId,
|
||
label: proc.label || proc[1],
|
||
heuristicLabel: proc.heuristicLabel || proc[2],
|
||
processType: proc.processType || proc[3],
|
||
stepCount: proc.stepCount || proc[4],
|
||
},
|
||
steps: steps.map((s: any) => ({
|
||
step: s.step || s[3],
|
||
name: s.name || s[0],
|
||
type: s.type || s[1],
|
||
filePath: s.filePath || s[2],
|
||
})),
|
||
};
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
await closeLbug(); // close all connections
|
||
// Note: we intentionally do NOT call disposeEmbedder() here.
|
||
// ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
|
||
// and importing the embedder module on Node v24+ crashes if onnxruntime
|
||
// was never loaded during the session. Since process.exit(0) follows
|
||
// immediately after disconnect(), the OS reclaims everything. See #38, #89.
|
||
this.repos.clear();
|
||
this.contextCache.clear();
|
||
this.initializedRepos.clear();
|
||
}
|
||
}
|