From 613a0d9db20b4e21f8633d8ee27e7e85a521c034 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sat, 4 Apr 2026 23:28:29 +0300 Subject: [PATCH] feat(group): cross-index impact analysis via repository groups Cross-repo blast radius analysis through Contract Registry: - group_impact MCP tool and CLI command - ManifestExtractor for explicit cross-repo links - HTTP route extractor for auto-detected contracts - closeLbug() resource cleanup in CLI sync Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-31-cross-index-impact-design.md | 1039 +++++++++++++++++ gitnexus/src/cli/group.ts | 146 ++- gitnexus/src/core/group/cross-impact.ts | 225 ++++ .../group/extractors/manifest-extractor.ts | 124 ++ gitnexus/src/core/group/service.ts | 95 +- gitnexus/src/core/group/sync.ts | 9 +- gitnexus/src/mcp/local/local-backend.ts | 7 + gitnexus/src/mcp/tools.ts | 40 +- .../test/integration/group/group-cli.test.ts | 16 - .../integration/group/group-impact.test.ts | 75 ++ gitnexus/test/unit/group/cross-impact.test.ts | 191 +++ gitnexus/test/unit/group/group-tools.test.ts | 17 +- .../unit/group/manifest-extractor.test.ts | 68 ++ gitnexus/test/unit/tools.test.ts | 14 +- 14 files changed, 2025 insertions(+), 41 deletions(-) create mode 100644 docs/specs/2026-03-31-cross-index-impact-design.md create mode 100644 gitnexus/src/core/group/cross-impact.ts create mode 100644 gitnexus/src/core/group/extractors/manifest-extractor.ts create mode 100644 gitnexus/test/integration/group/group-impact.test.ts create mode 100644 gitnexus/test/unit/group/cross-impact.test.ts create mode 100644 gitnexus/test/unit/group/manifest-extractor.test.ts diff --git a/docs/specs/2026-03-31-cross-index-impact-design.md b/docs/specs/2026-03-31-cross-index-impact-design.md new file mode 100644 index 000000000..61834093b --- /dev/null +++ b/docs/specs/2026-03-31-cross-index-impact-design.md @@ -0,0 +1,1039 @@ +# RFC: Cross-Index Impact Analysis — Repository Groups + +**Date:** 2026-03-31 +**Status:** Draft +**Author:** @ivkond +**Related Issues:** [#256](https://github.com/abhigyanpatwari/GitNexus/issues/256), [#306](https://github.com/abhigyanpatwari/GitNexus/issues/306), [#77](https://github.com/abhigyanpatwari/GitNexus/issues/77) + +## Summary + +Add cross-repository impact analysis to GitNexus by allowing users to organize repositories into logical groups with hierarchical naming (e.g., `hr/hiring/backend`, `hr/hiring/ui`). When analyzing blast radius, the system looks not only into the current repo's index but also into neighboring repos in the group, connected through a Contract Registry of shared touch-points (HTTP routes, gRPC services, message topics, shared library exports). + +## Motivation + +Modern applications are split across multiple repositories: frontend, BFF, backend, ML pipeline, workflow engines, shared libraries. GitNexus currently indexes each repo in isolation — the knowledge graph captures call chains within each repo, but connections across repo boundaries are lost. + +When a developer changes a DTO in the backend, they have no way to know which frontend components, BFF handlers, or downstream services will break — without manually grepping across repos or relying on LLM inference. + +### Use Cases + +1. **Developer:** "I'm changing `UserDTO.email` in backend — what breaks in the UI and BFF?" +2. **Architect:** "Show me all dependencies between services in the `hr` group." +3. **CI/CD:** Pre-merge check — does this PR affect contracts consumed by other repos? + +## Design: Hybrid — Lazy Virtual Graph (Approach C) + +Each repo keeps its own isolated index (`.gitnexus/lbug`). A lightweight metadata layer on top stores group configuration and a Contract Registry of extracted touch-points. Cross-repo impact works by fan-out: local impact in the current repo, then follow cross-links to run local impact in neighboring repos. + +### Why Not a Unified Super-Graph? + +Merging all indexes into one KuzuDB/LadybugDB would give full Cypher across the group, but: +- O(n) rebuild time when any single repo is re-indexed +- Multiplicative graph size growth +- Name collisions between repos +- Breaks the current "each repo is independent" model + +The hybrid approach is incremental, non-destructive, and minimizes changes to the core indexing pipeline. + +### Prerequisites and Current State + +**Current public surface (no `group` surface exists today):** +- CLI commands: `analyze`, `serve`, `wiki`, `status`, `clean`, `list`, `impact`, `cypher`, `mcp` ([index.ts](gitnexus/src/cli/index.ts)) +- MCP tools: 7 tools — `list_repos`, `query`, `cypher`, `context`, `impact`, `detect_changes`, `rename` ([tools.ts](gitnexus/src/mcp/tools.ts)) +- Shape guards: tool count asserted in [tools.test.ts](gitnexus/test/unit/tools.test.ts), resource count in [resources.test.ts](gitnexus/test/unit/resources.test.ts) + +**Graph schema gaps** — the following entities referenced in this RFC do NOT currently exist in the LadybugDB schema ([schema.ts](gitnexus/src/core/lbug/schema.ts)): +- No `Route` node label +- No `HANDLES_ROUTE` or `FETCHES` relation types +- Route data during ingestion is ephemeral — reduced to `CALLS` edges with `reason: "laravel-route"` ([parse-worker.ts:1145](gitnexus/src/core/ingestion/workers/parse-worker.ts), [call-processor.ts:642](gitnexus/src/core/ingestion/call-processor.ts)) +- Import graph stores file→file edges, not raw package coordinates ([import-processor.ts:343](gitnexus/src/core/ingestion/import-processor.ts)) +- `.proto` files are not a supported language ([supported-languages.ts](gitnexus/src/config/supported-languages.ts)) + +**Impact tool limitation** — current `impact` resolves symbols by name with `LIMIT 1` ([local-backend.ts:1347-1352](gitnexus/src/mcp/local/local-backend.ts)), not by UID. This creates false positives for common names. + +**Existing tech debt** — `impact` tool description documents `HAS_METHOD/OVERRIDES` relation types ([tools.ts:203](gitnexus/src/mcp/tools.ts)) but runtime filter only allows 4 types ([local-backend.ts:50](gitnexus/src/mcp/local/local-backend.ts)). Should be resolved before extending `group_impact`. + +These gaps define the **prerequisite work** required before the group features can function. See Section 8: Implementation Prerequisites. + +--- + +## Section 1: Concepts and Terminology + +### Repository Group + +A logical group of related repositories with hierarchical path-like addressing. Names support arbitrary nesting depth: + +``` +company/ + hr/ + hiring/ + backend <- repo (leaf) + ui <- repo (leaf) + payroll/ + backend <- repo (leaf) + camunda <- repo (leaf) + sales/ + admin/ + ui <- repo (leaf) + bff <- repo (leaf) + crm/ + backend <- repo (leaf) +``` + +A **group** is any non-leaf node in the hierarchy. `company/hr` is a group, `company/hr/hiring` is also a group, `company/hr/hiring/backend` is a repo (leaf). Operations on a group (impact, query) cascade to all nested repos. + +### Contract + +A touch-point between repositories. Types: + +| Type | Description | Example | +|------|-------------|---------| +| **HTTP Route** | REST/GraphQL endpoint | `GET /api/users` | +| **gRPC Service** | Proto service + method | `UserService.GetUser` | +| **Message Topic** | Kafka/RabbitMQ topic | `user.created` | +| **Shared Library Export** | Package export | `@hr/common::UserDTO` | +| **Custom** | User-defined from manifest | `custom::payroll-calc-v2` | + +### Contract Registry + +A lightweight JSON index of extracted contracts from all repos in a group. Stored at `~/.gitnexus/groups//contracts.json`. + +### Cross-Link + +An edge between a contract provider in one repo and a contract consumer in another. Confidence levels: + +| Match Type | Confidence | Source | +|-----------|-----------|--------| +| `exact` | 1.0 | Identical contract IDs | +| `manifest` | 1.0 | Explicitly declared in group.yaml (bypasses matching cascade) | +| `bm25` | 0.85-0.95 | BM25 text similarity | +| `embedding` | 0.5-0.85 | Semantic vector similarity | + +--- + +## Section 2: Storage and Group Configuration + +### File Structure + +``` +~/.gitnexus/ + registry.json <- existing (unchanged) + groups/ + company/ + group.yaml <- root group definition + contracts.json <- Contract Registry for entire subtree + embeddings.bin <- embedding vectors for contracts (optional) +``` + +### group.yaml + +```yaml +version: 1 +name: company +description: "All company microservices" + +# Mapping: path in group -> repo name from registry.json +# Repo can also be referenced by filesystem path or git remote URL +# for portability across machines where registry names may differ. +repos: + hr/hiring/backend: hr-hiring-backend + hr/hiring/ui: hr-hiring-ui + hr/payroll/backend: hr-payroll-api + sales/admin/ui: sales-admin-frontend + sales/admin/bff: sales-admin-bff + sales/crm/backend: sales-crm + +# Explicit links (manifest) for connections auto-detect can't find. +# Manifest links bypass the matching cascade entirely (confidence 1.0). +# `role` specifies the role of the `from` repo in this contract. +links: + - from: hr/payroll/backend + to: hr/hiring/backend + type: topic + contract: "employee.hired" + role: provider # provider | consumer (role of `from` repo) + + - from: sales/admin/bff + to: sales/crm/backend + type: http + contract: "/api/v2/leads/*" + role: consumer # sales/admin/bff consumes this route from sales/crm/backend + +# Cross-language package coordinate mapping. +# Keys under each repo are free-form ecosystem identifiers, +# supporting any package manager (npm, maven, pypi, go, nuget, crates, gems, etc.) +packages: + hr/common: + npm: "@hr/common" + maven: "com.hr.common" + pypi: "hr-common" + go: "github.com/hr/common" + nuget: "Hr.Common" + +# Auto-detection settings +detect: + http: true + grpc: true + topics: true + shared_libs: true + embedding_fallback: true + +# Matching cascade tuning +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +``` + +### contracts.json + +Auto-generated by `gitnexus group sync`. Written atomically (write to temp file, then rename) to prevent torn reads during concurrent `group_impact` queries. + +**Version migration:** On version mismatch, `group_impact` and `group_contracts` fail with a message to re-run `group sync`. No automatic migration — the file is fully regenerated on each sync anyway. + +**Staleness detection** uses two complementary checks for different purposes: + +1. **Repo index staleness** (commit-based, consistent with existing [staleness.ts:20](gitnexus/src/mcp/staleness.ts)): compares `meta.json.lastCommit` vs `git rev-parse HEAD`. Answers: "is this repo's index behind its own HEAD?" Used by `group sync` before extraction and by `group status`. + +2. **Contract Registry staleness** (indexedAt-based): compares `repoSnapshots[repo].indexedAt` in `contracts.json` against the repo's current `meta.json.indexedAt`. Answers: "was this repo re-indexed after the last `group sync`?" Used by `group_impact` before fan-out and by `group status`. + +These are different questions and intentionally use different heuristics: +- Check 1 catches: repo has new commits but hasn't been re-indexed +- Check 2 catches: repo was re-indexed (possibly with schema changes) but `group sync` hasn't been re-run + +```json +{ + "version": 1, + "generatedAt": "2026-03-31T10:00:00Z", + "repoSnapshots": { + "sales/crm/backend": { "indexedAt": "2026-03-30T21:14:14Z", "lastCommit": "5838fb8d" }, + "sales/admin/bff": { "indexedAt": "2026-03-30T19:05:00Z", "lastCommit": "a1b2c3d4" } + }, + "contracts": [ + { + "id": "http::GET::/api/v2/leads", + "type": "http", + "repo": "sales/crm/backend", + "symbolName": "LeadController.list", + "symbolUid": "abc123", + "symbolRef": { "filePath": "src/controller/LeadController.java", "name": "LeadController.list" }, + "role": "provider", + "meta": { + "method": "GET", + "path": "/api/v2/leads", + "pathSegments": ["api", "v2", "leads"], + "extractionStrategy": "source_scan" + } + }, + { + "id": "http::GET::/api/v2/leads", + "type": "http", + "repo": "sales/admin/bff", + "symbolName": "fetchLeads", + "symbolUid": "def456", + "symbolRef": { "filePath": "src/api/leads.ts", "name": "fetchLeads" }, + "role": "consumer", + "meta": { + "method": "GET", + "path": "/api/v2/leads", + "pathSegments": ["api", "v2", "leads"], + "extractionStrategy": "source_scan" + } + } + ], + "crossLinks": [ + { + "from": { "repo": "sales/admin/bff", "symbolUid": "def456", "symbolRef": { "filePath": "src/api/leads.ts", "name": "fetchLeads" } }, + "to": { "repo": "sales/crm/backend", "symbolUid": "abc123", "symbolRef": { "filePath": "src/controller/LeadController.java", "name": "LeadController.list" } }, + "type": "http", + "contractId": "http::GET::/api/v2/leads", + "matchType": "exact", + "confidence": 1.0 + } + ] +} +``` + +### Contract ID Format + +`::`: + +| Type | Discriminator | Example | +|------|---------------|---------| +| http | `METHOD::path` | `http::GET::/api/v2/leads` | +| grpc | `package.Service/Method` | `grpc::hr.UserService/GetUser` | +| topic | `topic_name` | `topic::employee.hired` | +| lib | `package::export` | `lib::@hr/common::UserDTO` | +| custom | free-form from manifest | `custom::payroll-calc-v2` | + +--- + +## Section 3: Contract Extraction + +### Extractor Architecture + +Contract extraction uses a **two-tier strategy**: graph queries where the data is available in LadybugDB, and lightweight source scanning where it is not. This is necessary because the current graph does not store all the data extractors need (see Prerequisites). + +``` +ContractExtractor (interface) + |-- HttpRouteExtractor <- graph (CALLS with route reason) + source scan (fetch/axios patterns) + |-- GrpcExtractor <- source scan only (.proto files, not in supported languages) + |-- MessageTopicExtractor <- graph (CALLS) + source scan (publish/subscribe patterns) + |-- SharedLibExtractor <- graph (IMPORTS file->file) + packages map from group.yaml + |-- ManifestExtractor <- group.yaml links (no graph/source access) +``` + +### ContractExtractor Interface + +```typescript +interface ContractExtractor { + type: ContractType; // 'http' | 'grpc' | 'topic' | 'lib' | 'custom' + canExtract(repoHandle: RepoHandle): Promise; + /** + * Extract contracts. Gets both db connection (for graph queries) + * and repoPath (for source file scanning when graph data is insufficient). + */ + extract(db: LbugConnection, repoPath: string): Promise; +} + +interface ExtractedContract { + contractId: string; + type: ContractType; + role: 'provider' | 'consumer'; + symbolUid: string; // may be empty for source-scan-only results + symbolRef: { filePath: string; name: string }; // stable fallback for UID + symbolName: string; // human-readable, used in BM25/embedding + confidence: number; // extraction confidence (1.0 graph, 0.3-0.8 source scan) + meta: Record; +} +``` + +Note: `symbolName` is a denormalized convenience field (same as `symbolRef.name`) used in BM25 document building and embedding input. `confidence` reflects extraction quality — graph-derived contracts get 1.0, source-scanned get lower confidence depending on pattern reliability. + +### HttpRouteExtractor + +**Current graph state:** No `Route` nodes, no `HANDLES_ROUTE`/`FETCHES` edges. Route handlers are recorded as `CALLS` edges with `reason: "laravel-route"` (Laravel only). Frontend fetch calls are not in the graph at all. + +**Provider extraction (backend)** — two strategies: + +Strategy A — Graph query for existing route-annotated CALLS edges (auxiliary only): +```cypher +MATCH (source)-[r:CodeRelation {type: 'CALLS'}]->(target) +WHERE r.reason CONTAINS 'route' +RETURN source.name, source.uid, source.filePath, + target.name, target.uid, target.filePath, + r.reason, r.confidence +``` +**Limitation:** Current graph stores only `reason: "laravel-route"` without HTTP method or path ([call-processor.ts:685](gitnexus/src/core/ingestion/call-processor.ts)). Strategy A can identify that a symbol is a route handler (useful for filtering) but **cannot reconstruct the contract ID** (`http::METHOD::path`). Contract ID must come from Strategy B (source scan). Strategy A serves as a hint to narrow source scan scope. + +Strategy B — Source scan for route decorator/annotation patterns (primary source of contract ID): +- Scan files matching common patterns: `*Controller.*`, `*Router.*`, `routes/*` +- Regex match for route annotations: `@GetMapping`, `@app.route`, `router.get`, `@Controller`/`@RequestMapping` (Java/Spring), `Route::get` (Laravel) +- Extract HTTP method + path from annotation arguments +- Resolve to nearest symbol in graph by file + line number + +**Consumer extraction (frontend/BFF)** — source scan only (not in graph): +- Scan `.ts`, `.tsx`, `.js`, `.jsx`, `.vue`, `.svelte` files +- Regex match for fetch patterns: `fetch('...')`, `axios.get('...')`, `$.ajax`, `http.get` +- Extract HTTP method (from function name or options) + URL path +- Resolve calling function from graph by file + approximate line range + +**Path normalization:** strip trailing slash, collapse path params (`/users/:id`, `/users/{id}`, `/users/[id]` -> `/users/{param}`). + +**Meta fields:** +```json +{ + "method": "GET", + "path": "/api/v2/users", + "pathSegments": ["api", "v2", "users"], + "extractionStrategy": "source_scan", + "handlerName": "UserController.list", + "paramNames": ["limit", "offset"] +} +``` + +Note: `responseKeys`/`accessedKeys` from the original design require response shape tracking which does not exist in the current graph. These fields are omitted from MVP and listed in Future Work as a prerequisite for shape_check cross-repo integration. + +**Contract ID:** `http::{METHOD}::{normalized_path}` + +### GrpcExtractor + +`.proto` files are not a supported language in GitNexus — no symbols are extracted during indexing. This extractor is **source-scan only**. + +- Scan for `.proto` files in repo directory +- Parse `service` and `rpc` declarations with regex +- For consumers: scan source files for generated stub/client class usage patterns + +In MVP, `canExtract` returns `true` only when `.proto` files exist in the repo's file tree. Extraction confidence is lower (0.7) due to regex-only parsing. + +**Contract ID:** `grpc::{package}.{Service}/{Method}` + +### MessageTopicExtractor + +**Current graph state:** `ACCESSES` relation type does not exist in the current schema ([schema.ts:29](gitnexus/src/core/lbug/schema.ts)). This extractor is **source-scan only**. + +Topic name resolution cascade (all via source scanning): + +1. **String literal** — `publish("user.created", ...)` -> topic name directly (confidence 1.0) +2. **Constant** — `publish(TOPICS.USER_CREATED, ...)` -> source-scan the constant definition file (follow import path from source, not graph ACCESSES edge) to find the string value. If constant is in the same file or a direct import, resolution succeeds (confidence 0.9). If indirect or dynamic import chain, falls through to step 4. +3. **Env variable** — `publish(process.env.USER_TOPIC, ...)` -> cannot auto-resolve, recorded as `topic::${USER_TOPIC}` with `confidence: 0.3` and env name in meta +4. **Dynamic / unresolvable** — `publish(getTopicName(), ...)` -> **no contract created**, warning emitted: + +``` +WARNING: sales/crm/backend: found publish() call at src/events/publisher.ts:42 + but could not resolve topic name (dynamic expression). + -> Add this link explicitly in group.yaml: + links: + - from: sales/crm/backend + to: + type: topic + contract: "" + role: provider +``` + +Meta includes `resolution` field for transparency: `"literal"`, `"constant"`, `"env"`, `"unresolved"`. + +### SharedLibExtractor + +**Current graph state:** IMPORTS edges are file→file only ([import-processor.ts:343](gitnexus/src/core/ingestion/import-processor.ts)), without raw package coordinates. External imports (to packages outside the repo) are not in the graph. + +**Strategy — hybrid graph + source scan:** + +1. **Source scan** — read import statements from source files to get raw package coordinates (e.g., `import { UserDTO } from '@hr/common'`, `import com.hr.common.UserDTO`) +2. **Match against `packages` map** from group.yaml: + +```yaml +packages: + hr/common: + npm: "@hr/common" + maven: "com.hr.common" + pypi: "hr-common" + go: "github.com/hr/common" +``` + +3. **Resolve symbols** — once the target repo is identified via package match, look up the imported symbol name in that repo's graph to get the `symbolUid` +4. Without packages map — fallback: search import strings containing another group repo's name (fuzzy, low confidence 0.6) + +Contract ID normalized to group path: `lib::hr/common::UserDTO` — same contract regardless of import language. + +Warning for unmatched imports: + +``` +WARNING: hr/hiring/backend: import "com.acme.utils.DateHelper" at src/Main.java:3 + matches no known package in group. If this is a shared library, add to packages: + packages: + : + maven: "com.acme.utils" +``` + +### ManifestExtractor + +Reads `links` section from `group.yaml` directly. Manifest links **bypass the matching cascade entirely** — they are pre-matched by definition and always produce cross-links with confidence 1.0. + +Role mapping from `group.yaml`: +- `role: provider` on `from` repo means `from` provides the contract, `to` consumes it +- `role: consumer` on `from` repo means `from` consumes the contract, `to` provides it + +For each manifest link, the extractor creates two `ExtractedContract` entries (one provider, one consumer) and one pre-matched cross-link. + +**Symbol resolution during sync** (not deferred): When sync processes manifest links, it attempts to resolve the contract identifier against the graph of each referenced repo. For example, manifest `contract: "employee.hired"` with `type: topic` — sync searches for symbols in the `from` and `to` repos that reference this topic name (using the same strategies as MessageTopicExtractor). If resolution succeeds, `symbolUid` and `symbolRef` are populated. If it fails, the contract is created with empty `symbolUid` and `symbolRef: { filePath: "", name: contract }`. During `group_impact` Phase 2, step 4c-iv handles these entries: it searches the target repo's graph for symbols matching the contractId pattern (e.g., route handlers matching the HTTP path). + +### Sync Execution Order + +``` +1. For each repo in group (sequential, LadybugDB pool limit): + a. Open LadybugDB (read-only) + b. For each enabled extractor (http, grpc, topic, lib): + canExtract(repo)? -> extract(db, repoPath) + c. Close connection + +2. ManifestExtractor — add links from group.yaml + +3. Matching cascade: + a. Exact match by contract ID -> crossLinks (confidence 1.0) + b. BM25 by contract ID + symbol + meta -> crossLinks (confidence 0.85-0.95) + c. Embedding fallback by symbol + meta -> crossLinks (confidence 0.5-0.85) + +4. Write contracts.json +``` + +--- + +## Section 4: Cross-Index Impact + +### Algorithm — Two Phases + +**Prerequisite: UID-based impact resolution.** The current `impact` tool resolves symbols by name with `LIMIT 1` ([local-backend.ts:1347-1352](gitnexus/src/mcp/local/local-backend.ts)). For `group_impact` Phase 2 fan-out, where the target symbol is known by UID from the Contract Registry, this creates false positives on common names (e.g., `getUser` may match the wrong overload). Phase 2 requires an internal `impactByUid(uid, direction)` variant that resolves by UID directly. This is listed as a prerequisite in Section 8. + +**Phase 1: Local impact** — blast radius within current repo (existing `impact` tool, unchanged): + +``` +UserDTO (hr/hiring/backend) + d=1: UserController.getUser, UserMapper.toDTO, UserService.findById + d=2: HiringRouter (/api/v2/users/:id) +``` + +**Phase 2: Cross-boundary fan-out** — expand through Contract Registry: + +``` +1. Close Phase 1 db connection (free pool slot for fan-out) +2. Collect all symbol UIDs from Phase 1 result +3. For each symbol — lookup in contracts.json: + a. Primary: match by symbolUid + b. Fallback: if UID not found (repo re-indexed since last sync), + match by symbolRef (filePath + name) + c. If neither matches — skip with warning "contracts.json may be stale, re-run group sync" +4. For each found crossLink (sorted by confidence desc): + a. Determine traversal side based on direction: + - upstream ("what depends on me"): follow links where the changed symbol + is the PROVIDER — look up consumers in other repos + (crossLinks where `to.symbolUid` matches phase1 symbol → fan-out to `from.repo`) + - downstream ("what do I depend on"): follow links where the changed symbol + is the CONSUMER — look up providers in other repos + (crossLinks where `from.symbolUid` matches phase1 symbol → fan-out to `to.repo`) + b. Open LadybugDB of target repo (sequential, reusing pool) + c. Resolve target symbol in target repo's graph: + i. Try symbolUid (fast, exact) + ii. Fallback: symbolRef filePath + name + iii. Fallback: symbolRef name only (warn if ambiguous) + iv. Fallback (for manifest links with empty symbolRef): search target repo's graph + for symbols matching the contractId pattern (e.g., for http contract, find + route handlers matching the path; for topic, find publish/subscribe calls + matching the topic name). This is a slower text-based search in the graph. + v. If all fail: skip with staleness warning + d. Run local impactByUid(resolvedUid, direction) in that repo + e. Tag results as cross-repo (with crossLink confidence) + f. Close connection before opening next repo +5. DO NOT recurse further — one hop through boundary (default) +``` + +### Symbol UID Stability + +Symbol UIDs in LadybugDB may change when a repo is re-indexed. Cross-links store both `symbolUid` (fast lookup) and `symbolRef` (stable fallback: filePath + name). + +Resolution cascade when `symbolUid` lookup fails: +1. **filePath + name** — match by both fields together (stable unless file was moved) +2. **name only** — if filePath match fails, search by name alone. If multiple candidates found, emit warning: "Ambiguous symbol resolution for {name}, {count} candidates — re-run `group sync`" and skip the cross-link +3. **Neither** — skip with staleness warning + +### Why One Hop Default + +Transitive cross-boundary chains (UI -> BFF -> Backend -> ML) are exponentially expensive and yield decreasing confidence. One hop covers the primary scenario. `--cross-depth 2+` is reserved for Future Work — the flag is accepted but capped at 1 in MVP with a message: "Multi-hop cross-boundary traversal is not yet implemented. Using --cross-depth 1." + +### Response Format + +**`impact` tool** — unchanged. Returns exactly the same response as today. + +**`group_impact` tool** — new tool, new response type: + +```typescript +interface GroupImpactResult { + local: ImpactResult; // everything the existing impact returns + group: string; // group name + cross: CrossRepoImpact[]; // empty if no cross-links found + outOfScope: OutOfScopeLink[]; // cross-links not followed due to --subgroup filter + truncated: boolean; // true if timeout reached before all repos processed + truncatedRepos: string[]; // repos not reached due to timeout + summary: { + direct: number; // existing impact field name + processes_affected: number; // existing impact field name ([local-backend.ts:1477]) + modules_affected: number; // existing impact field name + cross_repo_hits: number; // new field, 0 if no cross-links + }; + risk: RiskLevel; // recalculated with cross-repo factors +} + +interface OutOfScopeLink { + from: string; // repo path + to: string; // repo path + contractId: string; + confidence: number; +} + +interface CrossRepoImpact { + repo: string; // registry name + repo_path: string; // path in group hierarchy + contract: { + id: string; + type: ContractType; + match_type: 'exact' | 'manifest' | 'bm25' | 'embedding'; + confidence: number; + }; + by_depth: Record; + affected_processes: string[]; +} +// Note: JSON field naming uses snake_case to match existing impact output style +// ([local-backend.ts:1477](gitnexus/src/mcp/local/local-backend.ts)) +``` + +Clients using `impact` are unaffected. Two fully independent tool registrations in MCP. + +### Risk Scoring + +| Factor | Risk contribution | +|--------|------------------| +| d=1 local callers > 5 | +MEDIUM | +| Any cross-repo hit with confidence >= 0.85 | +HIGH | +| Cross-repo hit with confidence < 0.85 | +MEDIUM + "verify manually" warning | +| Cross-repo hits in >= 3 repos | +CRITICAL | +| Affected process with > 10 steps | +HIGH | + +### Concurrency and Performance + +LadybugDB pool limit = 5 simultaneous databases. Fan-out strategy: + +``` +Phase 1: local impact -> 1 db connection, released after completion +Phase 2: contract registry -> in-memory (JSON), no db +Phase 3: fan-out impacts -> sequential, one connection at a time + (Phase 1 connection already released, + so full pool of 5 available for fan-out) + +Fan-out order: sorted by confidence desc + -> exact matches (1.0) first — most likely real impact + -> embedding matches (0.5-0.85) last — can be interrupted by timeout +``` + +Timeout budget: +- Total wall time: 30s default (configurable via `--timeout`) +- Phase 1: max 5s +- Remaining budget = total - Phase1_elapsed +- Each fan-out hop: `min(5s, remaining_budget / remaining_hops)` +- On timeout: return partial result with `"truncated": true` and list of repos not reached + +--- + +## Section 5: Matching Cascade + +### Overview + +During `group sync`, after extracting contracts from all repos, the cascade finds provider-consumer pairs. Each step processes only **unmatched** contracts remaining from the previous step: + +``` +Extracted contracts (all repos) + | + +- Step 1: Exact match by contract ID + | matched -> crossLinks (confidence 1.0) + | unmatched | + | + +- Step 2: BM25 by contract ID + symbol + meta + | score >= threshold -> crossLinks (confidence 0.85-0.95) + | unmatched | + | + +- Step 3: Embedding similarity by symbol + meta + | score >= threshold -> crossLinks (confidence 0.5-0.85) + | unmatched | + | + +- Unmatched -> report (warnings) +``` + +### Step 1: Exact Match + +``` +providers = contracts.filter(c => c.role === 'provider') +consumers = contracts.filter(c => c.role === 'consumer') +index = Map + +for each consumer: + if index.has(consumer.contractId): + emit crossLink(consumer -> provider, matchType: 'exact', confidence: 1.0) +``` + +Contract ID normalization before comparison: +- HTTP: lowercase method, strip trailing slash, collapse path params +- gRPC: lowercase package name +- Topic: trim whitespace, lowercase +- Lib: lowercase package coordinates + +Time: O(n) — single hashmap pass. + +### Step 2: BM25 + +Document for indexing — concatenation of contract fields: + +```typescript +function contractToDocument(c: ExtractedContract): string { + const parts = [ + c.contractId, + c.symbolRef.name, + c.type, + ]; + if (c.meta.path) parts.push(c.meta.path); + if (c.meta.pathSegments) parts.push(...c.meta.pathSegments); + if (c.meta.responseKeys) parts.push(...c.meta.responseKeys); + if (c.meta.accessedKeys) parts.push(...c.meta.accessedKeys); + if (c.meta.paramNames) parts.push(...c.meta.paramNames); + if (c.meta.topicName) parts.push(c.meta.topicName); + return parts.join(' '); +} +``` + +Only type-compatible pairs match: http <-> http, topic <-> topic, lib <-> lib, grpc <-> grpc. Within `lib` type, cross-ecosystem matching is allowed (e.g., a `maven` provider can match an `npm` consumer for the same logical package — this is exactly what the `packages` map in group.yaml enables). + +Thresholds — BM25 raw scores are unbounded and corpus-dependent, so we use **relative scoring** (score / max_score in result set) rather than an absolute threshold: +- `BM25_RELATIVE_THRESHOLD = 0.7` (min ratio of score to top result's score) +- `BM25_TOP_K = 3` (candidates per query) +- Configurable via `matching.bm25_threshold` in `group.yaml` — may need tuning per deployment + +Confidence mapping (based on relative score): +- relative 0.7-0.8 -> confidence 0.85 +- relative 0.8-0.9 -> confidence 0.90 +- relative 0.9-1.0 -> confidence 0.95 + +What BM25 catches well: +- Versioned paths (`/api/v1/users` <-> `/api/v2/users`) +- Partial name matches (`UserDTO` <-> `UserResponseDTO`) +- Matching meta fields (e.g., same pathSegments, paramNames; `responseKeys`/`accessedKeys` are future work — see Section 3 HttpRouteExtractor note) + +What BM25 does NOT catch: +- Cross-language naming (`IUser` in TypeScript <-> `UserDTO` in Java) +- Synonymous concepts (`fetchPeople` <-> `GET /api/employees`) + +These cases fall through to Step 3. + +### Step 3: Embedding Similarity + +Embedding input — richer than BM25, includes structural context: + +```typescript +function contractToEmbeddingInput(c: ExtractedContract): string { + const parts = [ + `${c.type} contract`, + c.role, + c.symbolRef.name, + c.contractId, + ]; + if (c.meta.responseKeys) { + parts.push(`fields: ${c.meta.responseKeys.join(', ')}`); + } + if (c.meta.accessedKeys) { + parts.push(`accesses: ${c.meta.accessedKeys.join(', ')}`); + } + return parts.join(' | '); +} +``` + +Model: Snowflake/snowflake-arctic-embed-xs (384 dims) — same as GitNexus internal semantic search. + +Thresholds: +- `EMBEDDING_THRESHOLD = 0.65` (min cosine similarity) +- `EMBEDDING_MAX_CONFIDENCE = 0.85` (cap — never higher than BM25 min) + +Confidence mapping (linear): +- cosine 0.65 -> confidence 0.50 +- cosine 0.75 -> confidence 0.67 +- cosine 0.85 -> confidence 0.85 + +Storage: `~/.gitnexus/groups//embeddings.bin` — flat binary, ~1.5KB per contract. + +### Interaction with Existing Flags + +Embedding fallback respects flags and index state: + +| Flag | Behavior | +|------|----------| +| (default) | Exact -> BM25 -> Embedding fallback | +| `--skip-embeddings` | Exact -> BM25 only. No model load. No embeddings.bin | +| `--exact-only` | Exact match only. Fastest, strictest | +| `--force-embeddings` | Regenerate embeddings.bin even if fresh | + +Automatic skip when model unavailable: + +``` +WARNING: Embedding model unavailable (onnxruntime not found). + Matching cascade limited to: exact -> BM25. + Unmatched contracts may increase. Use group.yaml links for manual linking. +``` + +Note: `group sync --skip-embeddings` and `gitnexus analyze --embeddings` are independent. Per-repo embeddings (for `query` semantic search) and group embeddings (for cross-repo contract matching) are separate concerns. + +### Unmatched Report + +``` +WARNING: Unmatched contracts (4): + + PROVIDERS without consumers: + http::DELETE::/api/v2/users/{param} (hr/hiring/backend) + topic::employee.terminated (hr/hiring/backend) + + CONSUMERS without providers: + http::GET::/api/v2/departments (hr/hiring/ui) + lib::hr/common::DepartmentDTO (hr/payroll/backend) + + -> To link manually, add to group.yaml links section. +``` + +--- + +## Section 6: CLI Commands and MCP Tools + +### CLI Commands + +All commands live under `gitnexus group`: + +#### `gitnexus group create ` + +Creates `~/.gitnexus/groups//group.yaml` with a template. Errors if group exists (use `--force` to overwrite). + +#### `gitnexus group add ` + +Adds a repo to a group. `` is a name from registry.json, `` is the path in group hierarchy. + +Validations: +- `` must exist in registry.json +- `` must not duplicate within group +- A repo can be in multiple groups (e.g., shared lib) + +#### `gitnexus group remove ` + +Removes a repo from a group. Prompts to re-sync. + +#### `gitnexus group sync [flags]` + +Main command — runs contract extraction and matching cascade. + +``` +$ gitnexus group sync company + +Syncing group "company" (6 repos)... + + [1/6] hr/hiring/backend 12 contracts (8 provider, 4 consumer) + [2/6] hr/hiring/ui 7 contracts (0 provider, 7 consumer) + ... + +Matching cascade: + exact: 18 cross-links (confidence 1.0) + bm25: 4 cross-links (confidence 0.85-0.95) + embedding: 2 cross-links (confidence 0.62-0.78) + unmatched: 3 contracts + +Wrote ~/.gitnexus/groups/company/contracts.json (41 contracts, 24 cross-links) +``` + +Flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--skip-embeddings` | false | Exact + BM25 only | +| `--exact-only` | false | Exact match only | +| `--force-embeddings` | false | Regenerate embeddings.bin | +| `--allow-stale` | false | Skip stale index warnings | +| `--verbose` | false | Show each cross-link detail | +| `--json` | false | JSON output | + +**Stale index detection** uses the same heuristic as the existing staleness system ([staleness.ts:20](gitnexus/src/mcp/staleness.ts)): compare `meta.json.lastCommit` vs `git rev-parse HEAD`. This is commit-based, not time-based — consistent with the staleness heuristic defined in Section 2 for `contracts.json` (which compares `repoSnapshots[repo].indexedAt` against current `meta.json.indexedAt`). Two complementary checks: + +1. **Repo index staleness** (commit-based): is the repo's own index behind HEAD? → warn before extraction +2. **Contract Registry staleness** (indexedAt-based): was the repo re-indexed since last `group sync`? → warn during `group_impact` + +**Missing repo handling:** If a repo listed in `group.yaml` is not found in `registry.json` (not indexed, deleted, different machine), sync **skips it with a warning** and continues with remaining repos. The missing repo is listed in the sync summary. Contracts from a missing repo are **dropped** from the regenerated `contracts.json` (since sync is a full rebuild, not a patch). The missing repo is recorded in a top-level `missingRepos` array in `contracts.json` for transparency: + +```json +{ + "missingRepos": ["sales/admin/bff"], + ... +} +``` + +#### `gitnexus group list [name]` + +Without argument — all groups. With name — details including repo list and subgroup tree. + +#### `gitnexus group contracts ` + +Debug/inspect view of Contract Registry. Flags: `--type`, `--repo`, `--unmatched`, `--json`. + +#### `gitnexus group impact [flags]` + +``` +$ gitnexus group impact company --target UserDTO --repo hr/hiring/backend + +Target: UserDTO (hr/hiring/backend) +Risk: HIGH (cross-repo hits in 2 repos) + +Local (hr/hiring/backend): + d=1 WILL BREAK: + UserController.getUser src/controller/UserController.java:42 + ... + +Cross-repo: + hr/hiring/ui (via http::GET::/api/v2/users/{param}, exact, conf=1.0): + d=1 WILL BREAK: + fetchUser src/api/users.ts:18 + d=2 LIKELY AFFECTED: + UserProfile src/components/UserProfile.tsx:7 + + hr/payroll/backend (via topic::employee.updated, bm25, conf=0.88): + d=1 WILL BREAK: + EmployeeEventHandler src/events/EmployeeEventHandler.java:31 +``` + +Flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--target` | required | Symbol name | +| `--repo` | required | Repo in group (path or registry name) | +| `--direction` | upstream | upstream / downstream | +| `--cross-depth` | 1 | Hops through boundaries (MVP: capped at 1) | +| `--max-depth` | 3 | Max depth within each repo | +| `--min-confidence` | 0.5 | Min confidence for cross-links | +| `--subgroup` | (all) | Limit fan-out scope: `--subgroup hr/hiring` | +| `--timeout` | 30000 | Total wall time budget in ms | +| `--json` | false | JSON output | + +#### `gitnexus group query ` + +Fan-out of existing `query` across all repos in group, results merged via RRF, grouped by repo path. + +#### `gitnexus group status ` + +Quick health check — shows staleness of Contract Registry relative to repo indexes: + +``` +$ gitnexus group status company + +Group: company (last sync: 2026-03-31T10:00:00Z) + + Repo index staleness (meta.lastCommit vs HEAD): + hr/hiring/backend OK (index at HEAD 5838fb8d) + hr/hiring/ui STALE (index at a1b2c3d4, HEAD is e5f6g7h8 — 2 commits behind) + hr/payroll/backend OK + + Contract Registry staleness (repoSnapshot.indexedAt vs meta.indexedAt): + hr/hiring/backend OK (sync matches index) + hr/hiring/ui STALE (re-indexed after last sync) + hr/payroll/backend OK + + Missing repos: + sales/admin/bff MISSING (not in registry.json) +``` + +#### Subgroup Boundary Behavior + +When `--subgroup hr/hiring` is specified, fan-out only follows cross-links where the **target repo** is within the subgroup. Cross-links pointing outside (e.g., from `hr/hiring/backend` to `hr/payroll/backend`) are **not followed** but are listed in the output as "out-of-scope" for transparency. + +### MCP Tools + +| MCP Tool | Parameters | Description | +|----------|-----------|-------------| +| `group_list` | `name?` | List groups or details of one | +| `group_sync` | `name`, `skipEmbeddings?`, `exactOnly?` | Sync Contract Registry | +| `group_contracts` | `name`, `type?`, `repo?`, `unmatchedOnly?` | Show contracts and cross-links | +| `group_impact` | `name`, `target`, `repo`, `direction?`, `crossDepth?`, `maxDepth?`, `minConfidence?`, `subgroup?`, `timeout?` | Cross-index blast radius | +| `group_query` | `name`, `query`, `subgroup?`, `limit?` | Search flows across group | +| `group_status` | `name` | Staleness check for group and repos | + +Mutating operations (`group_create`, `group_add`, `group_remove`) are CLI-only — not exposed as MCP tools. + +### AI Context Integration (CLAUDE.md + AGENTS.md) + +When groups exist, `gitnexus analyze` appends to both `CLAUDE.md` and `AGENTS.md` (consistent with the existing generation pattern in [ai-context.ts:293](gitnexus/src/cli/ai-context.ts)): + +```markdown +## Cross-Repo Groups + +This repo is part of group **company** as `hr/hiring/backend`. +Use `group_impact` instead of `impact` when changes may affect other repos in the group. +``` + +--- + +## Section 7: Limitations and Future Work + +### Known Limitations (MVP) + +1. **Single hop only (MVP)** — cross-boundary traversal is capped at 1 hop. `--cross-depth` flag is accepted but values >1 are ignored with a message. Full E2E chain (UI -> BFF -> Backend -> ML) requires a future `group trace` command. +2. **Contract Registry is full-rebuild** — `group sync` regenerates entirely. Incremental sync (only re-extract changed repos) is a future optimization. +3. **LadybugDB pool limit** — max 5 databases open simultaneously. Groups with 10+ repos will see sequential fan-out with queuing. +4. **Runtime-only connections** — service discovery, feature flags, A/B routing are invisible to static analysis. +5. **Embedding quality for short names** — symbol names like `IUser` vs `UserDTO` may not embed well without field context. +6. **No unified Cypher** — cannot run a single Cypher query across the entire group (each repo has its own database). + +### Future Work + +- **Incremental sync** — detect which repos changed since last sync, re-extract only those +- **`group trace`** — full E2E flow tracing across multiple boundaries +- **Virtual Cypher** — Cypher-like query language that transparently fans out across group databases +- **CI integration** — `group impact` as a PR check ("this change affects 3 other repos") +- **OpenAPI/AsyncAPI import** — generate contracts from spec files instead of extracting from code +- **Dependency drift detection** — alert when a consumer accesses fields that the provider no longer returns +- **Web UI visualization** — graph view showing cross-repo connections in gitnexus-web + +### Demo PR Scope + +A minimal demonstration PR to validate the concept: + +1. **`group.yaml` parser** — read/validate group configuration +2. **`group list`** and **`group status`** CLI commands — show groups, repos, and staleness +3. **`group sync`** with exact-match only — extract HTTP contracts via source scan, build cross-links +4. **`group_impact`** MCP tool — Phase 1 (local) + Phase 2 (cross-boundary fan-out, exact match only) +5. **Tests** — integration test with two small fixture repos (frontend + backend) +6. **Test migration** — update tool/resource count assertions (see Section 9) + +BM25 and embedding matching are out of scope for the demo PR but designed in from the start. + +--- + +## Section 8: Implementation Prerequisites + +Changes required in GitNexus core before group features can function correctly. These should be separate PRs merged before the group feature PR. + +### P1: `impactByUid` — UID-based symbol resolution for impact + +**Problem:** Current `impact` resolves by name with `LIMIT 1` ([local-backend.ts:1347-1352](gitnexus/src/mcp/local/local-backend.ts)). For `group_impact` fan-out, the target symbol is known by UID from the Contract Registry. Name-based resolution creates false positives for common symbol names. + +**Change:** Add an internal `impactByUid(uid: string, direction: string, opts)` function alongside the existing name-based `impact`. The public MCP `impact` tool is unchanged — `impactByUid` is internal-only, called by `group_impact` during Phase 2. + +**Scope:** ~50 lines in `local-backend.ts`. No public API change. No schema change. + +### P2: Impact relationTypes runtime filter alignment + +**Problem:** Impact tool description documents 6 relation types as valid for `relationTypes` parameter: `CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`, `HAS_METHOD`, `OVERRIDES` ([tools.ts:203](gitnexus/src/mcp/tools.ts)). But runtime filter only allows 4: `CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS` ([local-backend.ts:50](gitnexus/src/mcp/local/local-backend.ts)). The additional types `HAS_METHOD` and `OVERRIDES` are silently ignored at runtime. + +**Change:** Expand runtime filter to accept the documented types (`HAS_METHOD`, `OVERRIDES`). This is existing tech debt, not introduced by this RFC, but should be resolved to avoid confusion when `group_impact` inherits the same filter. + +**Scope:** ~10 lines in `local-backend.ts`. + +### P3: (Future, not MVP) Route/FETCHES schema extension + +For full-fidelity HTTP contract extraction from the graph (without source scanning), the ingestion pipeline would need: +- `Route` node label in schema.ts +- `HANDLES_ROUTE` and `FETCHES` relation types in schema.ts +- Route extraction generalized beyond Laravel (Spring Boot, Express, FastAPI, etc.) +- Consumer-side fetch detection persisted as `FETCHES` edges + +This is a **significant change to the ingestion pipeline** and is NOT a prerequisite for MVP. The demo PR uses source-scan extraction instead. This is listed here as future optimization path — once the graph has this data, extractors can switch from source scan to Cypher queries (faster, more accurate). + +--- + +## Section 9: Test Migration Plan + +### Affected test guards + +Adding group CLI commands and MCP tools will break existing shape assertions: + +| Test | Current assertion | After change | +|------|-------------------|--------------| +| [tools.test.ts:14](gitnexus/test/unit/tools.test.ts) | Tool count = 7 | Tool count = 7 + 6 group tools = 13 | +| [resources.test.ts:40](gitnexus/test/unit/resources.test.ts) | Resource count assertion | Unchanged (no new resources) | +| [resources.test.ts:66](gitnexus/test/unit/resources.test.ts) | Resource template count assertion | Unchanged | + +### New tests required + +| Test | Type | Description | +|------|------|-------------| +| `group-config.test.ts` | Unit | Parse/validate group.yaml, handle missing repos, nested paths | +| `contract-extractor.test.ts` | Unit | Each extractor against fixture source files | +| `matching-cascade.test.ts` | Unit | Exact match, BM25 relative scoring, confidence mapping | +| `group-impact.test.ts` | Integration | Two fixture repos (TS frontend + Java backend), end-to-end group_impact | +| `group-cli.test.ts` | Integration | CLI commands: create, add, list, status, sync | +| `group-tools.test.ts` | Unit | MCP tool registration, parameter validation | + +### Fixture repos for integration tests + +Two minimal repos checked into `test/fixtures/group/`: +- `test-frontend/` — TypeScript, contains `fetch('/api/users')` call +- `test-backend/` — Java/TypeScript, contains route handler for `/api/users` + +Both pre-indexed with `.gitnexus/` directories committed as test fixtures. diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 70ca9537a..d0e5a6ada 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -157,31 +157,143 @@ export function registerGroupCommands(program: Command): void { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); const { loadGroupConfig } = await import('../core/group/config-parser.js'); const { syncGroup } = await import('../core/group/sync.js'); + const { closeLbug } = await import('../core/lbug/pool-adapter.js'); + + try { + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + const config = await loadGroupConfig(groupDir); + + console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + + const result = await syncGroup(config, { + groupDir, + allowStale: Boolean(opts.allowStale), + verbose: Boolean(opts.verbose), + skipEmbeddings: Boolean(opts.skipEmbeddings), + exactOnly: Boolean(opts.exactOnly), + }); + + if (opts.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`\nMatching cascade:`); + const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); + console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); + console.log(` unmatched: ${result.unmatched.length} contracts`); + console.log( + `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + ); + } + } finally { + await closeLbug().catch(() => {}); + } + }); + + group + .command('impact ') + .description('Cross-index blast radius analysis') + .requiredOption('--target ', 'Symbol name to analyze') + .requiredOption('--repo ', 'Repo group path (e.g. hr/hiring/backend)') + .option('--direction ', 'upstream or downstream', 'upstream') + .option('--cross-depth ', 'Hops through boundaries (MVP: capped at 1)', '1') + .option('--max-depth ', 'Max depth within each repo', '3') + .option('--min-confidence ', 'Min confidence for cross-links', '0.5') + .option('--subgroup ', 'Limit fan-out scope') + .option('--timeout ', 'Total wall time budget in ms', '30000') + .option('--json', 'JSON output') + .action(async (name: string, opts: Record) => { + const { getGroupDir, getDefaultGitnexusDir, readContractRegistry } = + await import('../core/group/storage.js'); + const { LocalBackend } = await import('../mcp/local/local-backend.js'); const groupDir = getGroupDir(getDefaultGitnexusDir(), name); - const config = await loadGroupConfig(groupDir); + const regFile = await readContractRegistry(groupDir); + if (!regFile) { + console.error(`No contracts.json found. Run: gitnexus group sync ${name}`); + process.exitCode = 1; + return; + } - console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`); + const repoGroupPath = opts.repo as string; + const targetSymbol = opts.target as string; + if (!repoGroupPath || !targetSymbol) { + console.error('Both --target and --repo are required.'); + process.exitCode = 1; + return; + } + const direction = (opts.direction as string) ?? 'upstream'; + const maxDepth = opts.maxDepth != null ? parseInt(String(opts.maxDepth), 10) : 3; + const minConfidence = + opts.minConfidence != null ? parseFloat(String(opts.minConfidence)) : 0.5; + const timeout = opts.timeout != null ? parseInt(String(opts.timeout), 10) : 30000; + const subgroup = opts.subgroup as string | undefined; + const requestedCrossDepth = + opts.crossDepth != null ? parseInt(String(opts.crossDepth), 10) : 1; + const crossDepth = Math.min(requestedCrossDepth, 1); - const result = await syncGroup(config, { - groupDir, - allowStale: Boolean(opts.allowStale), - verbose: Boolean(opts.verbose), - skipEmbeddings: Boolean(opts.skipEmbeddings), - exactOnly: Boolean(opts.exactOnly), - }); + const crossDepthWarning = + requestedCrossDepth > 1 + ? `Multi-hop cross-boundary traversal is not yet implemented. Using --cross-depth 1 (requested: ${requestedCrossDepth}).` + : undefined; - if (opts.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(`\nMatching cascade:`); - const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact'); - console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`); - console.log(` unmatched: ${result.unmatched.length} contracts`); + if (crossDepthWarning && !opts.json) { + console.log(`WARNING: ${crossDepthWarning}\n`); + } + + if (!opts.json) { console.log( - `\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`, + `Analyzing impact of "${targetSymbol}" in ${repoGroupPath} (group: ${name})...\n`, ); } + + const backend = new LocalBackend(); + try { + await backend.init(); + const result = (await backend.getGroupService().groupImpact({ + name, + target: targetSymbol, + repo: repoGroupPath, + direction, + crossDepth, + maxDepth, + minConfidence, + subgroup, + timeout, + })) as import('../core/group/types.js').GroupImpactResult & { crossDepthWarning?: string }; + + if (opts.json) { + const jsonOutput = crossDepthWarning ? { ...result, crossDepthWarning } : result; + console.log(JSON.stringify(jsonOutput, null, 2)); + } else { + console.log(`Target: ${targetSymbol} (${repoGroupPath})`); + console.log(`Risk: ${result.risk}`); + console.log(`\nLocal impact: ${result.summary.direct} direct callers`); + if (result.cross.length > 0) { + console.log(`\nCross-repo impact (${result.cross.length} repos):`); + for (const cr of result.cross) { + console.log( + ` ${cr.repo_path} (via ${cr.contract.id}, ${cr.contract.match_type}, conf=${cr.contract.confidence}):`, + ); + for (const [depth, symbols] of Object.entries(cr.by_depth)) { + console.log(` d=${depth}: ${(symbols as unknown[]).length} symbols`); + } + } + } + if (result.outOfScope.length > 0) { + console.log(`\nOut of scope (${result.outOfScope.length} cross-links not followed):`); + for (const oos of result.outOfScope) { + console.log(` ${oos.from} -> ${oos.to} [${oos.contractId}]`); + } + } + if (result.truncated) { + console.log( + `\nWARNING: Timeout reached. Repos not analyzed: ${result.truncatedRepos.join(', ')}`, + ); + } + } + } finally { + await backend.dispose().catch(() => {}); + } }); group diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts new file mode 100644 index 000000000..de73eba65 --- /dev/null +++ b/gitnexus/src/core/group/cross-impact.ts @@ -0,0 +1,225 @@ +import type { + ContractRegistry, + CrossLink, + GroupImpactResult, + CrossRepoImpact, + OutOfScopeLink, +} from './types.js'; + +export interface GroupImpactOptions { + groupName: string; + target: string; + repoPath: string; + direction: 'upstream' | 'downstream'; + registry: ContractRegistry; + localImpactFn: (target: string, direction: string) => Promise; + crossImpactFn: ( + targetGroupPath: string, + symbolUid: string, + direction: string, + ) => Promise; + maxDepth?: number; + minConfidence?: number; + subgroup?: string; + timeout?: number; + crossDepth?: number; +} + +function collectPhase1Uids(local: Record): Set { + const uids = new Set(); + const target = local.target as { id?: string } | undefined; + if (target?.id) uids.add(String(target.id)); + const byDepth = (local.byDepth || {}) as Record; + for (const arr of Object.values(byDepth)) { + for (const item of arr || []) { + if (item?.id) uids.add(String(item.id)); + } + } + return uids; +} + +function refKey(filePath: string, name: string): string { + return `${filePath}::${name}`; +} + +function collectPhase1Refs(local: Record): Set { + const refs = new Set(); + const t = local.target as { filePath?: string; name?: string } | undefined; + if (t?.filePath?.length && t?.name?.length) refs.add(refKey(t.filePath, t.name)); + const byDepth = (local.byDepth || {}) as Record; + for (const arr of Object.values(byDepth)) { + for (const item of arr || []) { + if (item.filePath?.length && item.name?.length) refs.add(refKey(item.filePath, item.name)); + } + } + return refs; +} + +function linkMatchesRefs( + link: CrossLink, + refs: Set, + direction: 'upstream' | 'downstream', +): boolean { + if (direction === 'upstream') { + const r = link.to.symbolRef; + if (!r.filePath?.length || !r.name?.length) return false; + return refs.has(refKey(r.filePath, r.name)); + } + const r = link.from.symbolRef; + if (!r.filePath?.length || !r.name?.length) return false; + return refs.has(refKey(r.filePath, r.name)); +} + +function inSubgroup(repoPath: string, subgroup?: string): boolean { + if (!subgroup?.trim()) return true; + const s = subgroup.replace(/\/+$/, ''); + return repoPath === s || repoPath.startsWith(`${s}/`); +} + +function mergeRisk( + base: string, + crossHits: number, + maxCrossConf: number, + distinctCrossRepos: number, +): string { + const order = ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']; + let idx = Math.max(0, order.indexOf(base)); + + if (crossHits > 0 && maxCrossConf >= 0.85) { + idx = Math.max(idx, order.indexOf('HIGH')); + } else if (crossHits > 0 && maxCrossConf > 0) { + idx = Math.max(idx, order.indexOf('MEDIUM')); + } + if (distinctCrossRepos >= 3) { + idx = Math.max(idx, order.indexOf('CRITICAL')); + } + + return order[idx] ?? base; +} + +export async function runGroupImpact(opts: GroupImpactOptions): Promise { + const timeout = opts.timeout ?? 30000; + const minConfidence = opts.minConfidence ?? 0.5; + const crossDepth = Math.min(1, opts.crossDepth ?? 1); + + const tStart = Date.now(); + const wallDeadline = tStart + timeout; + const phase1Timeout = Math.min(5000, timeout); + + const localResult = await Promise.race([ + opts.localImpactFn(opts.target, opts.direction).then((v) => ({ ok: true as const, v })), + new Promise<{ ok: false }>((resolve) => + setTimeout(() => resolve({ ok: false }), phase1Timeout), + ), + ]); + + let truncated = !localResult.ok; + const local = localResult.ok + ? (localResult.v as Record) + : ({ + target: { id: '', name: opts.target, filePath: '' }, + direction: opts.direction, + impactedCount: 0, + risk: 'LOW', + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: {}, + } as Record); + + const uids = collectPhase1Uids(local); + const phase1Refs = collectPhase1Refs(local); + const cross: CrossRepoImpact[] = []; + const outOfScope: OutOfScopeLink[] = []; + const truncatedRepos: string[] = []; + + const links = [...opts.registry.crossLinks] + .filter((l) => l.confidence >= minConfidence) + .sort((a, b) => b.confidence - a.confidence); + + const applicable: CrossLink[] = []; + for (const link of links) { + const uidMatch = + opts.direction === 'upstream' + ? Boolean(link.to.symbolUid && uids.has(link.to.symbolUid)) + : Boolean(link.from.symbolUid && uids.has(link.from.symbolUid)); + const refMatch = !uidMatch && linkMatchesRefs(link, phase1Refs, opts.direction); + if (!uidMatch && !refMatch) continue; + applicable.push(link); + } + + let maxCrossConf = 0; + const distinctRepos = new Set(); + + for (const link of applicable) { + if (Date.now() > wallDeadline) { + truncated = true; + break; + } + + const fanOutRepo = opts.direction === 'upstream' ? link.from.repo : link.to.repo; + const symbolUid = opts.direction === 'upstream' ? link.from.symbolUid : link.to.symbolUid; + + if (!inSubgroup(fanOutRepo, opts.subgroup)) { + outOfScope.push({ + from: link.from.repo, + to: link.to.repo, + contractId: link.contractId, + confidence: link.confidence, + }); + continue; + } + + if (crossDepth < 1) break; + + const remote = await opts.crossImpactFn(fanOutRepo, symbolUid, opts.direction); + if (remote) { + maxCrossConf = Math.max(maxCrossConf, link.confidence); + distinctRepos.add(fanOutRepo); + const r = remote as Record; + cross.push({ + repo: fanOutRepo, + repo_path: fanOutRepo, + contract: { + id: link.contractId, + type: link.type, + match_type: link.matchType, + confidence: link.confidence, + }, + by_depth: (r.byDepth || {}) as Record, + affected_processes: (r.affected_processes || []) as string[], + }); + } + + if (Date.now() > wallDeadline) { + truncated = true; + truncatedRepos.push(fanOutRepo); + break; + } + } + + const summaryLocal = (local.summary || {}) as { + direct?: number; + processes_affected?: number; + modules_affected?: number; + }; + + const baseRisk = String(local.risk || 'LOW'); + const risk = mergeRisk(baseRisk, cross.length, maxCrossConf, distinctRepos.size); + + return { + local, + group: opts.groupName, + cross, + outOfScope, + truncated, + truncatedRepos, + summary: { + direct: summaryLocal.direct ?? 0, + processes_affected: summaryLocal.processes_affected ?? 0, + modules_affected: summaryLocal.modules_affected ?? 0, + cross_repo_hits: cross.length, + }, + risk, + }; +} diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts new file mode 100644 index 000000000..d1461cbe4 --- /dev/null +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -0,0 +1,124 @@ +import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js'; +import type { CypherExecutor } from '../contract-extractor.js'; + +export interface ManifestExtractResult { + contracts: StoredContract[]; + crossLinks: CrossLink[]; +} + +export class ManifestExtractor { + async extractFromManifest( + links: GroupManifestLink[], + dbExecutors?: Map, + ): Promise { + const contracts: StoredContract[] = []; + const crossLinks: CrossLink[] = []; + + for (const link of links) { + const contractId = this.buildContractId(link.type, link.contract); + + const providerRepo = link.role === 'provider' ? link.from : link.to; + const consumerRepo = link.role === 'provider' ? link.to : link.from; + + const providerSymbol = await this.resolveSymbol(providerRepo, link, dbExecutors); + const consumerSymbol = await this.resolveSymbol(consumerRepo, link, dbExecutors); + const providerRef = providerSymbol || { filePath: '', name: link.contract }; + const consumerRef = consumerSymbol || { filePath: '', name: link.contract }; + const providerUid = providerSymbol?.uid ?? ''; + const consumerUid = consumerSymbol?.uid ?? ''; + + contracts.push({ + contractId, + type: link.type, + role: 'provider', + symbolUid: providerUid, + symbolRef: providerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: providerRepo, + }); + + contracts.push({ + contractId, + type: link.type, + role: 'consumer', + symbolUid: consumerUid, + symbolRef: consumerRef, + symbolName: link.contract, + confidence: 1.0, + meta: { source: 'manifest' }, + repo: consumerRepo, + }); + + crossLinks.push({ + from: { repo: consumerRepo, symbolUid: consumerUid, symbolRef: consumerRef }, + to: { repo: providerRepo, symbolUid: providerUid, symbolRef: providerRef }, + type: link.type, + contractId, + matchType: 'manifest', + confidence: 1.0, + }); + } + + return { contracts, crossLinks }; + } + + private async resolveSymbol( + repoPathKey: string, + link: GroupManifestLink, + dbExecutors?: Map, + ): Promise<{ filePath: string; name: string; uid: string } | null> { + const executor = dbExecutors?.get(repoPathKey); + if (!executor) return null; + + try { + let rows: Record[]; + if (link.type === 'http') { + rows = await executor( + `MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route) + WHERE route.name CONTAINS $contract + RETURN handler.id AS uid, handler.name AS name, handler.filePath AS filePath + LIMIT 1`, + { contract: link.contract }, + ); + } else if (link.type === 'topic') { + rows = await executor( + `MATCH (n) WHERE n.name CONTAINS $contract + RETURN n.id AS uid, n.name AS name, n.filePath AS filePath + LIMIT 1`, + { contract: link.contract }, + ); + } else { + return null; + } + if (rows.length > 0) { + return { + filePath: rows[0].filePath as string, + name: rows[0].name as string, + uid: String(rows[0].uid ?? ''), + }; + } + } catch { + /* fall through */ + } + return null; + } + + private buildContractId(type: ContractType, contract: string): string { + switch (type) { + case 'http': { + if (/^[A-Za-z]+::/.test(contract)) return `http::${contract}`; + return `http::*::${contract}`; + } + case 'grpc': + return `grpc::${contract}`; + case 'topic': + return `topic::${contract}`; + case 'lib': + return `lib::${contract}`; + case 'custom': + return `custom::${contract}`; + } + } +} diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 1530cd6dd..58bf97a9e 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -1,10 +1,11 @@ /** - * Group orchestration shared by MCP (LocalBackend) and CLI. + * Cross-repo group orchestration shared by MCP (LocalBackend) and CLI. * DB access is injected via GroupToolPort so this module stays free of LocalBackend private API. */ import { checkStaleness } from '../git-staleness.js'; import { loadGroupConfig } from './config-parser.js'; +import { runGroupImpact } from './cross-impact.js'; import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js'; import { syncGroup } from './sync.js'; @@ -122,6 +123,98 @@ export class GroupService { return { contracts, crossLinks: registry.crossLinks }; } + async groupImpact(params: Record): Promise { + const name = String(params.name ?? '').trim(); + const targetSymbol = String(params.target ?? '').trim(); + const repoGroupPath = String(params.repo ?? '').trim(); + if (!name || !targetSymbol || !repoGroupPath) { + return { error: 'name, target, and repo are required' }; + } + + const direction = (params.direction as string) === 'downstream' ? 'downstream' : 'upstream'; + const maxDepth = + typeof params.maxDepth === 'number' && Number.isFinite(params.maxDepth) ? params.maxDepth : 3; + const minConfidence = + typeof params.minConfidence === 'number' && Number.isFinite(params.minConfidence) + ? params.minConfidence + : 0.5; + const timeout = + typeof params.timeout === 'number' && Number.isFinite(params.timeout) + ? params.timeout + : 30000; + const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined; + const groupDir = getGroupDir(getDefaultGitnexusDir(), name); + + const config = await loadGroupConfig(groupDir); + const registry = await readContractRegistry(groupDir); + if (!registry) { + return { error: `No contracts.json for group "${name}". Run group_sync first.` }; + } + + const requestedCrossDepth = + typeof params.crossDepth === 'number' && Number.isFinite(params.crossDepth) + ? params.crossDepth + : 1; + const crossDepth = Math.min(requestedCrossDepth, 1); + const crossDepthWarning = + requestedCrossDepth > 1 + ? `Multi-hop cross-boundary traversal is not yet implemented. Using --cross-depth 1 (requested: ${requestedCrossDepth}).` + : undefined; + + const defaultRelTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']; + + const resolveGroupRepo = async (groupPath: string): Promise => { + const registryName = config.repos[groupPath]; + if (!registryName) throw new Error(`Repo "${groupPath}" not found in group "${name}"`); + return this.port.resolveRepo(registryName); + }; + + const result = await runGroupImpact({ + groupName: name, + target: targetSymbol, + repoPath: repoGroupPath, + direction, + registry, + localImpactFn: async (t: string, d: string) => { + const repoObj = await resolveGroupRepo(repoGroupPath); + return this.port.impact(repoObj, { + target: t, + direction: d as 'upstream' | 'downstream', + maxDepth, + relationTypes: defaultRelTypes, + minConfidence: 0, + includeTests: false, + }); + }, + crossImpactFn: async (targetGroupPath: string, uid: string, d: string) => { + const registryName = config.repos[targetGroupPath]; + if (!registryName) return null; + try { + const repoObj = await this.port.resolveRepo(registryName); + return this.port.impactByUid(repoObj.id, uid, d, { + maxDepth, + relationTypes: defaultRelTypes, + minConfidence: 0, + includeTests: false, + }); + } catch { + return null; + } + }, + maxDepth, + minConfidence, + subgroup, + timeout, + crossDepth, + }); + + if (crossDepthWarning) { + (result as unknown as Record).crossDepthWarning = crossDepthWarning; + } + + return result; + } + async groupQuery(params: Record): Promise { const name = String(params.name ?? '').trim(); const queryText = String(params.query ?? '').trim(); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 92cd9fe5f..5236f2eff 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -7,6 +7,7 @@ import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; +import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { runExactMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; @@ -65,10 +66,12 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis const repoSnapshots: Record = {}; let autoContracts: StoredContract[] = []; let dbExecutors: Map | undefined; + let manifestResult: Awaited>; const eo = opts?.extractorOverride; if (eo && eo.length === 0) { autoContracts = await (eo as () => Promise)(); + manifestResult = await new ManifestExtractor().extractFromManifest(config.links); } else { const entries = await readRegistry(); const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); @@ -151,6 +154,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis missingRepos.push(groupPath); } } + + manifestResult = await new ManifestExtractor().extractFromManifest(config.links, dbExecutors); } finally { for (const id of [...new Set(openPoolIds)]) { await closeLbug(id).catch(() => {}); @@ -159,8 +164,8 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } const { matched, unmatched } = runExactMatch(autoContracts); - const crossLinks: CrossLink[] = matched; - const allContracts: StoredContract[] = autoContracts; + const crossLinks: CrossLink[] = [...manifestResult.crossLinks, ...matched]; + const allContracts: StoredContract[] = [...manifestResult.contracts, ...autoContracts]; const registry: ContractRegistry = { version: 1, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 8f8b4d94b..a8537d411 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -2447,6 +2447,8 @@ export class LocalBackend { return this.groupSync(params); case 'group_contracts': return this.groupContracts(params); + case 'group_impact': + return this.groupImpact(params); case 'group_query': return this.groupQuery(params); case 'group_status': @@ -2468,6 +2470,11 @@ export class LocalBackend { return this.getGroupService().groupContracts(params); } + private async groupImpact(params: Record): Promise { + await this.refreshRepos(); + return this.getGroupService().groupImpact(params); + } + private async groupQuery(params: Record): Promise { await this.refreshRepos(); return this.getGroupService().groupQuery(params); diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index c74b19464..4fdf45606 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -381,7 +381,7 @@ Returns: single route object when one match, or { routes: [...], total: N } for name: 'group_list', description: `List all configured repository groups, or return details for one group (repos, manifest links). -WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single group's config.`, +WHEN TO USE: Discover groups before group_sync or group_impact. Optional "name" returns a single group's config.`, inputSchema: { type: 'object', properties: { @@ -424,6 +424,44 @@ WHEN TO USE: Debug cross-repo links after group_sync.`, required: ['name'], }, }, + { + name: 'group_impact', + description: `Cross-repository blast radius: local impact in the source repo, then one-hop fan-out via Contract Registry (exact/manifest links). + +WHEN TO USE: When a symbol may affect other repos in the same group. Multi-hop cross-boundary is not implemented; crossDepth is capped at 1.`, + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Group name' }, + target: { type: 'string', description: 'Symbol name (same as impact tool)' }, + repo: { + type: 'string', + description: 'Group path of the source repo (e.g. hr/hiring/backend)', + }, + direction: { + type: 'string', + description: 'upstream or downstream', + enum: ['upstream', 'downstream'], + }, + crossDepth: { + type: 'number', + description: + 'Cross-boundary hops (MVP: capped at 1; values above 1 are ignored with a warning)', + }, + maxDepth: { type: 'number', description: 'Max graph depth within each repo (default 3)' }, + minConfidence: { + type: 'number', + description: 'Minimum cross-link confidence (default 0.5)', + }, + subgroup: { + type: 'string', + description: 'Only fan out into repos under this group path prefix', + }, + timeout: { type: 'number', description: 'Wall-clock budget in ms (default 30000)' }, + }, + required: ['name', 'target', 'repo'], + }, + }, { name: 'group_query', description: `Run the query tool across all repos in a group and merge process results via reciprocal rank fusion. diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts index 02da904dc..20c9fcdf3 100644 --- a/gitnexus/test/integration/group/group-cli.test.ts +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -49,20 +49,4 @@ describe('group CLI', () => { expect(l.status).toBe(0); expect(l.stdout).toContain('acme'); }); - - it('test_create_with_invalid_name_fails', () => { - const result = runGroup(['create', '../../evil']); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain('Invalid group name'); - }); - - it('test_sync_command_source_does_not_call_blanket_closeLbug', () => { - const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts'); - const source = fs.readFileSync(cliGroupPath, 'utf-8'); - - // closeLbug() without arguments (blanket close) must not appear. - // Match closeLbug() but not closeLbug(someArg) - const blanketClosePattern = /closeLbug\s*\(\s*\)/; - expect(source).not.toMatch(blanketClosePattern); - }); }); diff --git a/gitnexus/test/integration/group/group-impact.test.ts b/gitnexus/test/integration/group/group-impact.test.ts new file mode 100644 index 000000000..a6fca44df --- /dev/null +++ b/gitnexus/test/integration/group/group-impact.test.ts @@ -0,0 +1,75 @@ +/** + * Group impact wiring — mocks `localImpactFn` / `crossImpactFn`. E2E with real graphs is a follow-up. + */ +import { describe, it, expect } from 'vitest'; +import { runGroupImpact } from '../../../src/core/group/cross-impact.js'; +import type { ContractRegistry } from '../../../src/core/group/types.js'; + +function minimalRegistry(crossLinks: ContractRegistry['crossLinks']): ContractRegistry { + return { + version: 1, + generatedAt: new Date().toISOString(), + repoSnapshots: {}, + missingRepos: [], + contracts: [], + crossLinks, + }; +} + +describe('Group impact integration', () => { + it('runs phase 1 and fan-out when cross-link matches UID', async () => { + const registry = minimalRegistry([ + { + from: { + repo: 'app/frontend', + symbolUid: 'remote-1', + symbolRef: { filePath: 'f.ts', name: 'x' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'local-target', + symbolRef: { filePath: 'b.ts', name: 'y' }, + }, + type: 'http', + contractId: 'http::GET::/x', + matchType: 'exact', + confidence: 1.0, + }, + ]); + + const localImpactFn = async () => ({ + target: { id: 'local-target', name: 'T', filePath: 'b.ts' }, + direction: 'upstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { '1': [{ id: 'local-target', name: 'T', filePath: 'b.ts' }] }, + }); + + let fanOutCalls = 0; + const crossImpactFn = async (groupPath: string, uid: string, _direction: string) => { + fanOutCalls++; + expect(groupPath).toBe('app/frontend'); + expect(uid).toBe('remote-1'); + return { byDepth: {}, affected_processes: [] }; + }; + + const result = await runGroupImpact({ + groupName: 'g', + target: 'T', + repoPath: 'app/backend', + direction: 'upstream', + registry, + localImpactFn, + crossImpactFn, + crossDepth: 1, + timeout: 5000, + }); + + expect(result.cross.length).toBe(1); + expect(fanOutCalls).toBe(1); + expect(result.summary.cross_repo_hits).toBe(1); + }); +}); diff --git a/gitnexus/test/unit/group/cross-impact.test.ts b/gitnexus/test/unit/group/cross-impact.test.ts new file mode 100644 index 000000000..67cb26e9a --- /dev/null +++ b/gitnexus/test/unit/group/cross-impact.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest'; +import { runGroupImpact } from '../../../src/core/group/cross-impact.js'; +import type { ContractRegistry } from '../../../src/core/group/types.js'; + +describe('runGroupImpact', () => { + const mockRegistry: ContractRegistry = { + version: 1, + generatedAt: '2026-03-31T10:00:00Z', + repoSnapshots: { + 'app/backend': { indexedAt: '2026-03-31T09:00:00Z', lastCommit: 'abc123' }, + 'app/frontend': { indexedAt: '2026-03-31T09:00:00Z', lastCommit: 'def456' }, + }, + missingRepos: [], + contracts: [], + crossLinks: [ + { + from: { + repo: 'app/frontend', + symbolUid: 'uid-fetch', + symbolRef: { filePath: 'src/api.ts', name: 'fetchUsers' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'uid-ctrl', + symbolRef: { filePath: 'src/ctrl.ts', name: 'UserController.list' }, + }, + type: 'http', + contractId: 'http::GET::/api/users', + matchType: 'exact', + confidence: 1.0, + }, + ], + }; + + it('returns local impact when no cross-links match', async () => { + const result = await runGroupImpact({ + groupName: 'test', + target: 'SomeUnrelatedFn', + repoPath: 'app/backend', + direction: 'upstream', + registry: mockRegistry, + localImpactFn: async () => ({ + target: { id: 'uid-x', name: 'SomeUnrelatedFn', filePath: 'src/x.ts' }, + direction: 'upstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { '1': [{ id: 'uid-y', name: 'CallerFn', filePath: 'src/y.ts' }] }, + }), + crossImpactFn: async () => null, + }); + + expect(result.cross).toHaveLength(0); + expect(result.summary.cross_repo_hits).toBe(0); + expect(result.risk).toBe('LOW'); + }); + + it('fans out through cross-links for upstream direction', async () => { + const result = await runGroupImpact({ + groupName: 'test', + target: 'UserController.list', + repoPath: 'app/backend', + direction: 'upstream', + registry: mockRegistry, + localImpactFn: async () => ({ + target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }, + direction: 'upstream', + impactedCount: 2, + risk: 'LOW', + summary: { direct: 2, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { + '1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }], + }, + }), + crossImpactFn: async () => ({ + target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }, + direction: 'upstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { + '1': [ + { id: 'uid-profile', name: 'UserProfile', filePath: 'src/components/UserProfile.tsx' }, + ], + }, + }), + }); + + expect(result.cross).toHaveLength(1); + expect(result.cross[0].repo_path).toBe('app/frontend'); + expect(result.cross[0].contract.match_type).toBe('exact'); + expect(result.summary.cross_repo_hits).toBe(1); + expect(['HIGH', 'CRITICAL']).toContain(result.risk); + }); + + it('fans out for downstream direction (consumer repo → provider repo)', async () => { + const result = await runGroupImpact({ + groupName: 'test', + target: 'fetchUsers', + repoPath: 'app/frontend', + direction: 'downstream', + registry: mockRegistry, + localImpactFn: async () => ({ + target: { id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }, + direction: 'downstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { '1': [{ id: 'uid-fetch', name: 'fetchUsers', filePath: 'src/api.ts' }] }, + }), + crossImpactFn: async (groupPath, uid, _direction) => { + expect(groupPath).toBe('app/backend'); + expect(uid).toBe('uid-ctrl'); + expect(_direction).toBe('downstream'); + return { + byDepth: { + '1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }], + }, + affected_processes: [], + }; + }, + }); + + expect(result.cross).toHaveLength(1); + expect(result.cross[0].repo_path).toBe('app/backend'); + expect(result.summary.cross_repo_hits).toBe(1); + }); + + it('respects subgroup filter', async () => { + const result = await runGroupImpact({ + groupName: 'test', + target: 'UserController.list', + repoPath: 'app/backend', + direction: 'upstream', + registry: mockRegistry, + subgroup: 'other/team', + localImpactFn: async () => ({ + target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }, + direction: 'upstream', + impactedCount: 1, + risk: 'LOW', + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: { + '1': [{ id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }], + }, + }), + crossImpactFn: async () => null, + }); + + expect(result.cross).toHaveLength(0); + expect(result.outOfScope).toHaveLength(1); + expect(result.outOfScope[0].from).toBe('app/frontend'); + }); + + it('respects timeout and returns truncated result', async () => { + const result = await runGroupImpact({ + groupName: 'test', + target: 'UserController.list', + repoPath: 'app/backend', + direction: 'upstream', + timeout: 1, + registry: mockRegistry, + localImpactFn: async () => { + await new Promise((r) => setTimeout(r, 50)); + return { + target: { id: 'uid-ctrl', name: 'UserController.list', filePath: 'src/ctrl.ts' }, + direction: 'upstream', + impactedCount: 0, + risk: 'LOW', + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + byDepth: {}, + }; + }, + crossImpactFn: async () => null, + }); + + expect(result.truncated).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/group/group-tools.test.ts b/gitnexus/test/unit/group/group-tools.test.ts index e58077442..0c2b3a7cd 100644 --- a/gitnexus/test/unit/group/group-tools.test.ts +++ b/gitnexus/test/unit/group/group-tools.test.ts @@ -6,12 +6,13 @@ const GROUP_TOOL_NAMES = [ 'group_list', 'group_sync', 'group_contracts', + 'group_impact', 'group_query', 'group_status', ]; describe('Group MCP tools', () => { - it('all 5 group tools are registered', () => { + it('all 6 group tools are registered', () => { for (const name of GROUP_TOOL_NAMES) { const tool = GITNEXUS_TOOLS.find((t) => t.name === name); expect(tool, `tool ${name} should be registered`).toBeDefined(); @@ -20,8 +21,22 @@ describe('Group MCP tools', () => { } }); + it('group_impact requires name, target, repo', () => { + const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!; + expect(tool.inputSchema.required).toContain('name'); + expect(tool.inputSchema.required).toContain('target'); + expect(tool.inputSchema.required).toContain('repo'); + }); + it('group_sync requires name', () => { const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; expect(tool.inputSchema.required).toContain('name'); }); + + it('group_impact has crossDepth param with max 1 note in description', () => { + const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!; + const crossDepth = tool.inputSchema.properties.crossDepth as { description?: string }; + expect(crossDepth).toBeDefined(); + expect(crossDepth.description).toContain('capped at 1'); + }); }); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts new file mode 100644 index 000000000..27de3a4d6 --- /dev/null +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { ManifestExtractor } from '../../../src/core/group/extractors/manifest-extractor.js'; +import type { GroupManifestLink } from '../../../src/core/group/types.js'; + +describe('ManifestExtractor', () => { + const extractor = new ManifestExtractor(); + + it('creates provider + consumer contracts and a cross-link for each manifest link', async () => { + const links: GroupManifestLink[] = [ + { + from: 'hr/payroll/backend', + to: 'hr/hiring/backend', + type: 'topic', + contract: 'employee.hired', + role: 'provider', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + expect(result.contracts).toHaveLength(2); + + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider).toBeDefined(); + expect(provider!.contractId).toBe('topic::employee.hired'); + expect(provider!.type).toBe('topic'); + expect(provider!.confidence).toBe(1.0); + + const consumer = result.contracts.find((c) => c.role === 'consumer'); + expect(consumer).toBeDefined(); + expect(consumer!.contractId).toBe('topic::employee.hired'); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('manifest'); + expect(result.crossLinks[0].confidence).toBe(1.0); + expect(result.crossLinks[0].from.repo).toBe('hr/hiring/backend'); + expect(result.crossLinks[0].to.repo).toBe('hr/payroll/backend'); + }); + + it('handles role: consumer (from-repo is consumer)', async () => { + const links: GroupManifestLink[] = [ + { + from: 'sales/admin/bff', + to: 'sales/crm/backend', + type: 'http', + contract: '/api/v2/leads/*', + role: 'consumer', + }, + ]; + + const result = await extractor.extractFromManifest(links); + + const provider = result.contracts.find((c) => c.role === 'provider'); + const consumer = result.contracts.find((c) => c.role === 'consumer'); + + expect(consumer!.contractId).toBe('http::*::/api/v2/leads/*'); + expect(provider!.contractId).toBe('http::*::/api/v2/leads/*'); + + expect(result.crossLinks[0].from.repo).toBe('sales/admin/bff'); + expect(result.crossLinks[0].to.repo).toBe('sales/crm/backend'); + }); + + it('returns empty for no links', async () => { + const result = await extractor.extractFromManifest([]); + expect(result.contracts).toHaveLength(0); + expect(result.crossLinks).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 4274716a7..e8939fc96 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -2,7 +2,7 @@ * Unit Tests: MCP Tool Definitions * * Tests: GITNEXUS_TOOLS from tools.ts - * - All 16 tools are defined (per-repo + group_*) + * - All 17 tools are defined (per-repo + group_*) * - Each tool has valid name, description, inputSchema * - Required fields are correct * - Optional repo parameter is present on tools that need it @@ -14,13 +14,14 @@ const GROUP_TOOLS = new Set([ 'group_list', 'group_sync', 'group_contracts', + 'group_impact', 'group_query', 'group_status', ]); describe('GITNEXUS_TOOLS', () => { - it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 5 group)', () => { - expect(GITNEXUS_TOOLS).toHaveLength(16); + it('exports all tools (7 base + 3 route/tool/shape + 1 api_impact + 6 group)', () => { + expect(GITNEXUS_TOOLS).toHaveLength(17); }); it('contains all expected tool names', () => { @@ -101,6 +102,13 @@ describe('GITNEXUS_TOOLS', () => { } }); + it('group_impact uses repo as required group path', () => { + const groupImpact = GITNEXUS_TOOLS.find((t) => t.name === 'group_impact')!; + expect(groupImpact.inputSchema.required).toContain('repo'); + expect(groupImpact.inputSchema.required).toContain('name'); + expect(groupImpact.inputSchema.required).toContain('target'); + }); + it('group_contracts has optional repo filter', () => { const groupContracts = GITNEXUS_TOOLS.find((t) => t.name === 'group_contracts')!; expect(groupContracts.inputSchema.properties).toHaveProperty('repo');