mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-10 22:43:40 +00:00
Merge origin/main into fix/skill-evolution-gate
This commit is contained in:
commit
d232671278
835 changed files with 124914 additions and 10145 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"source": "./gitnexus-claude-plugin",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,13 +21,20 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--watch` | Keep a Git repository index current with serialized refreshes |
|
||||
| `--debounce <ms>` | Watch quiet period before refresh (default: 300 ms) |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
```bash
|
||||
|
|
@ -55,15 +62,19 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
|||
node .gitnexus/run.cjs wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login.
|
||||
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language |
|
||||
| `--provider <name>` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. |
|
||||
| `--model <model>` | LLM model (default: MiniMax-M3) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
|
@ -82,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_
|
|||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
|
|
|
|||
|
|
@ -13,9 +13,28 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
A root cause traced in the wrong repository is a wrong root cause.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. This matters most for `cypher`, whose
|
||||
statement carries no in-band hint of which database it ran against.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
A stale index describes the code from before your bug, so refresh before
|
||||
trusting a trace, and state the repository and index freshness with the
|
||||
diagnosis.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
|
|
@ -27,6 +46,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
|
|
@ -34,6 +54,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
- [ ] State the repository and index freshness with the diagnosis
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
|
@ -44,7 +65,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
|
@ -52,7 +73,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
**query** — find code related to error:
|
||||
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
query({search_query: "payment validation error", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
|
@ -60,13 +81,15 @@ query({search_query: "payment validation error"})
|
|||
**context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
context({name: "validatePayment", repo: "my-app"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces. Pass `repo` alongside the statement; the
|
||||
Cypher text itself names no repository, so the result is unattributable without
|
||||
it:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
|
|
@ -76,7 +99,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
|||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
|
|
@ -87,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe
|
|||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — bind my-app explicitly on every call
|
||||
|
||||
1. query({search_query: "payment error handling", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment", repo: "my-app"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
Repository: my-app Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -13,10 +13,22 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Step 1 discovers what is indexed; every call after it must say which of those
|
||||
it means. With one indexed repository, use the examples below as written. With
|
||||
more than one, pass `repo` on every call: an omitted `repo` normally errors,
|
||||
but under an MCP policy with a configured default it resolves to that default
|
||||
silently. If you cannot tell which repository is meant, stop and ask. Report
|
||||
the bound repository and index freshness alongside your explanation.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
1. list_repos {} or READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
|
|
@ -28,12 +40,14 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
- [ ] State the repository and index freshness with the explanation
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
|
@ -50,7 +64,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
**query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
query({search_query: "payment processing", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
|
@ -58,16 +72,20 @@ query({search_query: "payment processing"})
|
|||
**context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
context({name: "validateUser", repo: "my-app"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
`repo` is required once more than one repository is indexed, and may be omitted
|
||||
with a single one.
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
1. list_repos {} → total: 1 (my-app) — bind it
|
||||
READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
|
|
@ -75,4 +93,8 @@ context({name: "validateUser"})
|
|||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
5. Answer, noting: Repository my-app, index current
|
||||
```
|
||||
|
||||
Had step 1 returned two repositories, every call above would carry
|
||||
`repo: "my-app"`.
|
||||
|
|
|
|||
|
|
@ -14,13 +14,42 @@ description: "Use when the user wants to know what will break if they change som
|
|||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Impact analysis is the gate that authorizes an edit, so it must answer for the
|
||||
repository you are about to edit.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask — every result below an ambiguous
|
||||
identity inherits the ambiguity. `list_repos` is paginated, so page with
|
||||
`offset: pagination.nextOffset` until `hasMore` is false before concluding a
|
||||
repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when your changes are in a linked worktree
|
||||
the MCP server was not launched from. The server auto-detects a worktree only
|
||||
when it was launched from inside one; otherwise `git diff` runs in the wrong
|
||||
checkout and reports zero changed symbols — a false clean check that carries
|
||||
none of the degradation flags described below. In the CLI fallbacks, `--repo .`
|
||||
means the current checkout; pass the intended repository path instead when you
|
||||
are not standing in it.
|
||||
|
||||
State the bound identity with your risk report:
|
||||
|
||||
```
|
||||
Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEAD
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .`
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .`
|
||||
4. Assess risk and report to user
|
||||
4. Assess risk and report to user, echoing repo/worktree/index identity
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
|
@ -29,12 +58,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
- [ ] Confirm the checkout you edited is the checkout that was diffed
|
||||
- [ ] Assess risk level and report, stating repo/worktree/index identity
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
|
@ -62,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
|||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
|
||||
uncertainty is resolved. Within single-repo mode, compare File and symbol
|
||||
targets with local `riskSharedAxes` (direct/total only). Within group mode,
|
||||
compare only group results: their `riskSharedAxes` overlays resolved
|
||||
cross-repo crossings on that local value. Never use either field to waive the
|
||||
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
|
||||
omit process/module axes, while web Graph-RAG expands File targets to in-file
|
||||
symbols before enrichment.
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:
|
||||
|
|
@ -69,6 +109,7 @@ treating the symbol as safe to change or delete.
|
|||
```
|
||||
impact({
|
||||
target: "validateUser",
|
||||
repo: "my-app", // required once >1 repository is indexed
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
|
|
@ -92,10 +133,26 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
Add `repo` once more than one repository is indexed, and `worktree: "<abs
|
||||
path>"` when your changes are in a linked worktree the server was not launched
|
||||
from.
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth, and reads like
|
||||
`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather
|
||||
than tick the pre-commit check.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is shape-identical to a genuine
|
||||
clean result, so confirm the checkout you edited is the one that was diffed
|
||||
before treating an empty change set as a passed check.
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
|
@ -103,4 +160,8 @@ detect_changes({scope: "all"})
|
|||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -124,12 +124,17 @@ phase that needs them.
|
|||
statement-level claims (never reconstructs fake edges).
|
||||
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
|
||||
labelled **source-derived**, with a recommendation to index.
|
||||
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
|
||||
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
|
||||
writable target repository, and a shared filesystem for the plan and
|
||||
Git-admin vault. The writer fails closed when those guarantees are
|
||||
unavailable; it never redirects the plan elsewhere.
|
||||
- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus
|
||||
`/proc/self/fd` on Linux; every other platform is refused. No interpreter is
|
||||
spawned and no native code is loaded. Publication is `link(2)`, which fails
|
||||
rather than replaces when the destination name is taken. Linux resolves every
|
||||
name against a held descriptor, so a parent swapped mid-write cannot redirect
|
||||
the operation; macOS has no equivalent path and instead pins each directory
|
||||
with an open descriptor and re-proves the chain either side of every step,
|
||||
which detects such a swap and aborts. Publishing also needs a writable target
|
||||
repository and a shared filesystem for the plan and Git-admin vault. The
|
||||
writer fails closed when those guarantees are unavailable; it never redirects
|
||||
the plan elsewhere.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ excluded.
|
|||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
`read-plan` fails closed unless the host platform can resolve names against a
|
||||
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
|
||||
refused outright — an unverified read is not a degraded read, it is a different,
|
||||
racy operation. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
|
|
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
|||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
The writer fails closed unless the host platform offers `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
|
||||
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
|
||||
the destination name is taken, and refuses a symlinked destination without
|
||||
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
|
||||
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
|
||||
supported platform. The temporary name is unlinked once the link succeeds; the
|
||||
published file is the same inode the writer created and verified, so every
|
||||
identity check downstream holds by construction. A link that succeeds followed
|
||||
by an unlink that fails leaves the plan published and is reported as success,
|
||||
because it is one. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
|
|
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
|
|||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
the parent and the temporary path, inode, size, and digest. Publication links
|
||||
the temporary name to the destination relative to the held directory
|
||||
descriptor, which fails rather than replaces if the destination is taken.
|
||||
Initial mode therefore cannot overwrite a destination that appears after the
|
||||
absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
### Linux anchors, macOS verifies
|
||||
|
||||
The two platforms reach the same destination by different proofs, and the
|
||||
difference is real enough to state rather than smooth over.
|
||||
|
||||
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
|
||||
the kernel resolves against the inode the descriptor already holds. The names
|
||||
above it are never re-walked, so an attacker who renames a parent between the
|
||||
check and the use cannot redirect the operation. The race is impossible, not
|
||||
merely detected.
|
||||
|
||||
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
|
||||
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
|
||||
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
|
||||
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
|
||||
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
|
||||
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
|
||||
every directory in the chain for the whole operation, and proves before *and*
|
||||
after each step that the chain still names exactly the inodes it is holding.
|
||||
Holding the descriptors is what makes the recorded inode numbers trustworthy:
|
||||
an open descriptor pins its inode, so a freed number cannot be recycled beneath
|
||||
the walk.
|
||||
|
||||
What that buys is detection rather than prevention. A parent swapped inside the
|
||||
window between a check and its use is caught by the check that follows, and the
|
||||
operation aborts having written nothing — but on Linux it could not have
|
||||
happened at all. No published byte escapes verification on either platform.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,9 +13,32 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Refactoring writes to disk. `rename` with `dry_run: false` edits files in
|
||||
whichever repository was resolved, so binding identity here is a safety gate,
|
||||
not bookkeeping.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. Never run `rename` with
|
||||
`dry_run: false` until the preview in the same bound repository has been
|
||||
reviewed — its returned `file_path` values show which checkout is about to be
|
||||
written, so read them as a confirmation of identity.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when you are editing a linked worktree the
|
||||
MCP server was not launched from; otherwise `git diff` runs in the wrong
|
||||
checkout and reports nothing changed, which reads as a verified refactor.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
|
|
@ -29,7 +52,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Confirm the previewed file paths are in the bound repository/worktree
|
||||
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
|
|
@ -39,6 +64,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
|
|
@ -50,6 +76,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
|
|
@ -64,7 +91,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
**rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 text_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
|
|
@ -73,7 +100,7 @@ rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true
|
|||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", repo: "my-app", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
|
@ -87,6 +114,14 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth: a short or empty
|
||||
list is not proof that only the expected files changed. Re-run it rather than
|
||||
treat the refactor as verified.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is indistinguishable from a
|
||||
clean verification, so confirm the diffed checkout is the one you edited.
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
|
|
@ -102,20 +137,28 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 text_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review text_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all", repo: "my-app"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -216,7 +216,10 @@ Work through plan §7 step by step, in order. For each step:
|
|||
`detect_changes` → commit as one unbroken sequence from the repository
|
||||
root — interleaving other work between the gate and the commit is how
|
||||
the gate gets skipped. Unexpected
|
||||
affected flows → investigate before committing, not after.
|
||||
affected flows → investigate before committing, not after. A result
|
||||
flagged `partial` (a graph query failed) or `truncated` (the symbol
|
||||
listing was capped) blocks the commit the same way: the gate did not
|
||||
see every changed symbol, so re-run it rather than read it as clean.
|
||||
|
||||
A relationship-affecting implementation edit or commit invalidates the
|
||||
procedure's prior proof. The next step must perform the required inter-step
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ excluded.
|
|||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
`read-plan` fails closed unless the host platform can resolve names against a
|
||||
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
|
||||
refused outright — an unverified read is not a degraded read, it is a different,
|
||||
racy operation. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
|
|
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
|||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
The writer fails closed unless the host platform offers `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
|
||||
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
|
||||
the destination name is taken, and refuses a symlinked destination without
|
||||
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
|
||||
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
|
||||
supported platform. The temporary name is unlinked once the link succeeds; the
|
||||
published file is the same inode the writer created and verified, so every
|
||||
identity check downstream holds by construction. A link that succeeds followed
|
||||
by an unlink that fails leaves the plan published and is reported as success,
|
||||
because it is one. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
|
|
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
|
|||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
the parent and the temporary path, inode, size, and digest. Publication links
|
||||
the temporary name to the destination relative to the held directory
|
||||
descriptor, which fails rather than replaces if the destination is taken.
|
||||
Initial mode therefore cannot overwrite a destination that appears after the
|
||||
absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
### Linux anchors, macOS verifies
|
||||
|
||||
The two platforms reach the same destination by different proofs, and the
|
||||
difference is real enough to state rather than smooth over.
|
||||
|
||||
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
|
||||
the kernel resolves against the inode the descriptor already holds. The names
|
||||
above it are never re-walked, so an attacker who renames a parent between the
|
||||
check and the use cannot redirect the operation. The race is impossible, not
|
||||
merely detected.
|
||||
|
||||
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
|
||||
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
|
||||
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
|
||||
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
|
||||
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
|
||||
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
|
||||
every directory in the chain for the whole operation, and proves before *and*
|
||||
after each step that the chain still names exactly the inodes it is holding.
|
||||
Holding the descriptors is what makes the recorded inode numbers trustworthy:
|
||||
an open descriptor pins its inode, so a freed number cannot be recycled beneath
|
||||
the walk.
|
||||
|
||||
What that buys is detection rather than prevention. A parent swapped inside the
|
||||
window between a check and its use is caught by the check that follows, and the
|
||||
operation aborts having written nothing — but on Linux it could not have
|
||||
happened at all. No published byte escapes verification on either platform.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -15,3 +15,15 @@
|
|||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
||||
# TypeScript sources are always text for diff purposes. Git's binary
|
||||
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
|
||||
# file that carried one on a base commit still renders as "Binary files
|
||||
# differ" — with no hunks and no inline comments — long after the byte
|
||||
# itself is gone from the working tree. A head-side guard cannot see
|
||||
# that, by construction. This does not mark the files binary or change
|
||||
# how they are stored; it only stops the heuristic from hiding a diff.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.mts diff
|
||||
*.cts diff
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ jobs:
|
|||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
|
|
|
|||
2
.github/workflows/ci-e2e.yml
vendored
2
.github/workflows/ci-e2e.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v3
|
||||
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
|
|
|||
192
.github/workflows/ci-tests.yml
vendored
192
.github/workflows/ci-tests.yml
vendored
|
|
@ -481,7 +481,29 @@ jobs:
|
|||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Java wildcard-static route constant guards (#3110)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: named-import control vs wildcard materialization;
|
||||
# fingerprints bindings and guards scaling + absolute wall time.
|
||||
run: node --import tsx bench/java-wildcard-route-constants/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin package-star route constant guards (#3110)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: explicit-import control vs package-star folding;
|
||||
# fingerprints route facts and guards scaling + widening overhead.
|
||||
run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Runs even after an earlier guard fails (#2895). Every step here was
|
||||
# fail-fast, so the FIRST failing --check aborted the job and every guard
|
||||
# after it reported `skipped` — which reads identically to "nothing to do".
|
||||
# Audited across 13 benchmark runs on #2856: the job succeeded zero times
|
||||
# and the last two guards executed zero times for the life of the PR, while
|
||||
# two reviews read the checks summary and saw nothing wrong. `!cancelled()`
|
||||
# rather than `always()` so an explicit cancel still stops the job.
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts emit<Lang>ScopeCaptures output is unchanged
|
||||
# (fingerprint) and stays linear (scaling < 1.5) for go/csharp/rust/php/
|
||||
# ruby/cobol. Catches an O(n^2) re-regression without the worker pool.
|
||||
|
|
@ -489,6 +511,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Callable-value-flow target-index guards (#2693)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts buildGraphTargetIndex resolves an unchanged target
|
||||
# set (fingerprint), stays linear in def count, and that the #2693
|
||||
# widened gate — which now considers VALUE bindings, a population that
|
||||
|
|
@ -500,7 +523,44 @@ jobs:
|
|||
run: node --import tsx bench/callable-value-flow/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Java Lombok accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-Lombok vs Lombok-heavy corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/java-lombok-synthesis/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin JVM accessor synthesis guards (#2885)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: no-property vs data-class corpora; fingerprint over
|
||||
# synthetic Method ids; scaling + widening overhead budgets.
|
||||
run: node --import tsx bench/kotlin-jvm-accessors/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin Spring config-consumer capture guards (#2412)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: explicit-import control vs wildcard-import feature path;
|
||||
# fingerprints @Value / @ConfigurationProperties facts and guards scaling
|
||||
# + widening overhead. The parity check is the regression gate: each file
|
||||
# declares a sibling nested type named `Value`, which must not suppress
|
||||
# the imported Spring annotation (file-wide shadowing dropped 2 of every
|
||||
# 3 facts on this corpus).
|
||||
run: node --import tsx bench/spring-config-bindings/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Re-export closure scaling guards (#2864)
|
||||
# Build-free: asserts buildReexportClosures stays linear in chain depth
|
||||
# and within an absolute ceiling on a wide package corpus. #2864 changed
|
||||
# this pass's input class from TypeScript barrels (a handful of shallow
|
||||
# edges) to every module-level Python `from m import x`, which is where
|
||||
# its two quadratic corners became reachable. The depth arm specifically
|
||||
# guards MAX_VIA_LENGTH — the bound that was removed once already, in
|
||||
# fc919ad6, and stayed invisible for as long as the input was shallow.
|
||||
run: node --import tsx bench/finalize-reexport/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: C++ qualified-namespace resolution guards (#2788)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts resolveCppQualifiedNamespaceMember resolves an
|
||||
# unchanged symbol set (fingerprint) and that per-call-site cost stays
|
||||
# independent of corpus size. Rationale and history: see the header of
|
||||
|
|
@ -508,18 +568,118 @@ jobs:
|
|||
run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin import-resolution identity + scaling guards
|
||||
# Build-free: asserts resolveKotlinImportTarget resolves an unchanged
|
||||
# file set (fingerprint, in both file-set iteration orders — every
|
||||
# tie-break in that resolver is expressed only through iteration order)
|
||||
# and that per-import cost stays independent of workspace size. The
|
||||
# pre-index implementation scores 3.737 on this corpus against 0.99 for
|
||||
# the index, so the gate separates them by a wide margin. Rationale and
|
||||
# history: see the header of bench/kotlin-import-target/measure.mjs.
|
||||
- name: Import-target resolution guards (every registered language, #2877–#2909, PR #2911)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: runs EVERY import-target resolver registered in
|
||||
# SCOPE_RESOLVERS — plus C# a second time WITH csproj configs, over the
|
||||
# identical corpus, because the no-csproj arm returns before it can
|
||||
# reach the leg #2902 indexed. One arm per registered language over ONE
|
||||
# shared corpus, and no registered language ungated. That is enforced,
|
||||
# not enumerated: measure.mjs derives its list from a LANG_REGISTRY
|
||||
# table and its --check inventory arm reconciles that table against
|
||||
# SCOPE_RESOLVERS in both directions, so a language roster typed out
|
||||
# here would only be a second copy that can go stale — this one did.
|
||||
# A C/C++ #include is an import site for this purpose and is gated like
|
||||
# every other registered language (its headers arrive through
|
||||
# resolutionConfig rather than allFilePaths, which is the one structural
|
||||
# difference — see `newPass`).
|
||||
#
|
||||
# Asserts each returns an unchanged target set (a fingerprint per
|
||||
# language AND per arm), that per-import cost stays independent of
|
||||
# corpus size AND of path depth, that the absolute small-arm cost holds
|
||||
# — a constant-factor regression that grows both scale arms equally
|
||||
# passes every ratio — and that the per-pass index eight of them retain
|
||||
# stays within an absolute byte ceiling. The corpus SHAPE is asserted
|
||||
# too: a fingerprint alone cannot tell a legitimate resolution change
|
||||
# from a corpus quietly shrunk below the size the timing arms need.
|
||||
#
|
||||
# Several arms exist because an arm that stops MEASURING otherwise
|
||||
# passes. The heap arms drive real resolvers and carry a FLOOR as well
|
||||
# as a ceiling: when buildSuffixIndex's suffix maps went lazy, four arms
|
||||
# that called the builder directly read 0 B, and 0 B is under every
|
||||
# ceiling. EVERY budget is checked for PRESENCE first, timing and heap
|
||||
# alike, because `got > undefined` is false and `got < ceiling *
|
||||
# undefined` is false too, so deleting a budget key deleted its gate —
|
||||
# and the two heap scalars gate all eight heap arms at once. The heap
|
||||
# arm's own corpus shape (its two file counts, its path depth and the
|
||||
# probe it resolves) is asserted by the same loop as the timing arms,
|
||||
# because those four decide WHAT it measures. And an inventory arm
|
||||
# reconciles the bench's language table against SCOPE_RESOLVERS itself,
|
||||
# so a newly registered resolver cannot ship ungated the way JavaScript
|
||||
# did.
|
||||
#
|
||||
# The resolvers gated first were added as their own O(imports × files)
|
||||
# scans were indexed away (Ruby rebuilt a suffix index per `require`;
|
||||
# COBOL scanned twice per `COPY`), and the same corpus shape scores >3.3
|
||||
# against those pre-fix implementations. The rest were ungated until
|
||||
# this PR, which is not a theoretical gap: PR #2911 found JavaScript
|
||||
# reaching suffixResolve with no index at all — 25 972 µs per import at
|
||||
# 8000 files, protected only by unit tests. This step is what stops the
|
||||
# next one shipping.
|
||||
#
|
||||
# SCOPE: "independent of corpus size" holds for UNIQUE-LEAF layouts,
|
||||
# where no two directories share a last segment and no two files share a
|
||||
# basename — which is what the small/large/deep arms are, and where
|
||||
# every index bucket holds exactly one entry. The `collide` arm runs the
|
||||
# identical workload on the layout these languages are actually written
|
||||
# in (svcN/internal, SrcN/Models, a repeated basename per package, four
|
||||
# SPM modules instead of fifty); there the bucket grows with the file
|
||||
# count by construction and go, csharp, dart, java, swift and c/cpp
|
||||
# legitimately score 2.1–3.9, so that arm carries its own per-language
|
||||
# budget. It is a scope limit, not a regression — the indexed code is
|
||||
# still faster on that shape than the pre-change scan. Rust is the one
|
||||
# language whose collide arm is NOT a shared-leaf layout: it probes
|
||||
# candidate paths and is provably flat in the file count, so its arm is
|
||||
# a deep module tree that varies `::` segment count instead — the axis
|
||||
# its cost actually has.
|
||||
#
|
||||
# --expose-gc enables the retained-heap arm; --check REFUSES to run
|
||||
# without it rather than passing with the memory gate silently skipped.
|
||||
# ~44–45 s, which is essentially unchanged from the ~46 s it cost
|
||||
# before: the timing phase did fall from 39.8 s to 28.7 s when the
|
||||
# min-of-N estimator became per-language, but the inventory arm's one
|
||||
# dynamic import (pipeline/registry.ts pulls in every registered
|
||||
# provider) costs 6–10 s depending on the box and consumes almost all of
|
||||
# that. Report mode, which does not load the registry, is the mode that
|
||||
# got faster: ~33–35 s. Kept as-is because this job runs minutes clear
|
||||
# of the sharded coverage job that gates the merge, so the seconds buy
|
||||
# no merge latency — see COST in the bench header. The ts
|
||||
# family (javascript/typescript/vue) is still the largest block, 8.8 s,
|
||||
# because suffixResolve probes ~39 extensions per path part on a miss.
|
||||
# If this ever has to shrink, drop collide/collide_large for typescript
|
||||
# and vue (−3.9 s) — the only cut that removes near-duplicate work
|
||||
# rather than coverage. N is 15 (matching bench/cfg) for every language
|
||||
# whose cheapest arm is under 5 ms, because depth_ratio divides two
|
||||
# sub-3 ms numbers and at 5 or 7 it tripped its own budget roughly 1 run
|
||||
# in 20; the six languages whose cheapest arm is 20-28 ms drop to 7-8,
|
||||
# where the measured overshoot is at most 6.3%. The estimator was fixed
|
||||
# rather than the budget widened; distributions in _arms_note.
|
||||
# The Kotlin arm here is a second corpus, not a replacement for the
|
||||
# kotlin-import-target bench below, which carries declared-package
|
||||
# correctness probes this shared corpus does not.
|
||||
# It sits with the other resolver-index guards rather than at the end of
|
||||
# the job: parking a new gate last is not safety, it is the slot least
|
||||
# likely to execute (#2895 measured the last two guards running zero
|
||||
# times in 13 runs). #2899 landed the `if: ${{ !cancelled() }}` below,
|
||||
# which is what makes position irrelevant — a failing step no longer
|
||||
# aborts the ones after it.
|
||||
# Rationale, budgets and the measured blind spot: see the header of
|
||||
# measure.mjs and _blind_spot in baselines.json.
|
||||
run: node --expose-gc --import tsx bench/import-target/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin declared-package import correctness + scaling guards
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: fingerprints declared-package evidence, external decoy
|
||||
# rejection, top-level/member/wildcard imports, overload sets and root
|
||||
# packages, then guards one package-index build per workspace against
|
||||
# file-count and path-depth scaling. Rationale and history: see the
|
||||
# header of bench/kotlin-import-target/measure.mjs.
|
||||
run: node --import tsx bench/kotlin-import-target/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Receiver-resolution drop guards
|
||||
if: ${{ !cancelled() }}
|
||||
# NOT build-free: this one runs the real pipeline, so it needs dist/
|
||||
# (the setup action above builds). ~2m15s.
|
||||
#
|
||||
|
|
@ -545,6 +705,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Scope-emission guards (#2699)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts the JS/TS scope set is unchanged. Block scopes are
|
||||
# what make `let`/`const` in sibling blocks distinct bindings, but a
|
||||
# scope per `statement_block` triples the count and deepens every
|
||||
|
|
@ -557,6 +718,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: CFG construction time / disk / memory guards (#2081 M1)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts collectFunctionCfgs output is unchanged
|
||||
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
|
||||
# retained heap all stay sub-quadratic for the straight-line /
|
||||
|
|
@ -567,6 +729,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Emit-persistence throughput / byte-identity guards (#2203)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts streamAllCSVsToDisk output is byte-identical
|
||||
# (order-independent CSV-line fingerprint — the #2203 U2/U3 emit
|
||||
# optimisations must not change graph content) and that emit wall-time
|
||||
|
|
@ -576,6 +739,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Streaming PDG-emit byte-identity / bounded-RSS guards (#2202)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts the streaming PdgEmitSink emits a CSV row SET
|
||||
# byte-identical to the whole-graph streamAllCSVsToDisk emit, AND that
|
||||
# the in-memory graph retains zero BasicBlock nodes (the O(chunk) peak-RSS
|
||||
|
|
@ -585,19 +749,23 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
# cpp-adl-benchmark.test.ts is not a `*-pipeline-benchmark.test.ts` but
|
||||
# belongs here for the same reason: it is skipIf-gated on GITNEXUS_BENCH,
|
||||
# so it had never run in CI and the PR #1990 ADL emit-scaling guard it
|
||||
# holds was dead. ~45s of test time.
|
||||
if: ${{ !cancelled() }}
|
||||
# cpp-adl-benchmark.test.ts and csharp-razor-view-components-benchmark.test.ts
|
||||
# are not `*-pipeline-benchmark.test.ts` files but belong here for the
|
||||
# same reason: they are skipIf-gated on GITNEXUS_BENCH, so the scaling
|
||||
# guards they hold never run in the main coverage job.
|
||||
env:
|
||||
GITNEXUS_BENCH: '1'
|
||||
run: >-
|
||||
npx vitest run --no-file-parallelism
|
||||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-razor-view-components-benchmark.test.ts
|
||||
test/integration/cpp-adl-benchmark.test.ts
|
||||
test/integration/data-route-table-benchmark.test.ts
|
||||
test/integration/instance-ownership-pipeline-benchmark.test.ts
|
||||
test/integration/spring-bean-resource-benchmark.test.ts
|
||||
test/integration/spring-dynamic-lookup-benchmark.test.ts
|
||||
test/integration/rust-pipeline-benchmark.test.ts
|
||||
test/integration/php-pipeline-benchmark.test.ts
|
||||
test/integration/ruby-pipeline-benchmark.test.ts
|
||||
|
|
|
|||
10
.github/workflows/codeql.yml
vendored
10
.github/workflows/codeql.yml
vendored
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -71,8 +71,14 @@ jobs:
|
|||
# deliberately contain use-before-init / unused-variable shapes).
|
||||
- '**/test/fixtures/**'
|
||||
- '**/test/**/fixtures/**'
|
||||
# GET /api/grep intentionally builds RegExp from the query string
|
||||
# (literal=1 escapes). ReDoS is handled by worker terminate() —
|
||||
# see SECURITY.md. Inline codeql[] comments do not clear the
|
||||
# GitHub PR CodeQL gate, so this file is excluded to avoid
|
||||
# re-filing js/regex-injection on every push of the same line.
|
||||
- 'gitnexus/src/server/grep-params.ts'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
|
|
@ -141,7 +141,7 @@ jobs:
|
|||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
|
@ -256,7 +256,7 @@ jobs:
|
|||
# pulling from either GHCR or Docker Hub see the same provenance.
|
||||
- name: Generate build provenance attestation (GHCR)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
@ -264,7 +264,7 @@ jobs:
|
|||
|
||||
- name: Generate build provenance attestation (Docker Hub)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -53,6 +53,6 @@ jobs:
|
|||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
4
.github/workflows/trivy.yml
vendored
4
.github/workflows/trivy.yml
vendored
|
|
@ -50,7 +50,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -31,6 +31,8 @@ npm-debug.log*
|
|||
|
||||
# Testing
|
||||
coverage/
|
||||
.tmp-test/
|
||||
gitnexus/.tmp-test/
|
||||
|
||||
# Misc
|
||||
*.local
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
# Deleted README placeholder from PR #2458; no credential was present.
|
||||
c9fdab17f25ebaf332fba6e6ba55ee328f20fe66:README.md:curl-auth-header:348
|
||||
# Synthetic Kotlin Actuator fixture value from PR #3107; no credential was present.
|
||||
3951079300a18b14e79f5b5f5dd778ae19ced6e3:gitnexus/test/integration/spring-actuator-kotlin-runtime-pipeline.test.ts:generic-api-key:8
|
||||
|
|
|
|||
11
AGENTS.md
11
AGENTS.md
|
|
@ -111,18 +111,17 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
|
|||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — see below) |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS static route sources — see below) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
|
|
@ -108,7 +108,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
|
|||
| `pruneLocalSymbols` | `prune-local-symbols.ts` | `scopeResolution` | Drops inert block-local `Const`/`Variable`/`Static` nodes (only a `File→DEFINES` edge) post-resolution |
|
||||
| `mro` | `mro.ts` | `crossFile`, `scopeResolution`, `pruneLocalSymbols`, `structure` | METHOD_OVERRIDES + METHOD_IMPLEMENTS edges |
|
||||
| `springAopInheritance` | `spring-aop.ts` | `springAop`, `mro` | Propagates declarative behavior through class/interface inheritance decisions |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes or factory Methods to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `di` | `di.ts` | `mro` | INJECTS edges from consumer Classes, factory Methods, or AST-captured programmatic lookup callables to provider Classes/declaration CodeElements (framework-neutral DI resolution; per-language matchers registered in `di-extractors/`) |
|
||||
| `communities` | `communities.ts` | `mro`, `pruneLocalSymbols`, `structure` | Community nodes + MEMBER_OF edges (Leiden algorithm) |
|
||||
| `processes` | `processes.ts` | `communities`, `routes`, `tools`, `pruneLocalSymbols`, `structure` | Process nodes + STEP_IN_PROCESS edges |
|
||||
|
||||
|
|
@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
|
|||
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
|
||||
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
|
||||
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards** |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
|
||||
|
||||
The last row is the one whose name undersells it. A route is DECLARED by a
|
||||
decorator, but it can also be **inferred** from a raw `node:http` server's own
|
||||
|
|
@ -185,6 +185,15 @@ handler resolution are shared with decorator routes, and
|
|||
`ExtractedDecoratorRoute.source` carries the provenance difference through to
|
||||
the `HANDLES_ROUTE` edge.
|
||||
|
||||
JS/TS data route tables share that transport when a route-named array contains
|
||||
direct object literals with static `path`, `method`, and `handler` fields and a
|
||||
same-scope `for...of` dispatcher positively compares the path and method before
|
||||
directly invoking the handler. Dynamic values, computed keys, spreads,
|
||||
inline/called handlers, unknown verbs, and ambiguous handler bindings are
|
||||
suppressed. Bare import aliases and single-level member handlers are attributed
|
||||
only through declared import and owner provenance; an unproven receiver never
|
||||
falls back to a global name guess.
|
||||
|
||||
That extractor is deliberately **precision-weighted**: `route_map` presents its
|
||||
output as fact, so a `startsWith` namespace test, a bare `pathname === '/'`
|
||||
without a verb, and any regex it cannot translate exactly are all dropped rather
|
||||
|
|
@ -277,6 +286,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun
|
|||
|
||||
Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`.
|
||||
|
||||
Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator<string>` must not reach an implementor of `IValidator<int>`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T<A> for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest.
|
||||
|
||||
The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper<T> : IValidator<T>` stays reachable from every instantiation while `class IntValidator : IValidator<int>` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned.
|
||||
|
||||
The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run<T>(IValidator<T> v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place.
|
||||
|
||||
Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning.
|
||||
|
||||
### Receiver chains and the drop census (#2766)
|
||||
|
|
@ -388,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
|
|
|
|||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -62,18 +62,17 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows).
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). `partial: true` or `truncated: true` is not a clean check — a zero means unseen, not unaffected; re-run it. For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- MUST warn on HIGH/CRITICAL `risk` pre-edit; never use `riskSharedAxes` to waive a HIGH/CRITICAL `risk` warning. Compare File/symbol: MCP File omits axes; Graph-RAG expands File.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- **MUST use `query({search_query: "concept"})` for concepts/flows, `context({name: "symbolName"})` for a named symbol, or `impact` for blast radius, on read-only callers, dependencies, imports, or execution flow.** Graph first; text search only for empty/`UNKNOWN`/literals.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
- For control/data dependence, `pdg_query({mode: "controls", target: "fileOrSymbol"})` answers "under what condition does X run?" (CDG, incl. guard clauses) and `pdg_query({mode: "flows", target, variable})` traces "where does variable Y flow?" (REACHING_DEF). `--pdg` layer.
|
||||
|
||||
|
|
|
|||
|
|
@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus
|
|||
# node:22-bookworm-slim
|
||||
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
|
||||
|
||||
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
# curl for the healthcheck; git for cloning; procps for watch process identity;
|
||||
# ca-certificates for TLS verification.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm \
|
||||
&& rm -rf /usr/local/lib/node_modules/corepack \
|
||||
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
|
||||
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
|
||||
|
||||
### Scope extraction is incomplete
|
||||
|
||||
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["scope-extraction-failed"]` when files were omitted, or `incompleteReasons: ["scope-extraction-unverified"]` when the index predates the completeness receipt or its metadata is unreadable. `impact`/`context` reports the same uncertainty as `epistemic: "lower-bound"`; confirmed omissions set `causes.scopeExtractionFiles > 0`.
|
||||
- **Do:** Re-run `npx gitnexus analyze` (`--force` for a full graph rebuild). If the reason persists, inspect the scope-extraction warnings and treat impact counts as floors until the affected source is supported or corrected.
|
||||
- **Why:** Parsing continued, but scope captures for the reported file count could not be produced even after the main-thread fallback. Calls, inheritance, imports, or accesses originating there may therefore be absent from the graph.
|
||||
|
||||
### Analyze reports INCOMPLETE with a collapsed graph write
|
||||
|
||||
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
|
||||
|
|
@ -67,8 +73,8 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
### Wrong repo in multi-repo setups
|
||||
|
||||
- **Trigger:** Query/impact results belong to another project.
|
||||
- **Do:** Call `list_repos`, then pass `repo` on subsequent tools.
|
||||
- **Why:** Default target is ambiguous when multiple repos are registered.
|
||||
- **Do:** Confirm an MCP default is configured or the GitNexus process was launched inside the intended registered path without crossing into an unindexed nested Git checkout. Otherwise call `list_repos`, then pass `repo` on subsequent tools; pass it for mutating tools when multiple repos are registered and no MCP default exists.
|
||||
- **Why:** Read-only tools derive their default from MCP configuration or a process cwd that stays within one registered Git boundary. Outside those paths the target remains ambiguous, and mutating tools stay explicit unless configuration supplies the target.
|
||||
|
||||
### LadybugDB lock / "database busy"
|
||||
|
||||
|
|
|
|||
139
README.md
139
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# GitNexus
|
||||
# GitNexus (Akon Labs)
|
||||
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ flowchart TB
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> Per-repo tools take an optional `repo` parameter (omit it when only one repo is indexed) and an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> Per-repo read-only tools take an optional `repo` parameter. Omit it when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout; otherwise pass it explicitly. Mutating tools require `repo` when multiple repos are indexed and no MCP default exists. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`. Omitting `branch` queries the workspace index, which follows your checked-out working tree — switching branches and re-running `gitnexus analyze` updates it incrementally. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
### Resources for instant context
|
||||
|
||||
|
|
@ -384,6 +384,7 @@ Everyday commands:
|
|||
```bash
|
||||
gitnexus setup # Configure MCP for detected editors (one-time; -c to select)
|
||||
gitnexus analyze [path] # Index a repository (or update a stale index)
|
||||
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
gitnexus eval-server # Start lightweight evaluation HTTP tools (loopback by default)
|
||||
|
|
@ -396,6 +397,28 @@ gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks
|
|||
|
||||
You can also query the graph directly from the terminal — `gitnexus query`, `context`, `impact`, `trace`, `cypher`, `detect-changes`, and `check` mirror the MCP tools of the same names, and `gitnexus doctor` prints runtime platform capabilities.
|
||||
|
||||
`gitnexus analyze --watch` requires a Git repository. It runs one initial
|
||||
analysis, then debounces scanner-admitted working-tree changes for 300 ms by
|
||||
default and applies serialized incremental refreshes. Events arriving during a
|
||||
refresh remain queued, and retryable failures retain the same batch with bounded
|
||||
backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
|
||||
until the control file is fixed. Stop the watcher with Ctrl+C.
|
||||
|
||||
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
|
||||
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
|
||||
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
|
||||
embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git`
|
||||
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a
|
||||
warning rather than making an otherwise valid repository unwatchable.
|
||||
|
||||
POSIX requests clone-first copy-and-swap publication when the live index has no
|
||||
orphan sidecars. Windows and sidecar fallback runs update in place: failures
|
||||
known to occur before writes are retried, while a failure that may have mutated
|
||||
the live index stops the watcher. Watch mode does not pull remotes. Running MCP
|
||||
and `serve` processes reopen a newly published index automatically; MCP observes
|
||||
the replacement on its next tool call, typically within five seconds, so no
|
||||
restart is required.
|
||||
|
||||
<details>
|
||||
<summary><strong>Authenticated <code>eval-server</code> binding</strong></summary>
|
||||
|
||||
|
|
@ -426,10 +449,13 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
|
|||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16,
|
||||
# auto-sized to the repo). 0 is rejected — there is no sequential mode.
|
||||
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
|
||||
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
```
|
||||
|
||||
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
**Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:
|
||||
|
|
@ -444,6 +470,46 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Keep remote repositories indexed with <code>gitnexus auto-sync</code></strong></summary>
|
||||
|
||||
`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching.
|
||||
|
||||
```bash
|
||||
# 1. Create the config once. It never overwrites an existing file.
|
||||
gitnexus auto-sync init
|
||||
|
||||
# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
|
||||
gitnexus auto-sync start # `gitnexus auto-sync` is equivalent
|
||||
gitnexus auto-sync status
|
||||
gitnexus auto-sync restart # Required after config changes
|
||||
gitnexus auto-sync stop
|
||||
gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact
|
||||
```
|
||||
|
||||
`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:
|
||||
|
||||
```yaml
|
||||
sync_interval_minutes: 10
|
||||
analyze_timeout: 5m
|
||||
projects:
|
||||
- local_path: /absolute/path/to/clones
|
||||
branches: [main, master]
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
```
|
||||
|
||||
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
|
||||
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
|
||||
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
|
||||
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
|
||||
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
|
||||
|
||||
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Repository groups</strong> (multi-repo / monorepo service tracking)</summary>
|
||||
|
||||
|
|
@ -477,6 +543,7 @@ Commit a `.gitnexusrc` JSON file at the repo root to preconfigure recurring `ana
|
|||
"skipContextFiles": true, // alias of skipAgentsMd: keep your own AGENTS.md/CLAUDE.md
|
||||
"skipSkills": true, // don't install standard skill files under .claude/skills/ and .agents/skills/
|
||||
"embeddings": true, // generate embeddings by default
|
||||
"springActuator": "./actuator", // optional local runtime snapshot directory or bundle
|
||||
"workerTimeout": 60,
|
||||
}
|
||||
```
|
||||
|
|
@ -491,7 +558,7 @@ Notes:
|
|||
|
||||
- The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`.
|
||||
- `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`.
|
||||
- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts.
|
||||
|
||||
</details>
|
||||
|
|
@ -501,36 +568,39 @@ Notes:
|
|||
|
||||
Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults.
|
||||
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| Variable | Default | Effect | Tune when… |
|
||||
| ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
|
||||
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
| `GITNEXUS_MCP_AUTH_TOKEN` | unset | Bearer token for the dedicated `gitnexus mcp --http` server, for a **directly reachable** `gitnexus serve` `/api/mcp` route, and for the `docker-server` / web proxy in front of one. A non-loopback dedicated MCP bind requires it; `serve` enables protocol-layer MCP auth when it is set. Behind a proxy, set the **same** value on both services: the proxy spends the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with this token on `/api/mcp` only. | Dedicated MCP, a `serve` the client can reach directly, or a proxied deploy (Render Blueprint) where the backend runs protocol-layer MCP auth — configure it on the proxy too. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code. The worker is terminated at its next JS-safe point instead of mid-native-call (which aborts the whole process with `Napi::Error`, #2432); on expiry it is left running, unref'd, and terminated when it surfaces. | Shutdown latency matters more than draining a wedged worker (lower), or a legitimately-slow native grammar needs longer to surface (raise). |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction. On breach the file keeps the captures accumulated so far and logs a warning — the worker returns to JS instead of stalling in native-heavy loops (#2432). `0` expires immediately. | Pathological generated C++ that still exceeds the budget after the indexed lookups; raise for completeness, lower to fail-fast. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Per-bucket byte budget for parse-cache packing. Files are grouped by `(language, hash(path) mod 128)`; packs inside a bucket are cut at this limit. Smaller = finer-grained invalidation and more dispatch. Default is always 2 MiB and no longer scales with worker count. | Tuning incremental-analyze cache invalidation on monorepos without changing `--workers`. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips the vendored grammar materialize for `tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`, and `tree-sitter-kotlin` at install time (and the Dart/Proto source builds). Those four won't be parsed; the install still succeeds. | Installing on a host without a C++ toolchain or where the vendored prebuilds don't match; willing to skip Dart/Proto/Swift/Kotlin parsing. |
|
||||
| `GITNEXUS_MCP_READ_ONLY` | unset | Set to `1` to expose only proven single-repository read tools and resources; `0` disables the policy and any other value fails startup. | The MCP server runs in an environment where graph mutation, raw Cypher, and cross-repository group routing must be unavailable. |
|
||||
| `GITNEXUS_MCP_ALLOWED_REPOS` | unset | Comma-separated allowlist of canonical indexed repository names or absolute paths. Invalid, ambiguous, or blank entries fail startup. | One MCP process must expose only a bounded subset of the repositories in the global registry. |
|
||||
| `GITNEXUS_MCP_DEFAULT_REPO` | unset | Canonical indexed repository name or absolute path used when a tool or resource omits its repository. Must belong to the allowlist when one is set. | Several repositories are available but unqualified MCP calls should resolve deterministically. |
|
||||
| `GITNEXUS_MCP_DEFAULT_MAX_TOKENS` | unset | Default positive-integer response budget for MCP `query`, `context`, and `impact`, estimated at four UTF-8 bytes per token. Explicit `maxTokens` wins. | Long MCP responses consume too much model context and callers cannot reliably add a per-request budget. |
|
||||
| `GITNEXUS_PUBLIC_ORIGIN` | unset | The single browser origin `serve` is reached through, added to the CORS allowlist and to the write-route origin guard. A wildcard bind (`0.0.0.0`) has no host identity, so without this the server's own UI is refused. **Setting it currently refuses to start:** `serve` has no authentication, requests carrying no `Origin` header already reach `POST /api/analyze` and `DELETE /api/repo`, and this is the setting that would admit browser writes on top of that. Matching rules for when the gate lifts: the hostname must match exactly, and so must the scheme. A value with no scheme (`app.example.com`) means `https`, since a bare host comes from platform service discovery and those terminate TLS; spell out `http://app.example.com` for plain HTTP. An explicit port must match; with no port, any port on that hostname is accepted. Anything that is not one reachable host (a list, `*`, a bare port number, a `:0` port, a trailing dot) warns at startup and allows nothing. | `gitnexus serve` runs behind a reverse proxy or on a wildcard bind, and the UI's index/delete requests return `origin_not_allowed`. |
|
||||
| `GITNEXUS_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Express `trust proxy` value — which upstream hops may set `X-Forwarded-*`, and so what the per-IP rate limiter reads as the client IP. Set it to the exact number of proxies you control. Every hop past that is one more entry of the chain the caller gets to write. `false`/`no`/`off` (and a `0` hop count) trust no hop; a proxy list Express can compile (`loopback`, `10.0.0.0/8, 127.0.0.1`) names them instead. `true`/`yes`/`on` is **rejected**: it reads the client-controlled leftmost `X-Forwarded-For` entry, so a spoofed chain earns a fresh rate-limit key per request, and express-rate-limit rejects it too (`ERR_ERL_PERMISSIVE_TRUST_PROXY`). Counts above `16` are rejected as well, as a sanity ceiling rather than a safety boundary. Any invalid value warns and falls back to the default. Bind non-loopback with this unset and `serve` warns: a load balancer outside the private ranges is untrusted, so every request keys to the balancer and the per-IP limit becomes one shared limit. | `serve` sits behind a load balancer outside the private ranges (AWS ALB, Cloudflare, CGNAT), where every request otherwise collapses to the proxy hop and rate limiting goes global. |
|
||||
|
||||
</details>
|
||||
|
|
@ -589,7 +659,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
|
|||
|
||||
GitNexus uses a **global registry** so one MCP server can serve multiple indexed repos. No per-project MCP config needed — set it up once and it works everywhere.
|
||||
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). If only one repo is indexed, the `repo` parameter is optional on all tools — agents don't need to change anything.
|
||||
Each `gitnexus analyze` stores the index in `.gitnexus/` inside the repo (portable, gitignored) and registers a pointer in `~/.gitnexus/registry.json`. When an AI agent starts, the MCP server reads the registry and can serve any indexed repo. LadybugDB connections are opened lazily on first query and evicted after 5 minutes of inactivity (max 5 concurrent). Read-only tools can omit `repo` when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Outside those paths—and for mutating tools with multiple indexed repos and no MCP default—pass `repo` explicitly.
|
||||
|
||||
<details>
|
||||
<summary><strong>Architecture diagram</strong></summary>
|
||||
|
|
@ -767,6 +837,7 @@ gitnexus wiki
|
|||
# Use a custom model or provider (default model: minimax/minimax-m2.5)
|
||||
gitnexus wiki --model gpt-4o
|
||||
gitnexus wiki --base-url https://api.anthropic.com/v1
|
||||
gitnexus wiki --provider grok # local Grok Build CLI (uses `grok login`, no API key)
|
||||
|
||||
# Force full regeneration
|
||||
gitnexus wiki --force
|
||||
|
|
|
|||
11
RUNBOOK.md
11
RUNBOOK.md
|
|
@ -46,6 +46,17 @@ npx gitnexus status
|
|||
npx gitnexus list
|
||||
```
|
||||
|
||||
**Scope extraction incomplete:** `npx gitnexus status` reports
|
||||
`incompleteReasons: ["scope-extraction-failed"]` when one or more files still
|
||||
lack scope captures after the worker and fallback passes. `impact` and `context`
|
||||
then report a lower bound with `causes.scopeExtractionFiles` set to the affected
|
||||
file count. Re-run `npx gitnexus analyze --force`; if the reason remains, inspect
|
||||
the scope-extraction warnings for the unsupported or malformed source file.
|
||||
Every pre-existing index remains unverified until it is analyzed once by a
|
||||
version that writes the completeness receipt. An older index or unreadable completeness record reports
|
||||
`incompleteReasons: ["scope-extraction-unverified"]`; re-analyze it before treating
|
||||
empty impact results as exact.
|
||||
|
||||
---
|
||||
|
||||
## Embeddings
|
||||
|
|
|
|||
|
|
@ -59,11 +59,16 @@ The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnex
|
|||
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
|
||||
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
|
||||
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
|
||||
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
|
||||
- **`POST /api/mcp` rides the same path.** When `GITNEXUS_MCP_AUTH_TOKEN` is set on the backend, `serve` protects `/api/mcp` with the same constant-time Bearer check as the dedicated HTTP MCP server, before parsing the request body. The Render Blueprint does not set a backend MCP token by default. To enable it behind the proxy, set the **same** `GITNEXUS_MCP_AUTH_TOKEN` on both the `gitnexus-web` proxy and the `gitnexus-server` backend: the proxy consumes the edge `GITNEXUS_SERVE_AUTH_TOKEN`, then replaces `Authorization` with the MCP token on `/api/mcp` (and its subpaths) only — the edge credential is never forwarded, and other `/api/*` routes stay stripped. Configuring it on the backend alone makes every proxied MCP request `401`.
|
||||
- **A directly reachable `serve` still needs an explicit control.** If neither `GITNEXUS_MCP_AUTH_TOKEN` nor an authenticated edge/private-network boundary is present, `/api/mcp` is unauthenticated. Do not bind that topology to a LAN or public interface: MCP readers can access indexed source and graph context.
|
||||
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
|
||||
|
||||
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
|
||||
|
||||
### `/api/grep` regex semantics and residual ReDoS exposure
|
||||
|
||||
`GET /api/grep` executes caller-supplied patterns as real regular expressions (with an optional path-substring `fileFilter` and `caseSensitive` flag) to honor the web chat's grep tool contract; `literal=1` restores the older escaped-substring mode. Mitigations: a 200-character pattern cap, line-by-line matching, a max-200 result cap, and a 5-second wall-clock budget. Matching runs in a `worker_threads` worker so a catastrophic pattern (e.g. `(a+)+$`) can be killed with `terminate()` when the budget expires — the parent event loop (other routes + SSE) stays responsive. A timed-out scan returns partial results with `timedOut: true`; the web grep tool surfaces that flag so an agent does not treat a cut-off scan as exhaustive. CodeQL still flags constructing a `RegExp` from the query string; that is the advertised contract, not accidental injection. Hosted deploys continue to gate the route behind the edge token.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
|
|||
// (gitnexus/src/mcp/http-transport.ts).
|
||||
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
|
||||
|
||||
// The protocol-layer credential the upstream `serve` expects on /api/mcp when it
|
||||
// runs with MCP Bearer auth enabled. Set it to the SAME value on both services:
|
||||
// the edge token is spent here and replaced with this one for MCP requests only
|
||||
// (see proxyToUpstream). Unset — the default — means no injection, so a backend
|
||||
// without MCP auth is unaffected. Blank-is-absent follows resolveAuthToken
|
||||
// (gitnexus/src/mcp/http-transport.ts). Never logged.
|
||||
const mcpAuthToken = process.env.GITNEXUS_MCP_AUTH_TOKEN?.trim() || null;
|
||||
|
||||
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
|
||||
// relocated because the trust boundary is here: an unguarded `serve` behind a
|
||||
// private service is legitimate, an unguarded public proxy is not.
|
||||
|
|
@ -341,11 +349,17 @@ async function proxyToUpstream(req, res) {
|
|||
// talks to this same-origin web service.
|
||||
delete headers.origin;
|
||||
delete headers.referer;
|
||||
// The edge token is spent here. `serve` reads no Authorization header
|
||||
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
|
||||
// it would only copy a live credential into another service's logs. Pinned by
|
||||
// test.
|
||||
// The edge token is spent here and must never be forwarded: copying
|
||||
// Authorization would put a live credential into another service's logs. So
|
||||
// drop it unconditionally first, then — for the MCP route alone, and only
|
||||
// when a backend token is configured — replace it with that separate
|
||||
// protocol credential. Unset GITNEXUS_MCP_AUTH_TOKEN (the default) leaves
|
||||
// every request stripped, as before. The scope is the normalized pathname,
|
||||
// so a query string can't widen it and /api/mcpfoo doesn't qualify.
|
||||
delete headers.authorization;
|
||||
const upstreamPath = upstream.pathname;
|
||||
const isMcpRoute = upstreamPath === '/api/mcp' || upstreamPath.startsWith('/api/mcp/');
|
||||
if (isMcpRoute && mcpAuthToken) headers.authorization = `Bearer ${mcpAuthToken}`;
|
||||
headers.host = upstream.host;
|
||||
// Replace, never forward, the inbound chain (see clientAddressFor).
|
||||
const clientAddress = clientAddressFor(req);
|
||||
|
|
|
|||
|
|
@ -271,6 +271,12 @@ it('does not inject config into static assets', async () => {
|
|||
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
|
||||
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
|
||||
|
||||
// The protocol token the upstream expects on /api/mcp. Deliberately unlike the
|
||||
// edge token, so "injected the backend credential" and "forwarded the edge one"
|
||||
// can never both satisfy an assertion.
|
||||
const TEST_MCP_TOKEN = 'backend-mcp-token-0123456789abcdefghij';
|
||||
const TEST_MCP_BEARER = `Bearer ${TEST_MCP_TOKEN}`;
|
||||
|
||||
// rawRequest never sends credentials; apiRequest does. In a file whose subject
|
||||
// is who gets let through, no test should pass because a helper quietly
|
||||
// authenticated for it.
|
||||
|
|
@ -376,6 +382,11 @@ async function withProxy(
|
|||
const proc = spawnServerWithEnv(dir, port, {
|
||||
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
|
||||
// An ambient GITNEXUS_MCP_AUTH_TOKEN in the developer's shell would make the
|
||||
// proxy inject one on /api/mcp, so drop it: spawn omits undefined entries,
|
||||
// which unsets the inherited value. A test that wants injection sets it via
|
||||
// `env` below.
|
||||
GITNEXUS_MCP_AUTH_TOKEN: undefined,
|
||||
...env,
|
||||
});
|
||||
proc.stderr.setEncoding('utf8');
|
||||
|
|
@ -969,8 +980,9 @@ it('forwards an /api/* request that carries the correct token', async () => {
|
|||
});
|
||||
|
||||
it('strips the Authorization header instead of forwarding the edge token', async () => {
|
||||
// The token is spent at this hop. `serve` reads no Authorization header, so
|
||||
// forwarding would only copy a live credential into another service's logs.
|
||||
// The edge credential is spent and stripped at this hop. Forwarding it
|
||||
// would copy a live credential into another service's logs. With no
|
||||
// GITNEXUS_MCP_AUTH_TOKEN configured — the default — nothing replaces it.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, 'the request itself must still be proxied');
|
||||
|
|
@ -978,6 +990,72 @@ it('strips the Authorization header instead of forwarding the edge token', async
|
|||
});
|
||||
});
|
||||
|
||||
// -- Upstream MCP token injection (GITNEXUS_MCP_AUTH_TOKEN) -----------------
|
||||
//
|
||||
// A backend running protocol-layer MCP auth expects its own Bearer on
|
||||
// /api/mcp, and the edge credential can't serve as one. Both services are
|
||||
// configured with the same GITNEXUS_MCP_AUTH_TOKEN; this hop spends the edge
|
||||
// token and substitutes the backend one, for that route only.
|
||||
|
||||
// Stands in for a `serve` with MCP Bearer auth enabled: only the exact backend
|
||||
// credential gets through, so a passing two-hop request proves what was sent.
|
||||
const mcpBackend = (req, res) => {
|
||||
if (req.headers.authorization !== TEST_MCP_BEARER) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"error":"unauthorized"}');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"ok":true}');
|
||||
};
|
||||
|
||||
it('treats a blank GITNEXUS_MCP_AUTH_TOKEN as unset and still strips', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: ' ' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.headers.authorization, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the edge credential with the upstream MCP token on /api/mcp', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, 'a backend that demands the MCP token must accept this hop');
|
||||
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER);
|
||||
assert.notEqual(
|
||||
ctx.received.headers.authorization,
|
||||
TEST_BEARER,
|
||||
'the edge credential must never be forwarded',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('injects the upstream MCP token on /api/mcp subpaths and ignores the query string', async () => {
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ upstream: mcpBackend, env }, async (port, ctx) => {
|
||||
for (const path of ['/api/mcp/messages', '/api/mcp?session=abc']) {
|
||||
const res = await apiRequest(port, path, { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, `${path} must reach the MCP backend authenticated`);
|
||||
assert.equal(ctx.received.headers.authorization, TEST_MCP_BEARER, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves non-MCP routes stripped when an upstream MCP token is configured', async () => {
|
||||
// /api/mcpfoo shares a prefix with the MCP route but is not it, and a plain
|
||||
// API route never carries a protocol credential.
|
||||
const env = { GITNEXUS_MCP_AUTH_TOKEN: TEST_MCP_TOKEN };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
for (const path of ['/api/mcpfoo', '/api/health']) {
|
||||
const res = await apiRequest(port, path);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.headers.authorization, undefined, path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates static assets behind the token', async () => {
|
||||
// The UI has to load before it can prompt for a token.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
|
|
|
|||
312
docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md
Normal file
312
docs/plans/2026-08-28-gitnexus-plan-impact-file-risk.md
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: Fix #3075 — File `impact` risk is not comparable to Function/Method risk.
|
||||
> Evidence verified at commit `6bff33d14cbfe1e7b4f04bca51507e9f64ef579c` (`feat/kotlin-const-resolver`); GitNexus index 129 commits behind, refresh skipped: full-repo `--index-only --pdg` rebuild is impractical this session. Scorer and schema claims are `[verified]` from source; live inversion numbers are `[graph]` on the stale index.
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Make File vs symbol `impact.risk` honest for consumers: either they can tell the scales differ, or they can compare on a shared two-axis score. Do **not** DEFINES-bridge processes/modules onto File targets (issue reporter sampled 8/10 one-importer files jumping to HIGH/CRITICAL). Do **not** retune Function HIGH/CRITICAL thresholds (agent warn-before-edit).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A File with a wider blast radius than a Function in the same file no longer looks “safer” when a consumer only reads `risk`, **or** the result states that `risk` is not comparable across kinds and offers `riskSharedAxes` for comparison.
|
||||
- File targets still cannot trip HIGH/CRITICAL via `processes_affected` / `modules_affected` unless those axes become real in the index (they are not today).
|
||||
- Existing Function/Method labels under the current four-axis ladder stay the same for the same inputs.
|
||||
- MCP `riskNote` remains UNKNOWN-only (`tools.ts` contract).
|
||||
|
||||
## 2. Current Behaviour
|
||||
|
||||
Callgraph `impact` ends in `LocalBackend._runImpactBFS` (`gitnexus/src/mcp/local/local-backend.ts`). After BFS it enriches impacted ids with `STEP_IN_PROCESS` and `MEMBER_OF`, then scores:
|
||||
|
||||
```7720:7738:gitnexus/src/mcp/local/local-backend.ts
|
||||
} else 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';
|
||||
} else {
|
||||
risk = 'LOW';
|
||||
}
|
||||
```
|
||||
|
||||
Empty upstream → `UNKNOWN` + `riskNote`. Downstream empty stays LOW. `skipEnrichment` (ambiguous probes) already scores on direct+total only. PDG mode forces `risk: UNKNOWN` (`composeUnifiedPdgImpactResult`) — out of scope.
|
||||
|
||||
File BFS walk is mostly File←IMPORTS File. Enrichment queries those File ids. Processes are CALLS traces (`process-processor.ts`); communities admit only Function/Class/Method/Interface (`isCommunitySymbol` in `community-processor.ts:412-416`). File is not in that set. `enrichCandidateLabels` UNION also **omits File**, so File `target.type` is often `""`; detect File via `id` prefix `File:`.
|
||||
|
||||
Web Graph RAG (`gitnexus-web/src/core/llm/tools.ts` ~1331–1346) duplicates the same ladder.
|
||||
|
||||
## 3. Relevant Architecture
|
||||
|
||||
| Layer | Role |
|
||||
|---|---|
|
||||
| Index | File never sources `STEP_IN_PROCESS` / `MEMBER_OF` by construction |
|
||||
| MCP `_runImpactBFS` | Blast radius + four-axis `risk` |
|
||||
| Ambiguous probes | `skipEnrichment` → 2-axis `risk` already |
|
||||
| `mergeRisk` | Group overlay; monotone in crossings; does not know target kind |
|
||||
| CLI `formatImpactResult` | Prints counts; **does not print `risk`** on the resolved callgraph path; JSON `impactCommand` still ships `risk` |
|
||||
| `ai-context.ts` / `tools.ts` | Agent contract: warn on HIGH/CRITICAL; `riskNote` UNKNOWN-only |
|
||||
| Web LLM `impact` | Same formula, prose `RISK:` line |
|
||||
|
||||
Modules: Local (MCP), Cli (format/docs), Group (`mergeRisk`), gitnexus-web LLM tools. Shared package `gitnexus-shared` is already a dependency of both CLI and web.
|
||||
|
||||
## 4. GitNexus Findings
|
||||
|
||||
- Primary: `_runImpactBFS` — d=1 `[graph]` `impact(target:_runImpactBFS, maxDepth:1, includeTests:true)`: `_impactImpl`, `impactByUid`. Production chain `[verified]`: `impact` → `_impactImpl` → `_runImpactBFS`; `impactByUid` skips per-symbol process lists but **not** aggregation (`skipPerSymbolEnrichment` only).
|
||||
- `LocalBackend.impact` d=1 `[graph]` `context`: `callTool`.
|
||||
- Duplicate scorer `[verified]` grep: `gitnexus-web/src/core/llm/tools.ts`.
|
||||
- `mergeRisk` `[verified]` callers in `src/`: only `runGroupImpact` (`cross-impact.ts:907`). Graph d=1 listed a test File (`impact-pdg-shape.test.ts`) and missed `runGroupImpact` — trust source.
|
||||
- Schema `[verified]`: `isCommunitySymbol` excludes File; `schema.ts` documents MEMBER_OF as Function/Class/Method/Interface only.
|
||||
- Live inversion `[graph]` stale index, `impact summaryOnly` on GitNexus:
|
||||
|
||||
| target | kind | impacted | direct | processes | modules | risk |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `lbug-config.ts` | File | 54 | 12 | 0 | 0 | MEDIUM |
|
||||
| `openLbugConnection` | Function | 16 | 9 | 3 | 2 | HIGH |
|
||||
| `local-backend.ts` | File | 12 | 10 | 0 | 0 | MEDIUM |
|
||||
| `refreshRepos` | Method | 50 | 5 | 4 | 7 | CRITICAL |
|
||||
|
||||
- Clusters/processes resources `[graph]`: Local/Cli/Group sit in the impact path; process traces are function-stepped, not File-stepped.
|
||||
- Related tests `[verified]`: `test/unit/impact-pagination.test.ts` (CRITICAL from `direct=400`); `test/integration/impact-zero-caller-risk.test.ts` (`withTestLbugDB` seed — pattern to extend); `test/unit/eval-formatters.test.ts` (`formatImpactResult`); group `mergeRisk` tests.
|
||||
|
||||
## 5. Statement-Level PDG Findings
|
||||
|
||||
PDG unavailable (`pdg_query` on `_runImpactBFS`: “no PDG layer”). Recommend `node .gitnexus/run.cjs analyze --index-only --pdg` before any future statement-slice work. Control flow of the scorer is a straight if/else after enrichment; no hidden guards. `skipEnrichment` is the only branch that structurally zeros process/module counts besides File ids.
|
||||
|
||||
## 6. Proposed Changes
|
||||
|
||||
### 6.1 Extract `scoreImpactRisk` — `gitnexus-shared/src/impact-risk.ts` (new)
|
||||
|
||||
- **Responsibility:** Pure function: `{ direction, directCount, processCount, moduleCount, impactedCount, unusedAxes }` → `{ risk, riskSharedAxes, riskScale }`.
|
||||
- **Behaviour:** Existing UNKNOWN/CRITICAL/HIGH/MEDIUM/LOW thresholds unchanged when `unusedAxes` is empty. `riskSharedAxes` always scores as if `processCount=0` and `moduleCount=0` (UNKNOWN rule still applies). `riskScale.comparableAcrossKinds` is false iff `unusedAxes` is non-empty. `riskScale.unusedAxes` lists `{ axis, reason }`.
|
||||
- **Constraints:** Zero deps. Export from `gitnexus-shared/src/index.ts`. Do not put MCP types here.
|
||||
- **File detection:** caller passes unused axes; helper does not parse UIDs.
|
||||
|
||||
### 6.2 Wire MCP — `_runImpactBFS` in `local-backend.ts`
|
||||
|
||||
- After computing `processCount`/`moduleCount`, set `unusedAxes`:
|
||||
- target `id` starts with `File:` **or** `symType === 'File'` → processes + modules, reason `file-nodes-have-no-process-or-community-membership`;
|
||||
- `skipEnrichment` → same axes, reason `enrichment-skipped` (ambiguous probes).
|
||||
- Replace inline ladder with `scoreImpactRisk`.
|
||||
- Spread `riskScale` and `riskSharedAxes` on the result next to `risk`. Do **not** set `riskNote` for File.
|
||||
- Ambiguous candidate summaries: forward the new fields (probes already skip enrichment).
|
||||
- `target.type` for File: if still `""`, prefer `'File'` when `id` starts with `File:` (display-only; helps CLI).
|
||||
|
||||
### 6.3 Web duplicate — `gitnexus-web/src/core/llm/tools.ts`
|
||||
|
||||
- Import `scoreImpactRisk` from `gitnexus-shared`. Print `RISK:` from `risk`; if `!comparableAcrossKinds`, one extra line: not comparable to Function risk; shared-axes label is `riskSharedAxes`.
|
||||
|
||||
### 6.4 Agent/MCP contract copy
|
||||
|
||||
- `gitnexus/src/mcp/tools.ts` impact description: document `riskScale` / `riskSharedAxes`; keep `riskNote` UNKNOWN-only; say File `risk` is not comparable to symbol `risk`.
|
||||
- `gitnexus/src/cli/ai-context.ts`: HIGH/CRITICAL warning still applies; add: do not rank a File `MEDIUM` below a contained Function `HIGH` without `riskSharedAxes`.
|
||||
- `formatImpactResult`: on resolved callgraph results with `risk`, print `Risk: {risk}` and, when incomparable, `Shared-axes risk: {riskSharedAxes} (File/process axes unused)`.
|
||||
|
||||
### 6.5 Explicitly not changing
|
||||
|
||||
- DEFINES-bridge, community/process indexers, `mergeRisk` formula, PDG `UNKNOWN`, `detectChanges` `risk_level`, Function thresholds.
|
||||
|
||||
## 7. Implementation Sequence
|
||||
|
||||
1. Add `gitnexus-shared` helper + unit table (issue-shaped inputs + UNKNOWN + skipEnrichment). Shared package tests if present; otherwise `gitnexus/test/unit/impact-risk.test.ts` importing the helper.
|
||||
2. Switch `_runImpactBFS` + candidate probe payload. Tree still coherent: old `risk` values identical for Function fixtures.
|
||||
3. Integration seed in `impact-zero-caller-risk.test.ts` **or** new `impact-file-risk-scale.test.ts`: File with ≥5 File IMPORTS (MEDIUM on direct) vs Function with 3 process-member callers (HIGH); assert File `riskScale.comparableAcrossKinds === false`, Function true, File `riskSharedAxes === risk`, Function `riskSharedAxes` is LOW/MEDIUM while `risk` is HIGH.
|
||||
4. CLI formatter + `eval-formatters.test.ts`.
|
||||
5. `tools.ts` + `ai-context.ts` wording.
|
||||
6. Web import + a unit assertion on the printed RISK block if a test already covers that tool.
|
||||
7. `npx tsc --noEmit` in `gitnexus/` and `gitnexus-web/`; `cd gitnexus && npm run test:unit -- test/unit/impact-risk.test.ts test/unit/eval-formatters.test.ts`; integration file from step 3.
|
||||
|
||||
## 8. Test Strategy
|
||||
|
||||
| File | Scenarios |
|
||||
|---|---|
|
||||
| `gitnexus/test/unit/impact-risk.test.ts` (new) | Issue table: File(25,13,0,0)→MEDIUM; Function(15,2,4,2)→HIGH; shared-axes File MEDIUM vs Function LOW; empty upstream UNKNOWN; downstream empty LOW; skipEnrichment unused axes; CRITICAL via direct≥30 still works with unused process axes |
|
||||
| `gitnexus/test/integration/impact-file-risk-scale.test.ts` (new) | `withTestLbugDB` seed: `File:src/crypto.ts` ← 13 File IMPORTS, no File STEP_IN_PROCESS; `getEncryptionKey` with 2 CALLS from functions that have STEP_IN_PROCESS to 4 distinct Process nodes — reproduce inversion; assert new fields |
|
||||
| `gitnexus/test/integration/impact-zero-caller-risk.test.ts` | Unchanged UNKNOWN/`riskNote`; candidates may grow `riskScale` — assert still present only when UNKNOWN for `riskNote` |
|
||||
| `gitnexus/test/unit/impact-pagination.test.ts` | Hub CRITICAL unchanged |
|
||||
| `gitnexus/test/unit/eval-formatters.test.ts` | Resolved result prints Risk + shared-axes line for File-shaped `riskScale` |
|
||||
| Web | Only if an existing Graph RAG impact test snapshots `RISK:` |
|
||||
|
||||
Commands (exist in `gitnexus/package.json`): `npm run test:unit`, `npm test` (full vitest), `npx tsc --noEmit`. Web: `npm test`, `npx tsc -b --noEmit`. Integration needs `pretest:integration` / `npm run test:integration` (runs `scripts/build.js`).
|
||||
|
||||
## 9. Risk and Impact Analysis
|
||||
|
||||
Direct dependents of `_runImpactBFS` `[graph]`: `_impactImpl`, `impactByUid`. `_impactImpl` is the only d=1 of `impact` besides the method’s own class. Any JSON consumer of `impact` (MCP, CLI `output(result)`, group local leg) sees additive fields — compatible if they ignore unknowns.
|
||||
|
||||
- **HIGH workflow:** Function HIGH/CRITICAL unchanged. File still cannot reach HIGH via processes; a File with `direct≥15` or `total≥100` still can. Agents that compare File MEDIUM vs Function HIGH must start using `riskSharedAxes` or `riskScale`.
|
||||
- **Ambiguous `maxRisk`:** probes skip enrichment, so File vs Function candidates are already 2-axis there — inversion is weaker on that path.
|
||||
- **Group `mergeRisk`:** still compares incomparable File local `risk` to crossing count. Do not retune this PR; if a group File target is common, follow-up.
|
||||
- **Web:** browser bundle picks up `gitnexus-shared` export — confirm `gitnexus-shared` build/exports include the new file.
|
||||
- **Performance:** none (pure arithmetic after existing enrichment).
|
||||
- **Ladybug empty labels:** File detection must not rely on `symType` alone.
|
||||
|
||||
## 10. Files Expected to Change
|
||||
|
||||
| File | Symbols | Reason |
|
||||
|---|---|---|
|
||||
| `gitnexus-shared/src/impact-risk.ts` | `scoreImpactRisk` | New shared scorer |
|
||||
| `gitnexus-shared/src/index.ts` | exports | Public helper |
|
||||
| `gitnexus/src/mcp/local/local-backend.ts` | `_runImpactBFS`, ambiguous candidate map | Wire scorer + File unused axes |
|
||||
| `gitnexus/src/mcp/tools.ts` | `impact` description | Contract |
|
||||
| `gitnexus/src/cli/ai-context.ts` | generated Always Do | Agent warning |
|
||||
| `gitnexus/src/cli/eval-server.ts` | `formatImpactResult` | Print scale |
|
||||
| `gitnexus-web/src/core/llm/tools.ts` | web `impact` | Same formula |
|
||||
| `gitnexus/test/unit/impact-risk.test.ts` | — | Table tests |
|
||||
| `gitnexus/test/integration/impact-file-risk-scale.test.ts` | — | Seeded inversion |
|
||||
| `gitnexus/test/unit/eval-formatters.test.ts` | `formatImpactResult` | Formatter |
|
||||
|
||||
## 11. Reusable Implementation Context
|
||||
|
||||
```yaml
|
||||
implementation_context:
|
||||
task_summary: "Fix #3075: File impact.risk is a 2-axis score silently labelled on a 4-axis scale. Extract scoreImpactRisk; mark File/skipEnrichment axes unused; add riskScale + riskSharedAxes; do not DEFINES-bridge or retune Function thresholds."
|
||||
acceptance_criteria:
|
||||
- "File vs Function comparison is either labelled incomparable (riskScale) or done via riskSharedAxes"
|
||||
- "Function/Method risk for identical four-axis inputs unchanged"
|
||||
- "riskNote still UNKNOWN-only"
|
||||
- "Integration seed reproduces crypto.ts-style inversion and asserts the new fields"
|
||||
primary_symbols:
|
||||
- symbol: "_runImpactBFS"
|
||||
file: "gitnexus/src/mcp/local/local-backend.ts"
|
||||
lines: "6991-7888"
|
||||
role: "BFS + enrichment + inline risk ladder (replace ladder only)"
|
||||
- symbol: "scoreImpactRisk"
|
||||
file: "gitnexus-shared/src/impact-risk.ts"
|
||||
lines: "new"
|
||||
role: "Pure scorer + shared-axes + riskScale"
|
||||
- symbol: "formatImpactResult"
|
||||
file: "gitnexus/src/cli/eval-server.ts"
|
||||
lines: "305-641"
|
||||
role: "Human/LLM text surface for impact JSON"
|
||||
related_symbols:
|
||||
- symbol: "_impactImpl"
|
||||
relationship: "CALLS"
|
||||
relevance: "Resolves target, PDG vs callgraph, ambiguous skipEnrichment probes"
|
||||
- symbol: "impactByUid"
|
||||
relationship: "CALLS"
|
||||
relevance: "Group fan-out; keep skipPerSymbolEnrichment; still run aggregation"
|
||||
- symbol: "mergeRisk"
|
||||
relationship: "consumes risk string"
|
||||
relevance: "Do not change this PR"
|
||||
- symbol: "isCommunitySymbol"
|
||||
relationship: "index gate"
|
||||
relevance: "Why File modules_affected is always 0"
|
||||
- symbol: "composeUnifiedPdgImpactResult"
|
||||
relationship: "separate path"
|
||||
relevance: "PDG risk stays UNKNOWN"
|
||||
execution_path:
|
||||
- "impact / callTool → _impactImpl (resolve symbol, File id prefix File:)"
|
||||
- "_runImpactBFS: IMPORTS-heavy walk for File; CALLS walk for Function"
|
||||
- "Enrich STEP_IN_PROCESS / MEMBER_OF on impacted ids (empty for File ids)"
|
||||
- "scoreImpactRisk with unusedAxes for File or skipEnrichment"
|
||||
- "JSON to MCP/CLI; formatImpactResult for eval text; web LLM tools parallel path"
|
||||
pdg_constraints:
|
||||
- description: "No PDG layer on the planning index; scorer is post-enrichment arithmetic"
|
||||
affected_statements: []
|
||||
implementation_consequence: "Do not wait on PDG; do not change pdg impact risk"
|
||||
architectural_patterns:
|
||||
- pattern: "Additive optional JSON fields on impact (riskNote, epistemic, partial)"
|
||||
example_location: "gitnexus/src/mcp/local/local-backend.ts _runImpactBFS base object ~7754"
|
||||
usage_guidance: "Add riskScale/riskSharedAxes the same way; never overload riskNote"
|
||||
- pattern: "withTestLbugDB CREATE seed for impact contract"
|
||||
example_location: "gitnexus/test/integration/impact-zero-caller-risk.test.ts"
|
||||
usage_guidance: "Seed File IMPORTS + Function CALLS + Process membership separately"
|
||||
files_to_modify:
|
||||
- file: "gitnexus-shared/src/impact-risk.ts"
|
||||
symbols: ["scoreImpactRisk"]
|
||||
intended_change: "new pure scorer"
|
||||
- file: "gitnexus-shared/src/index.ts"
|
||||
symbols: []
|
||||
intended_change: "re-export"
|
||||
- file: "gitnexus/src/mcp/local/local-backend.ts"
|
||||
symbols: ["_runImpactBFS"]
|
||||
intended_change: "unusedAxes + helper; File type display"
|
||||
- file: "gitnexus/src/mcp/tools.ts"
|
||||
symbols: []
|
||||
intended_change: "document fields"
|
||||
- file: "gitnexus/src/cli/ai-context.ts"
|
||||
symbols: []
|
||||
intended_change: "agent comparability note"
|
||||
- file: "gitnexus/src/cli/eval-server.ts"
|
||||
symbols: ["formatImpactResult"]
|
||||
intended_change: "print risk + shared-axes when incomparable"
|
||||
- file: "gitnexus-web/src/core/llm/tools.ts"
|
||||
symbols: []
|
||||
intended_change: "import helper; extra prose line"
|
||||
tests:
|
||||
- file: "gitnexus/test/unit/impact-risk.test.ts"
|
||||
scenarios:
|
||||
- "File(25,13,0,0)+unused process/module → risk MEDIUM, comparableAcrossKinds false, riskSharedAxes MEDIUM"
|
||||
- "Function(15,2,4,2) → HIGH, riskSharedAxes LOW (direct 2, total 15)"
|
||||
- "upstream impactedCount 0 → UNKNOWN both fields"
|
||||
- "direct 400 → CRITICAL even with unused process axes"
|
||||
- file: "gitnexus/test/integration/impact-file-risk-scale.test.ts"
|
||||
scenarios:
|
||||
- "Seed File crypto.ts with 13 File importers vs getEncryptionKey with process-rich callers → inversion on risk, File incomparable, Function comparable"
|
||||
- file: "gitnexus/test/unit/eval-formatters.test.ts"
|
||||
scenarios:
|
||||
- "formatImpactResult includes Shared-axes risk when riskScale.comparableAcrossKinds is false"
|
||||
verification_commands:
|
||||
- "cd gitnexus && npx tsc --noEmit"
|
||||
- "cd gitnexus && npm run test:unit -- test/unit/impact-risk.test.ts test/unit/eval-formatters.test.ts test/unit/impact-pagination.test.ts"
|
||||
- "cd gitnexus && npm run test:integration -- test/integration/impact-file-risk-scale.test.ts test/integration/impact-zero-caller-risk.test.ts"
|
||||
- "cd gitnexus-web && npx tsc -b --noEmit"
|
||||
risks:
|
||||
- "Consumers that only read risk still see the inversion unless they adopt riskScale/riskSharedAxes — that is the chosen (explicit-scale) fix"
|
||||
- "File type often empty; must key unusedAxes off File: id prefix"
|
||||
- "gitnexus-shared export must reach the web bundle"
|
||||
assumptions:
|
||||
- "WHAT: File nodes never gain STEP_IN_PROCESS/MEMBER_OF without an indexer change. HOW: keep isCommunitySymbol and process traces as-is; tests seed File with zero such edges"
|
||||
- "WHAT: Additive JSON fields are backward compatible. HOW: existing tests that exact-match the full impact object may need to allow extra keys — grep expect(res).toEqual on impact results before landing"
|
||||
- "WHAT: HEAD 6bff33d is the pin; scorer line numbers ~7720. HOW: re-read the ladder if that hunk moved"
|
||||
open_questions:
|
||||
- "Whether GroupImpactResult should copy riskScale from local File targets (deferred unless tests already snapshot the full group object)"
|
||||
avoid:
|
||||
- "Do not DEFINES-bridge File→symbol processes/modules"
|
||||
- "Do not lower Function process/module HIGH/CRITICAL thresholds"
|
||||
- "Do not reuse riskNote for File incomparability"
|
||||
- "Do not change PDG impact risk or detectChanges risk_level"
|
||||
- "Do not treat labels(n)[0] or empty target.type as proof the node is not a File"
|
||||
- "Do not repeat full repository discovery"
|
||||
```
|
||||
|
||||
## 12. Assumptions and Open Questions
|
||||
|
||||
**Assumptions**
|
||||
|
||||
- Indexer will not start attaching File→Process/Community in this change (`isCommunitySymbol` stays). `[verified]` source; `[assumed]` future indexers.
|
||||
- Ignoring unknown JSON keys is safe for MCP clients; any `toEqual` goldens in-repo must be updated. `[assumed]` — grep during implement.
|
||||
- Stale-index inversion (`lbug-config.ts` vs `openLbugConnection`) is illustrative; the integration seed is the regression lock. `[graph]` vs `[verified]` seed.
|
||||
|
||||
**Open questions**
|
||||
|
||||
- Group `mergeRisk` + File local risk: copy `riskScale` onto `GroupImpactResult`? Default **no** unless a test breaks.
|
||||
- Class/Interface STEP_IN_PROCESS sparsity: out of scope (#3075 is File).
|
||||
- Printing `risk` on CLI formatted output is new (JSON already has it). Keep the extra lines short.
|
||||
|
||||
**Deferred**
|
||||
|
||||
- Recalibrated File-only HIGH thresholds.
|
||||
- Indexing File community membership.
|
||||
- DEFINES-bridge after a threshold RFC.
|
||||
- Related #2975 (docs vs scorer wording) except as touched by `tools.ts`.
|
||||
|
||||
## 13. Definition of Done
|
||||
|
||||
- [ ] `scoreImpactRisk` is the only callgraph ladder in MCP and web.
|
||||
- [ ] File (and skipEnrichment) results include `riskScale.comparableAcrossKinds === false` and `riskSharedAxes`.
|
||||
- [ ] Function four-axis HIGH/CRITICAL cases in unit tests still pass with the same labels.
|
||||
- [ ] Integration seed proves wider File blast + lower `risk` than a contained Function, and `riskSharedAxes` orders them without pretending processes existed on the File.
|
||||
- [ ] `riskNote` still absent unless `risk === 'UNKNOWN'`.
|
||||
- [ ] `tools.ts` + `ai-context.ts` state that File `risk` is not comparable to symbol `risk`.
|
||||
- [ ] `cd gitnexus && npx tsc --noEmit` and the named unit/integration commands pass; web typecheck passes.
|
||||
|
|
@ -19,14 +19,14 @@
|
|||
*
|
||||
* False-positive suppression:
|
||||
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
|
||||
* `URL`, `marked`, `Number`).
|
||||
* `URL`, `marked`, `Number`, `path`).
|
||||
* - Skips calls whose first argument is a string-literal (grammar-load smoke
|
||||
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
|
||||
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
|
||||
* - Skips the `safe-parse.ts` helper itself.
|
||||
*/
|
||||
|
||||
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
|
||||
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']);
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
|
|
@ -74,7 +74,7 @@ export default {
|
|||
// Receiver-text-shape skip: anything matching well-known JS APIs that
|
||||
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
|
||||
if (
|
||||
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
|
||||
/^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) ||
|
||||
/\bjson\.parse\b/i.test(receiverText)
|
||||
) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ from .proposer_sandbox import (
|
|||
)
|
||||
|
||||
HARNESS_ROOT = Path(__file__).resolve().parents[2]
|
||||
# The mounted runtime is built from this checkout, so the pin tracks the harness'
|
||||
# own package version. A hardcoded copy only drifts on release day (#3064).
|
||||
PINNED_GITNEXUS_VERSION = json.loads((HARNESS_ROOT / "gitnexus" / "package.json").read_text())["version"]
|
||||
|
||||
CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"})
|
||||
SANDBOX_CE_PLUGIN = "/opt/compound-engineering-plugin"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"author": {
|
||||
"name": "GitNexus"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "gitnexus",
|
||||
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"skills": "./skills",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"hooks": "./hooks/hooks.json",
|
||||
|
|
|
|||
|
|
@ -543,7 +543,7 @@ function handlePostToolUse(input) {
|
|||
// If HEAD matches last indexed commit, no reindex needed
|
||||
if (currentHead && currentHead === lastCommit) return;
|
||||
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true });
|
||||
sendHookResponse(
|
||||
'PostToolUse',
|
||||
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
|
|
|
|||
|
|
@ -276,7 +276,13 @@ function formatBunxCommand(gitnexusArgs) {
|
|||
}
|
||||
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// `--index-only` is what a routine "your index is stale" nudge wants: it
|
||||
// reindexes without rewriting AGENTS.md / CLAUDE.md / skills, so an agent
|
||||
// following the nudge on every commit cannot churn the tracked agent guides
|
||||
// (#2907). Callers that actually want the docs refreshed omit it.
|
||||
const suffix = `${options.indexOnly ? ' --index-only' : ''}${
|
||||
options.embeddings ? ' --embeddings' : ''
|
||||
}`;
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
// The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with
|
||||
// resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess
|
||||
|
|
|
|||
|
|
@ -19,14 +19,21 @@ node .gitnexus/run.cjs analyze
|
|||
|
||||
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| Flag | Effect |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `--watch` | Keep a Git repository index current with serialized refreshes |
|
||||
| `--debounce <ms>` | Watch quiet period before refresh (default: 300 ms) |
|
||||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
||||
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
|
||||
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
||||
|
||||
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
|
||||
|
||||
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
|
@ -44,10 +51,10 @@ node .gitnexus/run.cjs clean
|
|||
|
||||
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
| Flag | Effect |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `--force` | Skip confirmation prompt |
|
||||
| `--all` | Clean all indexed repos, not just the current one |
|
||||
|
||||
### wiki — Generate documentation from the graph
|
||||
|
||||
|
|
@ -55,19 +62,21 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi
|
|||
node .gitnexus/run.cjs wiki
|
||||
```
|
||||
|
||||
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
||||
Generates repository documentation from the knowledge graph using an LLM. HTTP providers require an API key (saved to `~/.gitnexus/config.json` on first use). Local CLI providers (`--provider cursor|claude|codex|opencode|grok`) use your existing CLI login.
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration, also required to re-generate an existing wiki in a different language |
|
||||
| `--provider <name>` | LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax). Local CLIs (`cursor`, `claude`, `codex`, `opencode`, `grok`) use your existing CLI login and skip `--api-key`. |
|
||||
| `--model <model>` | LLM model (default: MiniMax-M3) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
||||
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese)|
|
||||
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
|
||||
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese) |
|
||||
| `--gist` | Publish wiki as a public GitHub Gist |
|
||||
|
||||
### list — Show all indexed repos
|
||||
|
||||
```bash
|
||||
|
|
@ -84,5 +93,5 @@ Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_
|
|||
## Troubleshooting
|
||||
|
||||
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
||||
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
||||
- **Index is stale after re-analyzing**: Wait for the next MCP tool call to reopen the published index; this normally takes no more than five seconds
|
||||
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,28 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
A root cause traced in the wrong repository is a wrong root cause.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. This matters most for `cypher`, whose
|
||||
statement carries no in-band hint of which database it ran against.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
A stale index describes the code from before your bug, so refresh before
|
||||
trusting a trace, and state the repository and index freshness with the
|
||||
diagnosis.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
|
|
@ -27,6 +46,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
|
|
@ -34,6 +54,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
- [ ] State the repository and index freshness with the diagnosis
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
|
@ -44,7 +65,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
|
@ -52,7 +73,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
|
|||
**query** — find code related to error:
|
||||
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
query({search_query: "payment validation error", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
|
@ -60,13 +81,15 @@ query({search_query: "payment validation error"})
|
|||
**context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
context({name: "validatePayment", repo: "my-app"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces. Pass `repo` alongside the statement; the
|
||||
Cypher text itself names no repository, so the result is unattributable without
|
||||
it:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
|
|
@ -76,7 +99,7 @@ RETURN [n IN nodes(path) | n.name] AS chain
|
|||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
|
|
@ -87,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe
|
|||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — bind my-app explicitly on every call
|
||||
|
||||
1. query({search_query: "payment error handling", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment", repo: "my-app"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
Repository: my-app Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,22 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Step 1 discovers what is indexed; every call after it must say which of those
|
||||
it means. With one indexed repository, use the examples below as written. With
|
||||
more than one, pass `repo` on every call: an omitted `repo` normally errors,
|
||||
but under an MCP policy with a configured default it resolves to that default
|
||||
silently. If you cannot tell which repository is meant, stop and ask. Report
|
||||
the bound repository and index freshness alongside your explanation.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
1. list_repos {} or READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
|
|
@ -28,12 +40,14 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
- [ ] State the repository and index freshness with the explanation
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
|
@ -50,7 +64,7 @@ description: "Use when the user asks how code works, wants to understand archite
|
|||
**query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
query({search_query: "payment processing", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
|
@ -58,16 +72,20 @@ query({search_query: "payment processing"})
|
|||
**context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
context({name: "validateUser", repo: "my-app"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
`repo` is required once more than one repository is indexed, and may be omitted
|
||||
with a single one.
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
1. list_repos {} → total: 1 (my-app) — bind it
|
||||
READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
|
|
@ -75,4 +93,8 @@ context({name: "validateUser"})
|
|||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
5. Answer, noting: Repository my-app, index current
|
||||
```
|
||||
|
||||
Had step 1 returned two repositories, every call above would carry
|
||||
`repo: "my-app"`.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,13 +14,42 @@ description: "Use when the user wants to know what will break if they change som
|
|||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Impact analysis is the gate that authorizes an edit, so it must answer for the
|
||||
repository you are about to edit.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask — every result below an ambiguous
|
||||
identity inherits the ambiguity. `list_repos` is paginated, so page with
|
||||
`offset: pagination.nextOffset` until `hasMore` is false before concluding a
|
||||
repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when your changes are in a linked worktree
|
||||
the MCP server was not launched from. The server auto-detects a worktree only
|
||||
when it was launched from inside one; otherwise `git diff` runs in the wrong
|
||||
checkout and reports zero changed symbols — a false clean check that carries
|
||||
none of the degradation flags described below. In the CLI fallbacks, `--repo .`
|
||||
means the current checkout; pass the intended repository path instead when you
|
||||
are not standing in it.
|
||||
|
||||
State the bound identity with your risk report:
|
||||
|
||||
```
|
||||
Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEAD
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .`
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .`
|
||||
4. Assess risk and report to user
|
||||
4. Assess risk and report to user, echoing repo/worktree/index identity
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
|
@ -29,12 +58,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
- [ ] Confirm the checkout you edited is the checkout that was diffed
|
||||
- [ ] Assess risk level and report, stating repo/worktree/index identity
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
|
@ -62,6 +93,15 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
|||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
|
||||
uncertainty is resolved. Within single-repo mode, compare File and symbol
|
||||
targets with local `riskSharedAxes` (direct/total only). Within group mode,
|
||||
compare only group results: their `riskSharedAxes` overlays resolved
|
||||
cross-repo crossings on that local value. Never use either field to waive the
|
||||
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
|
||||
omit process/module axes, while web Graph-RAG expands File targets to in-file
|
||||
symbols before enrichment.
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:
|
||||
|
|
@ -69,6 +109,7 @@ treating the symbol as safe to change or delete.
|
|||
```
|
||||
impact({
|
||||
target: "validateUser",
|
||||
repo: "my-app", // required once >1 repository is indexed
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
|
|
@ -92,10 +133,26 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
Add `repo` once more than one repository is indexed, and `worktree: "<abs
|
||||
path>"` when your changes are in a linked worktree the server was not launched
|
||||
from.
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth, and reads like
|
||||
`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather
|
||||
than tick the pre-commit check.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is shape-identical to a genuine
|
||||
clean result, so confirm the checkout you edited is the one that was diffed
|
||||
before treating an empty change set as a passed check.
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
|
@ -103,4 +160,8 @@ detect_changes({scope: "all"})
|
|||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,12 +124,17 @@ phase that needs them.
|
|||
statement-level claims (never reconstructs fake edges).
|
||||
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
|
||||
labelled **source-derived**, with a recommendation to index.
|
||||
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
|
||||
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
|
||||
writable target repository, and a shared filesystem for the plan and
|
||||
Git-admin vault. The writer fails closed when those guarantees are
|
||||
unavailable; it never redirects the plan elsewhere.
|
||||
- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus
|
||||
`/proc/self/fd` on Linux; every other platform is refused. No interpreter is
|
||||
spawned and no native code is loaded. Publication is `link(2)`, which fails
|
||||
rather than replaces when the destination name is taken. Linux resolves every
|
||||
name against a held descriptor, so a parent swapped mid-write cannot redirect
|
||||
the operation; macOS has no equivalent path and instead pins each directory
|
||||
with an open descriptor and re-proves the chain either side of every step,
|
||||
which detects such a swap and aborts. Publishing also needs a writable target
|
||||
repository and a shared filesystem for the plan and Git-admin vault. The
|
||||
writer fails closed when those guarantees are unavailable; it never redirects
|
||||
the plan elsewhere.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ excluded.
|
|||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
`read-plan` fails closed unless the host platform can resolve names against a
|
||||
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
|
||||
refused outright — an unverified read is not a degraded read, it is a different,
|
||||
racy operation. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
|
|
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
|||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
The writer fails closed unless the host platform offers `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
|
||||
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
|
||||
the destination name is taken, and refuses a symlinked destination without
|
||||
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
|
||||
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
|
||||
supported platform. The temporary name is unlinked once the link succeeds; the
|
||||
published file is the same inode the writer created and verified, so every
|
||||
identity check downstream holds by construction. A link that succeeds followed
|
||||
by an unlink that fails leaves the plan published and is reported as success,
|
||||
because it is one. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
|
|
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
|
|||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
the parent and the temporary path, inode, size, and digest. Publication links
|
||||
the temporary name to the destination relative to the held directory
|
||||
descriptor, which fails rather than replaces if the destination is taken.
|
||||
Initial mode therefore cannot overwrite a destination that appears after the
|
||||
absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
### Linux anchors, macOS verifies
|
||||
|
||||
The two platforms reach the same destination by different proofs, and the
|
||||
difference is real enough to state rather than smooth over.
|
||||
|
||||
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
|
||||
the kernel resolves against the inode the descriptor already holds. The names
|
||||
above it are never re-walked, so an attacker who renames a parent between the
|
||||
check and the use cannot redirect the operation. The race is impossible, not
|
||||
merely detected.
|
||||
|
||||
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
|
||||
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
|
||||
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
|
||||
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
|
||||
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
|
||||
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
|
||||
every directory in the chain for the whole operation, and proves before *and*
|
||||
after each step that the chain still names exactly the inodes it is holding.
|
||||
Holding the descriptors is what makes the recorded inode numbers trustworthy:
|
||||
an open descriptor pins its inode, so a freed number cannot be recycled beneath
|
||||
the walk.
|
||||
|
||||
What that buys is detection rather than prevention. A parent swapped inside the
|
||||
window between a check and its use is caught by the check that follows, and the
|
||||
operation aborts having written nothing — but on Linux it could not have
|
||||
happened at all. No published byte escapes verification on either platform.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,9 +13,32 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Refactoring writes to disk. `rename` with `dry_run: false` edits files in
|
||||
whichever repository was resolved, so binding identity here is a safety gate,
|
||||
not bookkeeping.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. Never run `rename` with
|
||||
`dry_run: false` until the preview in the same bound repository has been
|
||||
reviewed — its returned `file_path` values show which checkout is about to be
|
||||
written, so read them as a confirmation of identity.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when you are editing a linked worktree the
|
||||
MCP server was not launched from; otherwise `git diff` runs in the wrong
|
||||
checkout and reports nothing changed, which reads as a verified refactor.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
|
|
@ -29,7 +52,9 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Confirm the previewed file paths are in the bound repository/worktree
|
||||
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
|
|
@ -39,6 +64,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
|
|
@ -50,6 +76,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
|
|
@ -64,7 +91,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
|
|||
**rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 text_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
|
|
@ -73,7 +100,7 @@ rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true
|
|||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", repo: "my-app", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
|
@ -87,6 +114,14 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth: a short or empty
|
||||
list is not proof that only the expected files changed. Re-run it rather than
|
||||
treat the refactor as verified.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is indistinguishable from a
|
||||
clean verification, so confirm the diffed checkout is the one you edited.
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
|
|
@ -102,20 +137,28 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 text_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review text_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all", repo: "my-app"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,7 +216,10 @@ Work through plan §7 step by step, in order. For each step:
|
|||
`detect_changes` → commit as one unbroken sequence from the repository
|
||||
root — interleaving other work between the gate and the commit is how
|
||||
the gate gets skipped. Unexpected
|
||||
affected flows → investigate before committing, not after.
|
||||
affected flows → investigate before committing, not after. A result
|
||||
flagged `partial` (a graph query failed) or `truncated` (the symbol
|
||||
listing was capped) blocks the commit the same way: the gate did not
|
||||
see every changed symbol, so re-run it rather than read it as clean.
|
||||
|
||||
A relationship-affecting implementation edit or commit invalidates the
|
||||
procedure's prior proof. The next step must perform the required inter-step
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ excluded.
|
|||
|
||||
## Safe existing-plan read contract
|
||||
|
||||
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
|
||||
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
|
||||
`read-plan` fails closed unless the host platform can resolve names against a
|
||||
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
|
||||
refused outright — an unverified read is not a degraded read, it is a different,
|
||||
racy operation. It resolves the exact Git top-level, opens the
|
||||
repository root and every plan parent as held no-follow directory descriptors,
|
||||
rejects missing, symlink, non-directory, and escaping parents, and opens the
|
||||
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
|
||||
|
|
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
|
|||
|
||||
## Safe generated-plan write contract
|
||||
|
||||
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
|
||||
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
|
||||
available. Python may live in `/usr/local`, a Nix profile, or another absolute
|
||||
PATH directory, but the helper accepts only a resolved executable and
|
||||
containing directory owned by root or the current user and not writable by
|
||||
group/other. The resolved executable is opened without following links and
|
||||
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
|
||||
The writer fails closed unless the host platform offers `O_DIRECTORY` and
|
||||
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
|
||||
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
|
||||
the destination name is taken, and refuses a symlinked destination without
|
||||
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
|
||||
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
|
||||
supported platform. The temporary name is unlinked once the link succeeds; the
|
||||
published file is the same inode the writer created and verified, so every
|
||||
identity check downstream holds by construction. A link that succeeds followed
|
||||
by an unlink that fails leaves the plan published and is reported as success,
|
||||
because it is one. The plan parent and the
|
||||
repository's Git-admin directory must also share a filesystem. It resolves
|
||||
the target repository's exact Git top-level, opens that root and every
|
||||
destination parent as held no-follow directory descriptors, creates missing
|
||||
|
|
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
|
|||
parent descriptor and keeps its no-follow descriptor open. It writes and
|
||||
flushes the bytes, binds the temporary name to the opened inode, and hashes the
|
||||
open file before publication. Immediately before publication it revalidates
|
||||
the parent and the temporary path, inode, size, and digest. Publication uses an
|
||||
atomic no-replace move relative to the held directory descriptor. Initial mode
|
||||
therefore cannot overwrite a destination that appears after the absent check.
|
||||
the parent and the temporary path, inode, size, and digest. Publication links
|
||||
the temporary name to the destination relative to the held directory
|
||||
descriptor, which fails rather than replaces if the destination is taken.
|
||||
Initial mode therefore cannot overwrite a destination that appears after the
|
||||
absent check.
|
||||
The writer then flushes the directory and revalidates the committed path by
|
||||
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
|
||||
path-bound fd, and performing a second descriptor-anchored path identity check
|
||||
after hashing. A detected mutation or replacement aborts instead of accepting
|
||||
mixed-era output.
|
||||
|
||||
### Linux anchors, macOS verifies
|
||||
|
||||
The two platforms reach the same destination by different proofs, and the
|
||||
difference is real enough to state rather than smooth over.
|
||||
|
||||
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
|
||||
the kernel resolves against the inode the descriptor already holds. The names
|
||||
above it are never re-walked, so an attacker who renames a parent between the
|
||||
check and the use cannot redirect the operation. The race is impossible, not
|
||||
merely detected.
|
||||
|
||||
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
|
||||
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
|
||||
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
|
||||
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
|
||||
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
|
||||
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
|
||||
every directory in the chain for the whole operation, and proves before *and*
|
||||
after each step that the chain still names exactly the inodes it is holding.
|
||||
Holding the descriptors is what makes the recorded inode numbers trustworthy:
|
||||
an open descriptor pins its inode, so a freed number cannot be recycled beneath
|
||||
the walk.
|
||||
|
||||
What that buys is detection rather than prevention. A parent swapped inside the
|
||||
window between a check and its use is caught by the check that follows, and the
|
||||
operation aborts having written nothing — but on Linux it could not have
|
||||
happened at all. No published byte escapes verification on either platform.
|
||||
|
||||
`--replace` accepts only a pre-existing regular file and is reserved for
|
||||
Deepen; without it, accidental overwrite is rejected. It also requires the
|
||||
exact canonical `generated_plan_path` and `plan_digest` from the same session's
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -85,40 +85,241 @@ function findGitNexusDir(startDir) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function tokenizeShellWords(command) {
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let hasToken = false;
|
||||
|
||||
for (let index = 0; index < command.length; index += 1) {
|
||||
const char = command[index];
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === "'") {
|
||||
if (char === "'") quote = null;
|
||||
else current += char;
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote === '"') {
|
||||
if (char === '"') {
|
||||
quote = null;
|
||||
} else if (char === '\\') {
|
||||
const next = command[index + 1];
|
||||
if (next === '$' || next === '`' || next === '"' || next === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
current += '\\';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
hasToken = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
const next = command[index + 1];
|
||||
if (next === undefined || /\s/.test(next) || next === "'" || next === '"' || next === '\\') {
|
||||
escaped = true;
|
||||
} else {
|
||||
current += '\\' + next;
|
||||
index += 1;
|
||||
}
|
||||
hasToken = true;
|
||||
} else if (char === "'" || char === '"') {
|
||||
quote = char;
|
||||
hasToken = true;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (hasToken) tokens.push(current);
|
||||
current = '';
|
||||
hasToken = false;
|
||||
} else if (char === ';' || char === '|' || char === '&') {
|
||||
if (hasToken) tokens.push(current);
|
||||
current = '';
|
||||
hasToken = false;
|
||||
const next = command[index + 1];
|
||||
if ((char === '|' || char === '&') && next === char) {
|
||||
tokens.push(char + char);
|
||||
index += 1;
|
||||
} else {
|
||||
tokens.push(char);
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
hasToken = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (escaped) current += '\\';
|
||||
if (hasToken) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseRgGrepPattern(cmd) {
|
||||
const tokens = cmd.split(/\s+/);
|
||||
const tokens = tokenizeShellWords(cmd);
|
||||
let foundCmd = false;
|
||||
let skipNext = false;
|
||||
let skipNextAsPattern = false;
|
||||
let endOfOptions = false;
|
||||
let explicitPatternSeen = false;
|
||||
let patternFileSeen = false;
|
||||
const flagsWithValues = new Set([
|
||||
'-e',
|
||||
'-f',
|
||||
'--file',
|
||||
'-m',
|
||||
'--max-count',
|
||||
'-A',
|
||||
'-B',
|
||||
'-C',
|
||||
'-g',
|
||||
'--glob',
|
||||
'--iglob',
|
||||
'-t',
|
||||
'--type',
|
||||
'--include',
|
||||
'--exclude',
|
||||
'--encoding',
|
||||
'--path',
|
||||
]);
|
||||
const rgValueFlags = new Set(['-r', '--replace']);
|
||||
const patternFlags = new Set(['-e', '--regexp']);
|
||||
const connectors = new Set(['&&', '||', ';', '|', '&']);
|
||||
const wrappers = new Set([
|
||||
'npx',
|
||||
'bunx',
|
||||
'pnpm',
|
||||
'yarn',
|
||||
'npm',
|
||||
'sudo',
|
||||
'env',
|
||||
'command',
|
||||
'time',
|
||||
'nice',
|
||||
'xargs',
|
||||
'dlx',
|
||||
'exec',
|
||||
'run',
|
||||
'git',
|
||||
]);
|
||||
const wrapperFlagsWithValues = new Set([
|
||||
'--package',
|
||||
'-p',
|
||||
'--call',
|
||||
'--prefix',
|
||||
'--shell',
|
||||
'--filter',
|
||||
'--workspace',
|
||||
'--dir',
|
||||
'--cwd',
|
||||
]);
|
||||
const basename = (token) =>
|
||||
token
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/\.(exe|cmd|bat)$/i, '');
|
||||
|
||||
let previousToken;
|
||||
let seenWrapper = false;
|
||||
let searchCommand = null;
|
||||
for (const token of tokens) {
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
if (skipNextAsPattern) {
|
||||
skipNextAsPattern = false;
|
||||
if (token.length >= 3) return token;
|
||||
}
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
if (!foundCmd) {
|
||||
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
|
||||
if (connectors.has(token)) {
|
||||
seenWrapper = false;
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
const commandName = basename(token);
|
||||
if (wrappers.has(commandName)) {
|
||||
seenWrapper = true;
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
if (seenWrapper && token.startsWith('-')) {
|
||||
const flagName = token.split('=', 1)[0];
|
||||
if (!token.includes('=') && wrapperFlagsWithValues.has(flagName)) skipNext = true;
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
if (seenWrapper && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
const atCommandPosition =
|
||||
previousToken === undefined ||
|
||||
connectors.has(previousToken) ||
|
||||
wrappers.has(basename(previousToken)) ||
|
||||
seenWrapper;
|
||||
if (atCommandPosition && (commandName === 'rg' || commandName === 'grep')) {
|
||||
foundCmd = true;
|
||||
searchCommand = commandName;
|
||||
} else if (seenWrapper) {
|
||||
seenWrapper = false;
|
||||
}
|
||||
previousToken = token;
|
||||
continue;
|
||||
}
|
||||
previousToken = token;
|
||||
if (endOfOptions) {
|
||||
if (explicitPatternSeen || patternFileSeen) continue;
|
||||
return token.length >= 3 ? token : null;
|
||||
}
|
||||
if (token === '--') {
|
||||
endOfOptions = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
if (flagsWithValues.has(token)) skipNext = true;
|
||||
if (token === '-f' || token === '--file') {
|
||||
skipNext = true;
|
||||
patternFileSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--file=')) {
|
||||
patternFileSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('--regexp=')) {
|
||||
explicitPatternSeen = true;
|
||||
const value = token.slice('--regexp='.length);
|
||||
if (value.length >= 3) return value;
|
||||
continue;
|
||||
}
|
||||
const attachedPattern = token.match(/^-e(.+)$/);
|
||||
if (attachedPattern) {
|
||||
explicitPatternSeen = true;
|
||||
if (attachedPattern[1].length >= 3) return attachedPattern[1];
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
flagsWithValues.has(token) ||
|
||||
patternFlags.has(token) ||
|
||||
(searchCommand === 'rg' && rgValueFlags.has(token))
|
||||
) {
|
||||
skipNext = true;
|
||||
skipNextAsPattern = patternFlags.has(token);
|
||||
if (skipNextAsPattern) explicitPatternSeen = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const cleaned = token.replace(/['"]/g, '');
|
||||
return cleaned.length >= 3 ? cleaned : null;
|
||||
if (explicitPatternSeen || patternFileSeen) continue;
|
||||
return token.length >= 3 ? token : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -179,12 +380,6 @@ function extractPattern(toolName, toolInput) {
|
|||
if (t === 'shell') {
|
||||
const cmd = toolInput.command || '';
|
||||
if (!/\brg\b|\bgrep\b/.test(cmd)) return null;
|
||||
// NOTE: parseRgGrepPattern uses split(/\s+/) and cannot handle shell
|
||||
// quoting. `rg "User Service" src/` returns "User" (the first token
|
||||
// after the rg/grep arg, with surrounding quotes stripped) — the
|
||||
// multi-word pattern is intentionally not reconstructed since BM25 is
|
||||
// already token-tolerant. Quoted single tokens (`rg "validateUser"`)
|
||||
// work fine.
|
||||
return parseRgGrepPattern(cmd);
|
||||
}
|
||||
|
||||
|
|
@ -282,4 +477,6 @@ function main() {
|
|||
}
|
||||
}
|
||||
|
||||
main();
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { parseRgGrepPattern, tokenizeShellWords };
|
||||
|
|
|
|||
|
|
@ -1,20 +1,40 @@
|
|||
---
|
||||
name: gitnexus-debugging
|
||||
description: Trace bugs through call chains using knowledge graph
|
||||
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
||||
---
|
||||
|
||||
# Debugging with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Why is this function failing?"
|
||||
- "Trace where this error comes from"
|
||||
- "Who calls this method?"
|
||||
- "This endpoint returns 500"
|
||||
- Investigating bugs, errors, or unexpected behavior
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
A root cause traced in the wrong repository is a wrong root cause.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. This matters most for `cypher`, whose
|
||||
statement carries no in-band hint of which database it ran against.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
A stale index describes the code from before your bug, so refresh before
|
||||
trusting a trace, and state the repository and index freshness with the
|
||||
diagnosis.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo
|
||||
1. query({search_query: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → See callers/callees/processes
|
||||
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
||||
|
|
@ -26,6 +46,7 @@ description: Trace bugs through call chains using knowledge graph
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] Understand the symptom (error message, unexpected behavior)
|
||||
- [ ] query for error text or related code
|
||||
- [ ] Identify the suspect function from returned processes
|
||||
|
|
@ -33,45 +54,52 @@ description: Trace bugs through call chains using knowledge graph
|
|||
- [ ] Trace execution flow via process resource if applicable
|
||||
- [ ] cypher for custom call chain traces if needed
|
||||
- [ ] Read source files to confirm root cause
|
||||
- [ ] State the repository and index freshness with the diagnosis
|
||||
```
|
||||
|
||||
## Debugging Patterns
|
||||
|
||||
| Symptom | GitNexus Approach |
|
||||
|---------|-------------------|
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect |
|
||||
| Symptom | GitNexus Approach |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| Error message | `query` for error text → `context` on throw sites |
|
||||
| Wrong return value | `context` on the function → trace callees for data flow |
|
||||
| Intermittent failure | `context` → look for external calls, async deps |
|
||||
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
||||
| Recent regression | `detect_changes` to see what your changes affect — pass `worktree` for a linked worktree |
|
||||
| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call |
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find code related to error:
|
||||
|
||||
```
|
||||
query({search_query: "payment validation error"})
|
||||
query({search_query: "payment validation error", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
||||
```
|
||||
|
||||
**context** — full context for a suspect:
|
||||
|
||||
```
|
||||
context({name: "validatePayment"})
|
||||
context({name: "validatePayment", repo: "my-app"})
|
||||
→ Incoming calls: processCheckout, webhookHandler
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
→ Processes: CheckoutFlow (step 3/7)
|
||||
```
|
||||
|
||||
**cypher** — custom call chain traces:
|
||||
**cypher** — custom call chain traces. Pass `repo` alongside the statement; the
|
||||
Cypher text itself names no repository, so the result is unattributable without
|
||||
it:
|
||||
|
||||
```cypher
|
||||
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
||||
RETURN [n IN nodes(path) | n.name] AS chain
|
||||
```
|
||||
|
||||
**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops:
|
||||
|
||||
```
|
||||
trace({ from: "processCheckout", to: "fetchRates" })
|
||||
trace({ from: "processCheckout", to: "fetchRates", repo: "my-app" })
|
||||
→ status: ok, hopCount: 3
|
||||
→ hops: processCheckout → validatePayment → verifyCard → fetchRates
|
||||
→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0)
|
||||
|
|
@ -82,15 +110,22 @@ When no path exists, `trace` reports the furthest reachable node — exactly whe
|
|||
## Example: "Payment endpoint returns 500 intermittently"
|
||||
|
||||
```
|
||||
1. query({search_query: "payment error handling"})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — bind my-app explicitly on every call
|
||||
|
||||
1. query({search_query: "payment error handling", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, ErrorHandling
|
||||
→ Symbols: validatePayment, handlePaymentError
|
||||
|
||||
2. context({name: "validatePayment"})
|
||||
2. context({name: "validatePayment", repo: "my-app"})
|
||||
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
||||
|
||||
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
||||
→ Step 3: validatePayment → calls fetchRates (external)
|
||||
|
||||
4. Root cause: fetchRates calls external API without proper timeout
|
||||
Repository: my-app Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
---
|
||||
name: gitnexus-exploring
|
||||
description: Navigate unfamiliar code using GitNexus knowledge graph
|
||||
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
||||
---
|
||||
|
||||
# Exploring Codebases with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "How does authentication work?"
|
||||
- "What's the project structure?"
|
||||
- "Show me the main components"
|
||||
- "Where is the database logic?"
|
||||
- Understanding code you haven't seen before
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Step 1 discovers what is indexed; every call after it must say which of those
|
||||
it means. With one indexed repository, use the examples below as written. With
|
||||
more than one, pass `repo` on every call: an omitted `repo` normally errors,
|
||||
but under an MCP policy with a configured default it resolves to that default
|
||||
silently. If you cannot tell which repository is meant, stop and ask. Report
|
||||
the bound repository and index freshness alongside your explanation.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
1. READ gitnexus://repos → Discover indexed repos
|
||||
1. list_repos {} or READ gitnexus://repos → Discover indexed repos
|
||||
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
||||
3. query({search_query: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → Deep dive on specific symbol
|
||||
|
|
@ -27,44 +40,52 @@ description: Navigate unfamiliar code using GitNexus knowledge graph
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] READ gitnexus://repo/{name}/context
|
||||
- [ ] query for the concept you want to understand
|
||||
- [ ] Review returned processes (execution flows)
|
||||
- [ ] context on key symbols for callers/callees
|
||||
- [ ] READ process resource for full execution traces
|
||||
- [ ] Read source files for implementation details
|
||||
- [ ] State the repository and index freshness with the explanation
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | What you get |
|
||||
|----------|-------------|
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
| Resource | What you get |
|
||||
| --------------------------------------- | ------------------------------------------------------- |
|
||||
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
||||
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
||||
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
||||
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
||||
|
||||
## Tools
|
||||
|
||||
**query** — find execution flows related to a concept:
|
||||
|
||||
```
|
||||
query({search_query: "payment processing"})
|
||||
query({search_query: "payment processing", repo: "my-app"})
|
||||
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
||||
→ Symbols grouped by flow with file locations
|
||||
```
|
||||
|
||||
**context** — 360-degree view of a symbol:
|
||||
|
||||
```
|
||||
context({name: "validateUser"})
|
||||
context({name: "validateUser", repo: "my-app"})
|
||||
→ Incoming calls: loginHandler, apiMiddleware
|
||||
→ Outgoing calls: checkToken, getUserById
|
||||
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
||||
```
|
||||
|
||||
`repo` is required once more than one repository is indexed, and may be omitted
|
||||
with a single one.
|
||||
|
||||
## Example: "How does payment processing work?"
|
||||
|
||||
```
|
||||
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
1. list_repos {} → total: 1 (my-app) — bind it
|
||||
READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
||||
2. query({search_query: "payment processing"})
|
||||
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
||||
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
||||
|
|
@ -72,4 +93,8 @@ context({name: "validateUser"})
|
|||
→ Incoming: checkoutHandler, webhookHandler
|
||||
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
||||
4. Read src/payments/processor.ts for implementation details
|
||||
5. Answer, noting: Repository my-app, index current
|
||||
```
|
||||
|
||||
Had step 1 returned two repositories, every call above would carry
|
||||
`repo: "my-app"`.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
---
|
||||
name: gitnexus-impact-analysis
|
||||
description: Analyze blast radius before making code changes
|
||||
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
||||
---
|
||||
|
||||
# Impact Analysis with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Is it safe to change this function?"
|
||||
- "What will break if I modify X?"
|
||||
- "Show me the blast radius"
|
||||
|
|
@ -13,13 +14,42 @@ description: Analyze blast radius before making code changes
|
|||
- Before making non-trivial code changes
|
||||
- Before committing — to understand what your changes affect
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Impact analysis is the gate that authorizes an edit, so it must answer for the
|
||||
repository you are about to edit.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask — every result below an ambiguous
|
||||
identity inherits the ambiguity. `list_repos` is paginated, so page with
|
||||
`offset: pagination.nextOffset` until `hasMore` is false before concluding a
|
||||
repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when your changes are in a linked worktree
|
||||
the MCP server was not launched from. The server auto-detects a worktree only
|
||||
when it was launched from inside one; otherwise `git diff` runs in the wrong
|
||||
checkout and reports zero changed symbols — a false clean check that carries
|
||||
none of the degradation flags described below. In the CLI fallbacks, `--repo .`
|
||||
means the current checkout; pass the intended repository path instead when you
|
||||
are not standing in it.
|
||||
|
||||
State the bound identity with your risk report:
|
||||
|
||||
```
|
||||
Repository: <name> (<path>) Worktree: <path> Index: <commit>, <n> behind HEAD
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) or `node .gitnexus/run.cjs impact "X" --direction upstream --repo .`
|
||||
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
||||
3. detect_changes({scope: "all"}) or `node .gitnexus/run.cjs detect-changes --scope all --repo .`
|
||||
4. Assess risk and report to user
|
||||
4. Assess risk and report to user, echoing repo/worktree/index identity
|
||||
```
|
||||
|
||||
> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal.
|
||||
|
|
@ -28,29 +58,31 @@ description: Analyze blast radius before making code changes
|
|||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] impact({target, direction: "upstream"}) or CLI fallback to find dependents
|
||||
- [ ] Review d=1 items first (these WILL BREAK)
|
||||
- [ ] Check high-confidence (>0.8) dependencies
|
||||
- [ ] READ processes to check affected execution flows
|
||||
- [ ] detect_changes({scope: "all"}) or CLI fallback for pre-commit check
|
||||
- [ ] Assess risk level and report to user
|
||||
- [ ] Confirm the checkout you edited is the checkout that was diffed
|
||||
- [ ] Assess risk level and report, stating repo/worktree/index identity
|
||||
```
|
||||
|
||||
## Understanding Output
|
||||
|
||||
| Depth | Risk Level | Meaning |
|
||||
|-------|-----------|---------|
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
| Depth | Risk Level | Meaning |
|
||||
| ----- | ---------------- | ------------------------ |
|
||||
| d=1 | **WILL BREAK** | Direct callers/importers |
|
||||
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
||||
| d=3 | MAY NEED TESTING | Transitive effects |
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Affected | Risk |
|
||||
|----------|------|
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Affected | Risk |
|
||||
| ------------------------------ | -------- |
|
||||
| <5 symbols, few processes | LOW |
|
||||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
|
|
@ -61,12 +93,23 @@ dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
|||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
`risk` is the edit gate: warn on HIGH/CRITICAL and stop on UNKNOWN until the
|
||||
uncertainty is resolved. Within single-repo mode, compare File and symbol
|
||||
targets with local `riskSharedAxes` (direct/total only). Within group mode,
|
||||
compare only group results: their `riskSharedAxes` overlays resolved
|
||||
cross-repo crossings on that local value. Never use either field to waive the
|
||||
edit gate. Check `riskScale.unusedAxes` before comparing kinds: MCP File walks
|
||||
omit process/module axes, while web Graph-RAG expands File targets to in-file
|
||||
symbols before enrichment.
|
||||
|
||||
## Tools
|
||||
|
||||
**impact** — the primary tool for symbol blast radius. If MCP is unavailable, use `node .gitnexus/run.cjs impact <symbol> --direction upstream --repo .` instead:
|
||||
|
||||
```
|
||||
impact({
|
||||
target: "validateUser",
|
||||
repo: "my-app", // required once >1 repository is indexed
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
|
|
@ -81,6 +124,7 @@ impact({
|
|||
```
|
||||
|
||||
**detect_changes** — git-diff based impact analysis. If MCP is unavailable, use `node .gitnexus/run.cjs detect-changes --scope all --repo .` instead:
|
||||
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
|
||||
|
|
@ -89,10 +133,26 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
Add `repo` once more than one repository is indexed, and `worktree: "<abs
|
||||
path>"` when your changes are in a linked worktree the server was not launched
|
||||
from.
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth, and reads like
|
||||
`UNKNOWN` above: a zero there means unseen, not unaffected. Re-run it rather
|
||||
than tick the pre-commit check.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is shape-identical to a genuine
|
||||
clean result, so confirm the checkout you edited is the one that was diffed
|
||||
before treating an empty change set as a passed check.
|
||||
|
||||
## Example: "What breaks if I change validateUser?"
|
||||
|
||||
```
|
||||
1. impact({target: "validateUser", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. impact({target: "validateUser", repo: "my-app", direction: "upstream"}) or `node .gitnexus/run.cjs impact "validateUser" --direction upstream --repo .`
|
||||
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
||||
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
||||
|
||||
|
|
@ -100,4 +160,8 @@ detect_changes({scope: "all"})
|
|||
→ LoginFlow and TokenRefresh touch validateUser
|
||||
|
||||
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
|
|
@ -1,20 +1,44 @@
|
|||
---
|
||||
name: gitnexus-refactoring
|
||||
description: Plan safe refactors using blast radius and dependency mapping
|
||||
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
||||
---
|
||||
|
||||
# Refactoring with GitNexus
|
||||
|
||||
## When to Use
|
||||
|
||||
- "Rename this function safely"
|
||||
- "Extract this into a module"
|
||||
- "Split this service"
|
||||
- "Move this to a new file"
|
||||
- Any task involving renaming, extracting, splitting, or restructuring code
|
||||
|
||||
## Bind the repository first
|
||||
|
||||
Refactoring writes to disk. `rename` with `dry_run: false` edits files in
|
||||
whichever repository was resolved, so binding identity here is a safety gate,
|
||||
not bookkeeping.
|
||||
|
||||
Call `list_repos {}` before the first tool call. With one indexed repository,
|
||||
use the examples below as written. With more than one, pass `repo` on every
|
||||
call: an omitted `repo` normally errors, but under an MCP policy with a
|
||||
configured default it resolves to that default silently. If you cannot tell
|
||||
which repository is meant, stop and ask. Never run `rename` with
|
||||
`dry_run: false` until the preview in the same bound repository has been
|
||||
reviewed — its returned `file_path` values show which checkout is about to be
|
||||
written, so read them as a confirmation of identity.
|
||||
|
||||
`list_repos` is paginated, so page with `offset: pagination.nextOffset` until
|
||||
`hasMore` is false before concluding a repository is absent.
|
||||
|
||||
`detect_changes` takes `worktree` when you are editing a linked worktree the
|
||||
MCP server was not launched from; otherwise `git diff` runs in the wrong
|
||||
checkout and reports nothing changed, which reads as a verified refactor.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
0. list_repos {} → Bind repo (and worktree)
|
||||
1. impact({target: "X", direction: "upstream"}) → Map all dependents
|
||||
2. query({search_query: "X"}) → Find execution flows involving X
|
||||
3. context({name: "X"}) → See all incoming/outgoing refs
|
||||
|
|
@ -26,8 +50,11 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Checklists
|
||||
|
||||
### Rename Symbol
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
||||
- [ ] Confirm the previewed file paths are in the bound repository/worktree
|
||||
- [ ] Review graph edits (high confidence) and text_search edits (review carefully)
|
||||
- [ ] If satisfied: rename({..., dry_run: false}) — apply edits
|
||||
- [ ] detect_changes() — verify only expected files changed
|
||||
|
|
@ -35,7 +62,9 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Extract Module
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — see all incoming/outgoing refs
|
||||
- [ ] impact({target, direction: "upstream"}) — find all external callers
|
||||
- [ ] Define new module interface
|
||||
|
|
@ -45,7 +74,9 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
```
|
||||
|
||||
### Split Function/Service
|
||||
|
||||
```
|
||||
- [ ] list_repos {} — bind repo; explicit repo when >1 indexed, ask if ambiguous
|
||||
- [ ] context({name: target}) — understand all callees
|
||||
- [ ] Group callees by responsibility
|
||||
- [ ] impact({target, direction: "upstream"}) — map callers to update
|
||||
|
|
@ -58,21 +89,24 @@ description: Plan safe refactors using blast radius and dependency mapping
|
|||
## Tools
|
||||
|
||||
**rename** — automated multi-file rename:
|
||||
|
||||
```
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits across 8 files
|
||||
→ 10 graph edits (high confidence), 2 text_search edits (review)
|
||||
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
||||
```
|
||||
|
||||
**impact** — map all dependents first:
|
||||
|
||||
```
|
||||
impact({target: "validateUser", direction: "upstream"})
|
||||
impact({target: "validateUser", repo: "my-app", direction: "upstream"})
|
||||
→ d=1: loginHandler, apiMiddleware, testUtils
|
||||
→ Affected Processes: LoginFlow, TokenRefresh
|
||||
```
|
||||
|
||||
**detect_changes** — verify your changes after refactoring:
|
||||
|
||||
```
|
||||
detect_changes({scope: "all"})
|
||||
→ Changed: 8 files, 12 symbols
|
||||
|
|
@ -80,7 +114,16 @@ detect_changes({scope: "all"})
|
|||
→ Risk: MEDIUM
|
||||
```
|
||||
|
||||
`partial: true` (a graph query failed) or `truncated: true` (the changed-symbol
|
||||
listing was capped) means the result is short of the truth: a short or empty
|
||||
list is not proof that only the expected files changed. Re-run it rather than
|
||||
treat the refactor as verified.
|
||||
|
||||
A wrong-worktree zero carries neither flag and is indistinguishable from a
|
||||
clean verification, so confirm the diffed checkout is the one you edited.
|
||||
|
||||
**cypher** — custom reference queries:
|
||||
|
||||
```cypher
|
||||
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
||||
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
||||
|
|
@ -88,26 +131,34 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
|||
|
||||
## Risk Rules
|
||||
|
||||
| Risk Factor | Mitigation |
|
||||
|-------------|------------|
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Risk Factor | Mitigation |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| Many callers (>5) | Use rename for automated updates |
|
||||
| Cross-area refs | Use detect_changes after to verify scope |
|
||||
| String/dynamic refs | query to find them |
|
||||
| External/public API | Version and deprecate properly |
|
||||
| Same name in another indexed repo | Bind `repo`; verify previewed paths before applying |
|
||||
|
||||
## Example: Rename `validateUser` to `authenticateUser`
|
||||
|
||||
```
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
||||
0. list_repos {}
|
||||
→ total: 2 (my-app, billing-api) — both define validateUser, so bind explicitly
|
||||
|
||||
1. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: true})
|
||||
→ 12 edits: 10 graph (safe), 2 text_search (review)
|
||||
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
||||
|
||||
2. Review text_search edits (config.json: dynamic reference!)
|
||||
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
||||
3. rename({symbol_name: "validateUser", new_name: "authenticateUser", repo: "my-app", dry_run: false})
|
||||
→ Applied 12 edits across 8 files
|
||||
|
||||
4. detect_changes({scope: "all"})
|
||||
4. detect_changes({scope: "all", repo: "my-app"})
|
||||
→ Affected: LoginFlow, TokenRefresh
|
||||
→ Risk: MEDIUM — run tests for these flows
|
||||
Repository: my-app (/abs/path/my-app) Worktree: same Index: current
|
||||
```
|
||||
|
||||
With a single indexed repository, step 0 returns `total: 1` and the `repo`
|
||||
argument drops out of every call above.
|
||||
|
|
|
|||
375
gitnexus-shared/package-lock.json
generated
375
gitnexus-shared/package-lock.json
generated
|
|
@ -8,21 +8,382 @@
|
|||
"name": "gitnexus-shared",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,6 @@
|
|||
"src"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,21 @@ export type NodeLabel =
|
|||
| 'Section'
|
||||
| 'Route'
|
||||
| 'Tool'
|
||||
/**
|
||||
* A message-broker destination — a Kafka topic, a Rabbit exchange/routing
|
||||
* key, a JMS queue, a Spring Cloud Stream binding. The framework overlay for
|
||||
* ASYNCHRONOUS entry/exit points, symmetric to `Route` for HTTP.
|
||||
*
|
||||
* Identity is `(broker, resolved ADDRESS)`, so a publisher and a consumer of
|
||||
* the same address on the same broker land on one node and the connection is
|
||||
* a single hop — while a Kafka topic and a Rabbit queue that share a name
|
||||
* stay two nodes, the same way `GET /x` and `POST /x` are two Routes. A
|
||||
* destination whose address could NOT be resolved is keyed by its source
|
||||
* location instead and carries no `address` property at all. See
|
||||
* `pipeline-phases/spring-destinations.ts` for why an unresolved spelling may
|
||||
* not key a node, and `ingestion/destination-key.ts` for why the broker may.
|
||||
*/
|
||||
| 'Destination'
|
||||
// Taint/PDG substrate (issue #2080). Intra-procedural control-flow node.
|
||||
// Emitted by no phase yet — M1 (#2081) populates these behind an opt-in.
|
||||
| 'BasicBlock';
|
||||
|
|
@ -95,6 +110,30 @@ export type NodeProperties = {
|
|||
responseKeys?: string[];
|
||||
errorKeys?: string[];
|
||||
middleware?: string[];
|
||||
/** Route runtime evidence is authoritative only when this is exactly true. */
|
||||
runtimeConfirmed?: boolean;
|
||||
/** Provenance of runtime evidence; presence alone does not imply confirmation. */
|
||||
runtimeSource?: string;
|
||||
/** Runtime result such as runtime-confirmed or handler-conflict. */
|
||||
runtimeStatus?: string;
|
||||
// Destination (async messaging overlay). See the `Destination` label above.
|
||||
/** The RESOLVED broker address. Together with `broker` it is the key a
|
||||
* cross-repository pass joins on. Present only when the address resolved:
|
||||
* absent is the load-bearing state, because an absent property cannot match
|
||||
* another absent property. */
|
||||
address?: string;
|
||||
/** Broker family the syntax attests to (`kafka`, `rabbit`, `jms`, …). Part
|
||||
* of the node's identity alongside `address`, not a label on it. */
|
||||
broker?: string;
|
||||
/** How the address was arrived at (`literal`, `constant`) when it resolved,
|
||||
* or the named reason it did not. */
|
||||
resolution?: string;
|
||||
/** Configuration key named by an unresolvable `${…}` placeholder. The key
|
||||
* only — configuration VALUES are deliberately absent from this graph. */
|
||||
configKey?: string;
|
||||
/** The `${key:default}` default text. Not an address: configuration can
|
||||
* override it and the graph cannot see whether it did. */
|
||||
configDefault?: string;
|
||||
// BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine.
|
||||
text?: string;
|
||||
/** BasicBlock: space-joined leaf callee names invoked in the block — the
|
||||
|
|
@ -122,6 +161,19 @@ export type RelationshipType =
|
|||
| 'MEMBER_OF'
|
||||
| 'STEP_IN_PROCESS'
|
||||
| 'HANDLES_ROUTE'
|
||||
/** Outbound async messaging. Source = the callable that performs the publish
|
||||
* (or its File); target = the `Destination` it publishes to. Emitted by
|
||||
* `pipeline-phases/spring-destinations.ts` from Spring messaging-template
|
||||
* calls (`kafkaTemplate.send(...)`, `rabbitTemplate.convertAndSend(...)`).
|
||||
* One edge per address: a publish that names two destinations yields two
|
||||
* edges, and `reason` records which argument each came from. */
|
||||
| 'PUBLISHES_TO'
|
||||
/** Inbound async messaging — the mirror of `PUBLISHES_TO`. Source = the
|
||||
* annotated handler callable (or its File); target = the `Destination` it
|
||||
* subscribes to. Emitted from `@KafkaListener` / `@RabbitListener` /
|
||||
* `@JmsListener` and their siblings. Together the two types make
|
||||
* "who else reads what this service writes" a two-hop traversal. */
|
||||
| 'CONSUMES_FROM'
|
||||
| 'FETCHES'
|
||||
| 'HANDLES_TOOL'
|
||||
| 'ENTRY_POINT_OF'
|
||||
|
|
|
|||
151
gitnexus-shared/src/impact-risk.ts
Normal file
151
gitnexus-shared/src/impact-risk.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
export type ImpactRisk = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' | 'UNKNOWN';
|
||||
|
||||
export type ImpactRiskAxis = 'processes' | 'modules';
|
||||
|
||||
export type UnusedImpactRiskReason =
|
||||
| 'file-nodes-have-no-process-or-community-membership'
|
||||
| 'enrichment-skipped'
|
||||
| 'enrichment-budget-exhausted'
|
||||
| 'enrichment-truncated'
|
||||
| 'enrichment-query-failed';
|
||||
|
||||
export interface UnusedImpactRiskAxis {
|
||||
axis: ImpactRiskAxis;
|
||||
reason: UnusedImpactRiskReason;
|
||||
}
|
||||
|
||||
export interface ImpactRiskInput {
|
||||
direction: 'upstream' | 'downstream';
|
||||
directCount: number;
|
||||
processCount: number;
|
||||
moduleCount: number;
|
||||
impactedCount: number;
|
||||
unusedAxes?: readonly UnusedImpactRiskAxis[];
|
||||
}
|
||||
|
||||
export interface ImpactRiskResult {
|
||||
risk: ImpactRisk;
|
||||
riskSharedAxes: ImpactRisk;
|
||||
riskScale: {
|
||||
comparableAcrossKinds: boolean;
|
||||
unusedAxes: readonly UnusedImpactRiskAxis[];
|
||||
};
|
||||
}
|
||||
|
||||
function score(
|
||||
input: Pick<
|
||||
ImpactRiskInput,
|
||||
'direction' | 'directCount' | 'processCount' | 'moduleCount' | 'impactedCount'
|
||||
>,
|
||||
): ImpactRisk {
|
||||
const { direction, directCount, processCount, moduleCount, impactedCount } = input;
|
||||
|
||||
if (direction === 'upstream' && impactedCount === 0) return 'UNKNOWN';
|
||||
if (directCount >= 30 || processCount >= 5 || moduleCount >= 5 || impactedCount >= 200) {
|
||||
return 'CRITICAL';
|
||||
}
|
||||
if (directCount >= 15 || processCount >= 3 || moduleCount >= 3 || impactedCount >= 100) {
|
||||
return 'HIGH';
|
||||
}
|
||||
if (directCount >= 5 || impactedCount >= 30) return 'MEDIUM';
|
||||
return 'LOW';
|
||||
}
|
||||
|
||||
const UNMEASURED_REASONS: ReadonlySet<UnusedImpactRiskReason> = new Set([
|
||||
'file-nodes-have-no-process-or-community-membership',
|
||||
'enrichment-skipped',
|
||||
'enrichment-budget-exhausted',
|
||||
]);
|
||||
|
||||
function unusedPair(reason: UnusedImpactRiskReason): UnusedImpactRiskAxis[] {
|
||||
return [
|
||||
{ axis: 'processes', reason },
|
||||
{ axis: 'modules', reason },
|
||||
];
|
||||
}
|
||||
|
||||
function countsWithUnmeasuredAxesZeroed(
|
||||
input: ImpactRiskInput,
|
||||
): Pick<
|
||||
ImpactRiskInput,
|
||||
'direction' | 'directCount' | 'processCount' | 'moduleCount' | 'impactedCount'
|
||||
> {
|
||||
let processCount = input.processCount;
|
||||
let moduleCount = input.moduleCount;
|
||||
for (const unused of input.unusedAxes ?? []) {
|
||||
if (!UNMEASURED_REASONS.has(unused.reason)) continue;
|
||||
if (unused.axis === 'processes') processCount = 0;
|
||||
if (unused.axis === 'modules') moduleCount = 0;
|
||||
}
|
||||
return {
|
||||
direction: input.direction,
|
||||
directCount: input.directCount,
|
||||
processCount,
|
||||
moduleCount,
|
||||
impactedCount: input.impactedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map walk outcomes to unused process/module axes so comparability matches what was sampled. */
|
||||
export function unusedAxesForImpactWalk(input: {
|
||||
isFileTarget: boolean;
|
||||
skipEnrichment: boolean;
|
||||
maxChunks: number;
|
||||
processQueryFailed: boolean;
|
||||
moduleQueryFailed: boolean;
|
||||
/** When 0, a zero chunk budget is not an unused-axis event — there was nothing to enrich. */
|
||||
impactedCount: number;
|
||||
/** True when process/module queries ran on a strict subset of impacted symbols. */
|
||||
enrichmentTruncated?: boolean;
|
||||
}): UnusedImpactRiskAxis[] {
|
||||
if (input.isFileTarget) {
|
||||
return unusedPair('file-nodes-have-no-process-or-community-membership');
|
||||
}
|
||||
if (input.skipEnrichment) {
|
||||
return unusedPair('enrichment-skipped');
|
||||
}
|
||||
if (input.maxChunks === 0 && input.impactedCount > 0) {
|
||||
return unusedPair('enrichment-budget-exhausted');
|
||||
}
|
||||
const unused: UnusedImpactRiskAxis[] = [];
|
||||
if (input.enrichmentTruncated) {
|
||||
unused.push(...unusedPair('enrichment-truncated'));
|
||||
}
|
||||
if (input.processQueryFailed) {
|
||||
unused.push({ axis: 'processes', reason: 'enrichment-query-failed' });
|
||||
}
|
||||
if (input.moduleQueryFailed) {
|
||||
unused.push({ axis: 'modules', reason: 'enrichment-query-failed' });
|
||||
}
|
||||
return unused;
|
||||
}
|
||||
|
||||
const INCOMPLETE_SAMPLE_REASONS: ReadonlySet<UnusedImpactRiskReason> = new Set([
|
||||
'enrichment-query-failed',
|
||||
'enrichment-truncated',
|
||||
]);
|
||||
|
||||
export function scoreImpactRisk(input: ImpactRiskInput): ImpactRiskResult {
|
||||
const unusedAxes = input.unusedAxes ?? [];
|
||||
const observedRisk = score(countsWithUnmeasuredAxesZeroed(input));
|
||||
const incompleteSample = unusedAxes.some((unused) =>
|
||||
INCOMPLETE_SAMPLE_REASONS.has(unused.reason),
|
||||
);
|
||||
// Failed queries and truncated samples make observed process/module counts
|
||||
// lower bounds. Preserve any HIGH/CRITICAL warning already proved by those
|
||||
// counts, but never emit a confident LOW/MEDIUM edit gate from an incomplete
|
||||
// enrichment pass.
|
||||
const risk =
|
||||
incompleteSample && (observedRisk === 'LOW' || observedRisk === 'MEDIUM')
|
||||
? 'UNKNOWN'
|
||||
: observedRisk;
|
||||
|
||||
return {
|
||||
risk,
|
||||
riskSharedAxes: score({ ...input, processCount: 0, moduleCount: 0 }),
|
||||
riskScale: {
|
||||
comparableAcrossKinds: unusedAxes.length === 0,
|
||||
unusedAxes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -25,6 +25,17 @@ export {
|
|||
} from './language-detection.js';
|
||||
export type { MroStrategy } from './mro-strategy.js';
|
||||
|
||||
// Impact risk scoring
|
||||
export { scoreImpactRisk, unusedAxesForImpactWalk } from './impact-risk.js';
|
||||
export type {
|
||||
ImpactRisk,
|
||||
ImpactRiskAxis,
|
||||
ImpactRiskInput,
|
||||
ImpactRiskResult,
|
||||
UnusedImpactRiskAxis,
|
||||
UnusedImpactRiskReason,
|
||||
} from './impact-risk.js';
|
||||
|
||||
// Pipeline progress
|
||||
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export const NODE_TABLES = [
|
|||
'Module',
|
||||
'Route',
|
||||
'Tool',
|
||||
// Async messaging overlay — the broker-side counterpart of `Route`.
|
||||
'Destination',
|
||||
// Taint/PDG substrate (issue #2080) — inert until M1 (#2081) emits blocks.
|
||||
'BasicBlock',
|
||||
] as const;
|
||||
|
|
@ -64,6 +66,8 @@ export const REL_TYPES = [
|
|||
'MEMBER_OF',
|
||||
'STEP_IN_PROCESS',
|
||||
'HANDLES_ROUTE',
|
||||
'PUBLISHES_TO',
|
||||
'CONSUMES_FROM',
|
||||
'FETCHES',
|
||||
'HANDLES_TOOL',
|
||||
'ENTRY_POINT_OF',
|
||||
|
|
|
|||
|
|
@ -373,6 +373,8 @@ function makeEdgeDrafts(
|
|||
targetFile: null,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
...typeOnlyFor(parsed),
|
||||
...runsOnlyWhenCalledFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return [
|
||||
|
|
@ -392,7 +394,13 @@ function makeEdgeDrafts(
|
|||
// and resolved-dynamic imports are terminal at the file level — no
|
||||
// `targetDefId` needed since they materialize no `BindingRef`. Pre-
|
||||
// finalize them here so the fixpoint loop skips them entirely.
|
||||
const targetFiles = Array.isArray(targetFile) ? targetFile : [targetFile];
|
||||
// Annotated rather than inferred: `isArray`'s `arg is any[]` predicate widens
|
||||
// the true branch to a MUTABLE array, and a resolver may hand back a cached,
|
||||
// frozen candidate list (Kotlin's `dirChildren` buckets do). Only `.map` is
|
||||
// wanted here, so pinning `readonly` makes an in-place `.sort()`/`.push()` —
|
||||
// which would reorder that resolver's index for the rest of the run — a
|
||||
// compile error rather than a runtime TypeError.
|
||||
const targetFiles: readonly string[] = Array.isArray(targetFile) ? targetFile : [targetFile];
|
||||
const isFileLevelTerminal = parsed.kind === 'side-effect' || parsed.kind === 'dynamic-resolved';
|
||||
return targetFiles.map((tf) => {
|
||||
const base: ImportEdge = {
|
||||
|
|
@ -403,6 +411,8 @@ function makeEdgeDrafts(
|
|||
hooks.isNamespaceImport?.(parsed, tf, file.filePath) === true
|
||||
? 'namespace'
|
||||
: edgeKindFor(parsed),
|
||||
...typeOnlyFor(parsed),
|
||||
...runsOnlyWhenCalledFor(parsed),
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
|
|
@ -420,6 +430,73 @@ function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
|||
return parsed.kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry `ParsedImport.typeOnly` onto the edge — the erasure fact `check
|
||||
* --cycles` needs and cannot re-derive, because `kind` is identical for the
|
||||
* erased and the runtime spelling of the same import (`import type D` and
|
||||
* `import D` both arrive as `alias`).
|
||||
*
|
||||
* `'typeOnly' in parsed` rather than a switch over the erasable kinds: only
|
||||
* four variants declare the property, so `parsed.typeOnly` does not compile
|
||||
* against the whole union, and `in` narrows it without naming them. That is
|
||||
* also the safer shape — an enumeration has to be updated when a variant gains
|
||||
* the property or the fact silently stops reaching the edge, while this form
|
||||
* handles a new variant correctly whether or not it declares one.
|
||||
*
|
||||
* Returns a spreadable object rather than a `boolean` so an edge that is not
|
||||
* type-only keeps the exact property set it had before this field existed.
|
||||
* Every `finalized` edge is built by spreading `base`, so setting it here is
|
||||
* enough for all of them.
|
||||
*/
|
||||
function typeOnlyFor(parsed: ParsedImport): { typeOnly?: true } {
|
||||
return 'typeOnly' in parsed && parsed.typeOnly === true ? { typeOnly: true } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-carry both runtime-presence flags from an existing edge onto a derived
|
||||
* one.
|
||||
*
|
||||
* `expandWildcard` builds each `wildcard-expanded` edge from scratch rather
|
||||
* than spreading the source (three fields differ per exported name), so every
|
||||
* field it does not name is dropped. That is exactly how both flags were lost
|
||||
* once already. Naming the pair here keeps "these two travel together" in one
|
||||
* place, so a third presence flag is added in one place too.
|
||||
*/
|
||||
function carriedPresenceFlags(edge: Pick<ImportEdge, 'typeOnly' | 'runsOnlyWhenCalled'>): {
|
||||
typeOnly?: true;
|
||||
runsOnlyWhenCalled?: true;
|
||||
} {
|
||||
return {
|
||||
...(edge.typeOnly === true ? { typeOnly: true } : {}),
|
||||
...(edge.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry `ParsedImport.runsOnlyWhenCalled` onto the edge — the position fact
|
||||
* `check --cycles` needs and, unlike every other property of an import, cannot
|
||||
* look up for itself.
|
||||
*
|
||||
* The scope an import was written in does not survive to here:
|
||||
* `FinalizeFile.parsedImports` is a flat per-file list, and Phase 4 publishes
|
||||
* the finalized edges under `file.moduleScope` (see `linkedByScope.set` above),
|
||||
* so the consumer's map is keyed by the Module scope for every file. Walking
|
||||
* that map's key to look for an enclosing `Function` therefore always starts —
|
||||
* and ends — at a `Module`. Only the extractor still knows, so the edge has to
|
||||
* carry what it decided.
|
||||
*
|
||||
* No `in` guard, unlike {@link typeOnlyFor}: position is a property of where
|
||||
* the statement sits, so every variant declares `runsOnlyWhenCalled` and
|
||||
* `parsed.runsOnlyWhenCalled` compiles against the whole union. A new variant
|
||||
* that omits it is a build break here, which is the right outcome.
|
||||
*
|
||||
* Returns a spreadable object rather than a `boolean` so an edge that is not
|
||||
* deferred keeps the exact property set it had before this field existed.
|
||||
*/
|
||||
function runsOnlyWhenCalledFor(parsed: ParsedImport): { runsOnlyWhenCalled?: true } {
|
||||
return parsed.runsOnlyWhenCalled === true ? { runsOnlyWhenCalled: true } : {};
|
||||
}
|
||||
|
||||
function extractLocalName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'wildcard':
|
||||
|
|
@ -515,9 +592,11 @@ function tryFinalize(
|
|||
return null;
|
||||
}
|
||||
|
||||
const viaFiles = [targetFile, ...followed.via];
|
||||
// Capped here too, not just inside the closure: this is the last hop, the
|
||||
// one the emitted edge carries.
|
||||
const viaFiles = extendVia(targetFile, followed.via);
|
||||
const transitiveVia =
|
||||
draft.source.kind === 'reexport' || viaFiles.length > 1 ? Object.freeze(viaFiles) : undefined;
|
||||
draft.source.kind === 'reexport' || viaFiles.length > 1 ? viaFiles : undefined;
|
||||
|
||||
return {
|
||||
...draft.base,
|
||||
|
|
@ -549,11 +628,19 @@ type FileReexportClosure = ReadonlyMap<string, ReexportClosureEntry>;
|
|||
* level import graph. Replaces the legacy recursive
|
||||
* `followReexportChain` crawl with a bounded, stack-safe pass:
|
||||
*
|
||||
* 1. **Sub-graph.** Build a directed graph whose edges are
|
||||
* `reexport` and `wildcard` drafts only (regular imports do not
|
||||
* contribute to the export surface, and `namespace`/
|
||||
* `reexport-namespace` are terminal — their target def lives in
|
||||
* `localDefs`).
|
||||
* 1. **Sub-graph.** Build a directed graph whose edges are `wildcard`
|
||||
* drafts, `reexport` drafts, and `named`/`alias` drafts flagged
|
||||
* `reexportsName` by their provider. `namespace`/`reexport-namespace`
|
||||
* are terminal — their target def lives in `localDefs` — and are
|
||||
* excluded on `base.kind`, after any `isNamespaceImport`
|
||||
* reclassification.
|
||||
*
|
||||
* The flagged-named case is what languages with no dedicated
|
||||
* re-export form need (today: Python, whose module-level
|
||||
* `from m import x` both binds and republishes). For those providers
|
||||
* the sub-graph is close to the file-level named-import graph, NOT a
|
||||
* sparse barrel graph — measured ~20× more edges on the CPython
|
||||
* stdlib — so read every bound below with that input class in mind.
|
||||
* 2. **SCC condensation.** Run the same iterative `tarjanSccs` over
|
||||
* the sub-graph. Output is in reverse-topological order (leaves
|
||||
* first), so when we process an SCC every out-of-SCC neighbor
|
||||
|
|
@ -567,21 +654,34 @@ type FileReexportClosure = ReadonlyMap<string, ReexportClosureEntry>;
|
|||
* the cycle; first-wins precedence keeps the map monotone
|
||||
* so the fixpoint converges in at most |SCC| hops).
|
||||
*
|
||||
* **Precedence semantics — preserved from the recursive crawl.**
|
||||
* **Precedence semantics.**
|
||||
* * Named re-exports take precedence over wildcards.
|
||||
* * Within each kind, declaration order wins (first match for a
|
||||
* given exported name is kept; later drafts skip).
|
||||
* given exported name is kept; later drafts skip). This is only sound
|
||||
* where the language makes a duplicate export illegal — true for TS
|
||||
* and Rust `kind: 'reexport'`, false for the flagged-named form, where
|
||||
* the module namespace rebinds (last write wins) and `if`/`try` pairs
|
||||
* execute exactly one branch. For those, an in-file collision on the
|
||||
* same published name with two different in-workspace targets is
|
||||
* genuinely ambiguous and is dropped instead of guessed — see
|
||||
* `collectAmbiguousReexports`.
|
||||
*
|
||||
* **Complexity.**
|
||||
* * Pre-pass: O(V + E_re) for SCC, plus O(|SCC| × Σ drafts) per cyclic
|
||||
* SCC. For tree-shaped barrel graphs (the common case) it
|
||||
* collapses to O(E_re) total.
|
||||
* * Per-edge lookup at finalize time: O(1).
|
||||
* SCC. Tree-shaped barrel graphs collapse to O(E_re) total; the
|
||||
* flagged-named input class does not — the CPython stdlib produces 10
|
||||
* cyclic SCCs here where TypeScript-shaped input produced none.
|
||||
* * Per-edge lookup at finalize time: O(1). Target `localDefs` are
|
||||
* indexed by simple name on first use (`findExportByName`), so the
|
||||
* per-hop cost is O(1) rather than a linear scan of the target file.
|
||||
* * `transitiveVia` preserves the exact file path chain for diagnostics
|
||||
* and graph provenance. Building those arrays copies the inherited path,
|
||||
* which is O(depth²) in a pathological single-name barrel chain; practical
|
||||
* TypeScript barrel chains are shallow enough that we keep exact paths
|
||||
* instead of capping or summarizing them.
|
||||
* which is Θ(depth²) in a single-name chain, and Θ(|SCC|²) for a cyclic
|
||||
* SCC whose chain tracks the cycle. `MAX_REEXPORT_DEPTH = 100` bounded
|
||||
* this until it was removed in `fc919ad6` for shallow TypeScript
|
||||
* barrels; **nothing bounds it now**, and the flagged-named class feeds
|
||||
* it far deeper input. Real `__init__.py` chains measure ≤ ~6, so this
|
||||
* is a known unenforced assumption, not a live regression.
|
||||
* * Pathological deep chains that previously needed
|
||||
* `MAX_REEXPORT_DEPTH=100` to bound stack growth now resolve
|
||||
* in full and are bounded only by available memory — the
|
||||
|
|
@ -595,19 +695,22 @@ function buildReexportClosures(
|
|||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable
|
||||
// reexport/wildcard targets contribute edges).
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable wildcard /
|
||||
// reexport / flagged-named targets contribute edges), and collect the
|
||||
// per-file ambiguous names in the same walk.
|
||||
const subGraph = new Map<string, Set<string>>();
|
||||
const ambiguous = new Map<string, ReadonlySet<string>>();
|
||||
for (const file of files) {
|
||||
const targets = new Set<string>();
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts !== undefined) {
|
||||
for (const d of drafts) {
|
||||
if (d.source.kind !== 'reexport' && d.source.kind !== 'wildcard') continue;
|
||||
if (!contributesReexportEdge(d)) continue;
|
||||
if (d.targetFile === null) continue;
|
||||
if (!byFilePath.has(d.targetFile)) continue;
|
||||
targets.add(d.targetFile);
|
||||
}
|
||||
ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath));
|
||||
}
|
||||
subGraph.set(file.filePath, targets);
|
||||
}
|
||||
|
|
@ -623,7 +726,7 @@ function buildReexportClosures(
|
|||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures);
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -637,7 +740,7 @@ function buildReexportClosures(
|
|||
progressed = false;
|
||||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures)) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) {
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -647,6 +750,95 @@ function buildReexportClosures(
|
|||
return closures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this import republish names from its target under the *importing* file,
|
||||
* making it an edge in the re-export sub-graph?
|
||||
*
|
||||
* `reexport` and `wildcard` are the explicit forms; `named`/`alias` drafts
|
||||
* flagged `reexportsName` cover providers whose ordinary import syntax also
|
||||
* republishes (see that field on `ParsedImport` for the contract).
|
||||
*
|
||||
* Tested on `base.kind`, not `source.kind`: `isNamespaceImport` can reclassify
|
||||
* a `named` draft to `namespace` (Python's `from . import submodule`), and a
|
||||
* namespace import aliases the target *module* — it publishes no name, so
|
||||
* admitting it would republish whatever def happens to share the module's
|
||||
* simple name.
|
||||
*/
|
||||
function contributesReexportEdge(draft: ImportEdgeDraft): boolean {
|
||||
if (draft.base.kind === 'namespace') return false;
|
||||
if (draft.source.kind === 'wildcard') return true;
|
||||
return isNamedReexport(draft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Named (non-wildcard) re-export. The narrowed type lets `populateFileClosure`
|
||||
* read `localName` (the name this file publishes) and `importedName` (the name
|
||||
* the target exports) without re-discriminating on `kind`.
|
||||
*/
|
||||
function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & {
|
||||
readonly source: Extract<ParsedImport, { kind: 'named' | 'alias' | 'reexport' }>;
|
||||
} {
|
||||
if (draft.base.kind === 'namespace') return false;
|
||||
const source = draft.source;
|
||||
if (source.kind === 'reexport') return true;
|
||||
return (source.kind === 'named' || source.kind === 'alias') && source.reexportsName === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names this file publishes ambiguously, which the closure must decline to
|
||||
* answer for rather than guess at.
|
||||
*
|
||||
* Declaration-order first-wins is sound only where a duplicate export is
|
||||
* illegal — two `export { X } from …` is a TypeScript compile error, so the
|
||||
* rule never fires. The flagged-named form has no such guarantee: CPython's
|
||||
* module namespace rebinds, so
|
||||
*
|
||||
* from .v1 import Client # legacy, left behind
|
||||
* from .v2 import Client # the actual public Client
|
||||
*
|
||||
* binds `v2`, and first-wins would attribute every `from pkg import Client` in
|
||||
* the repo to the dead implementation. Last-wins is not the answer either —
|
||||
* for the equally common `try:`/`except ImportError:` and `if
|
||||
* sys.version_info` pairs exactly one branch runs, and which one is not
|
||||
* decidable here. So both directions are wrong on real code and the entry is
|
||||
* dropped: the importer stays unresolved, which is exactly the pre-#2864
|
||||
* answer, and the file-level IMPORTS edge is unaffected.
|
||||
*
|
||||
* Computed once per file from data phase 0 froze (`edgeIndex`, `targetFile`)
|
||||
* and never revised, so the closure map stays monotone and the `|SCC| + 1`
|
||||
* fixpoint cap keeps the meaning it has above. A set that could grow mid-
|
||||
* fixpoint would need retraction to propagate to files that already inherited
|
||||
* the name, and would break both.
|
||||
*
|
||||
* Only two flagged drafts resolving to two *different in-workspace files*
|
||||
* count. Duplicates of the same target are harmless, and an unresolvable
|
||||
* target (`null` — the `try: import ujson / except: import json` shape, both
|
||||
* external) never entered the closure to begin with.
|
||||
*
|
||||
* ponytail: named-vs-named only. Wildcard-vs-wildcard collisions are also
|
||||
* first-wins today, but their inherited half depends on target closures that
|
||||
* are still filling in, so detecting them needs a set that grows during the
|
||||
* fixpoint — the thing this pre-pass exists to avoid.
|
||||
*/
|
||||
function collectAmbiguousReexports(
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
): ReadonlySet<string> {
|
||||
const firstTarget = new Map<string, string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const draft of drafts) {
|
||||
if (!isNamedReexport(draft)) continue;
|
||||
if (draft.source.kind === 'reexport') continue; // explicit form: duplicates are illegal upstream
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null || !byFilePath.has(targetFile)) continue;
|
||||
const localName = draft.source.localName;
|
||||
const seen = firstTarget.get(localName);
|
||||
if (seen === undefined) firstTarget.set(localName, targetFile);
|
||||
else if (seen !== targetFile) conflicting.add(localName);
|
||||
}
|
||||
return conflicting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate one file's re-export closure for one pass. Returns `true`
|
||||
* iff the closure grew (signalling fixpoint progress to the caller).
|
||||
|
|
@ -666,24 +858,29 @@ function populateFileClosure(
|
|||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
const before = myClosure.size;
|
||||
const drafts = edgeIndex.get(filePath);
|
||||
if (drafts === undefined) return false;
|
||||
// Fixed for the whole run — see `collectAmbiguousReexports`. Consulted in
|
||||
// both loops below: suppressing only the named one would let a later
|
||||
// `import *` refill the name and reinstate an arbitrary winner.
|
||||
const ambiguous = ambiguousByFile.get(filePath) ?? EMPTY_NAME_SET;
|
||||
|
||||
// Named re-exports — precedence over wildcards, declaration order
|
||||
// first-wins for duplicates of the same exported name.
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'reexport') continue;
|
||||
if (!isNamedReexport(draft)) continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
const localName = draft.source.localName;
|
||||
if (myClosure.has(localName)) continue;
|
||||
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||||
|
|
@ -695,7 +892,7 @@ function populateFileClosure(
|
|||
if (inherited !== undefined) {
|
||||
myClosure.set(localName, {
|
||||
def: inherited.def,
|
||||
via: Object.freeze([targetFile, ...inherited.via]),
|
||||
via: extendVia(targetFile, inherited.via),
|
||||
});
|
||||
}
|
||||
// Else: target's closure is still empty (in-SCC, awaiting next
|
||||
|
|
@ -714,16 +911,16 @@ function populateFileClosure(
|
|||
|
||||
for (const def of targetModule.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || myClosure.has(name)) continue;
|
||||
if (name === null || ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
if (targetClosure !== undefined) {
|
||||
for (const [name, entry] of targetClosure) {
|
||||
if (myClosure.has(name)) continue;
|
||||
if (ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, {
|
||||
def: entry.def,
|
||||
via: Object.freeze([targetFile, ...entry.via]),
|
||||
via: extendVia(targetFile, entry.via),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -732,6 +929,35 @@ function populateFileClosure(
|
|||
return myClosure.size > before;
|
||||
}
|
||||
|
||||
/**
|
||||
* Longest `transitiveVia` chain kept intact. Beyond this the tail is replaced
|
||||
* by {@link VIA_TRUNCATED}, so the entry still says "this came through a long
|
||||
* chain" without carrying it.
|
||||
*
|
||||
* Reinstates a bound the algorithm lost. Each hop copies the inherited path,
|
||||
* so an uncapped chain is Θ(depth²) in both time and retained memory, and
|
||||
* Θ(|SCC|²) for a cycle whose chain tracks it. `MAX_REEXPORT_DEPTH = 100`
|
||||
* covered this until `fc919ad6` removed it — correctly, for the TypeScript
|
||||
* barrels that were then the only input, which are shallow. Admitting
|
||||
* flagged-named imports changes the input class, so the bound comes back.
|
||||
*
|
||||
* 32 against a measured real-world worst case of ~6 for `__init__.py` chains:
|
||||
* five times the deepest chain anyone has, and it turns the quadratic into
|
||||
* O(depth × 32). Safe to truncate because `ImportEdge.transitiveVia` has no
|
||||
* production reader — it is diagnostic provenance, emitted and typed but not
|
||||
* consumed by graph emission (`emitImportEdges` dedups on source→target and
|
||||
* drops it).
|
||||
*/
|
||||
const MAX_VIA_LENGTH = 32;
|
||||
const VIA_TRUNCATED = '…';
|
||||
|
||||
function extendVia(head: string, inherited: readonly string[]): readonly string[] {
|
||||
if (inherited.length + 1 <= MAX_VIA_LENGTH) return Object.freeze([head, ...inherited]);
|
||||
// Already truncated one hop down: re-truncating keeps the array at the cap
|
||||
// rather than growing it by one per hop, which is the whole point.
|
||||
return Object.freeze([head, ...inherited.slice(0, MAX_VIA_LENGTH - 2), VIA_TRUNCATED]);
|
||||
}
|
||||
|
||||
/**
|
||||
* O(1) lookup into a precomputed re-export closure. Replaces the legacy
|
||||
* recursive `followReexportChain` traversal with a single map indexing.
|
||||
|
|
@ -792,15 +1018,52 @@ function findExportByName(
|
|||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
let fallback: SymbolDefinition | undefined;
|
||||
for (const d of defs) {
|
||||
if (deriveSimpleName(d) !== name) continue;
|
||||
if (isCallableOrTypeLike(d.type)) return d;
|
||||
if (fallback === undefined) fallback = d;
|
||||
}
|
||||
return fallback;
|
||||
return indexExportsByName(defs).get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* `simple name → winning def` for one file's `localDefs`, built once and
|
||||
* memoized on the array itself.
|
||||
*
|
||||
* Every caller of `findExportByName` sits in a loop that revisits the same
|
||||
* target files: the phase-3 fixpoint rescans a target once per iteration, and
|
||||
* `populateFileClosure` scans once per admitted re-export — which for a
|
||||
* provider setting `reexportsName` is every named import in the file, where it
|
||||
* used to be zero. Keeping the scan turned that into O(edges × defs).
|
||||
*
|
||||
* Safe to key on identity because `FinalizeFile.localDefs` is documented static
|
||||
* input that the fixpoint never mutates; a `WeakMap` ties each index to its
|
||||
* array's lifetime with no cross-pass state to invalidate. Same shape as the
|
||||
* `defById` map `materializeBindings` already builds for the same reason.
|
||||
*/
|
||||
const EXPORTS_BY_NAME = new WeakMap<
|
||||
readonly SymbolDefinition[],
|
||||
ReadonlyMap<string, SymbolDefinition>
|
||||
>();
|
||||
|
||||
function indexExportsByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
const cached = EXPORTS_BY_NAME.get(defs);
|
||||
if (cached !== undefined) return cached;
|
||||
const index = new Map<string, SymbolDefinition>();
|
||||
for (const d of defs) {
|
||||
const name = deriveSimpleName(d);
|
||||
if (name === null) continue;
|
||||
const existing = index.get(name);
|
||||
// First match wins within a tier; a callable displaces a stored value
|
||||
// shadow but never another callable — identical to the linear scan's
|
||||
// "first callable if any, else first match".
|
||||
if (existing === undefined) index.set(name, d);
|
||||
else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type))
|
||||
index.set(name, d);
|
||||
}
|
||||
EXPORTS_BY_NAME.set(defs, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
const EMPTY_NAME_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||||
'Function',
|
||||
'Method',
|
||||
|
|
@ -874,6 +1137,35 @@ function expandWildcard(
|
|||
kind: 'wildcard-expanded',
|
||||
targetModuleScope: edge.targetModuleScope,
|
||||
targetDefId: def.nodeId,
|
||||
// Every expanded edge inherits the presence facts of the ONE statement it
|
||||
// came from. They are built fresh rather than spread from `edge` because
|
||||
// `localName`, `targetExportedName` and `targetDefId` all differ per name
|
||||
// — which is exactly how a property added to the wildcard edge upstream
|
||||
// gets silently dropped here, and how `runsOnlyWhenCalled` was.
|
||||
//
|
||||
// `runsOnlyWhenCalled`: Ruby's `def f; require './m'; end` is one
|
||||
// statement inside one method body — and every Ruby `require` is a
|
||||
// wildcard, since the required file's whole surface becomes visible — so
|
||||
// each name it brings in is bound only when `f` runs. Losing the flag
|
||||
// here re-reports the pair as an initialization dependency and
|
||||
// suppresses nothing — it INVENTS a cycle (`check --cycles`), which is
|
||||
// why this is carried and not derived.
|
||||
//
|
||||
// Ruby is the reachable spelling. Python has no function-local
|
||||
// `from x import *` — it is a SyntaxError — and Rust's `fn f() { use
|
||||
// m::*; }`, which IS legal, is not deferred at all: `use` is a
|
||||
// compile-time path alias, so the Rust provider opts out of the position
|
||||
// rule (`LanguageProvider.importsExecuteWhereWritten`).
|
||||
//
|
||||
// `typeOnly`: unreachable today and deliberately kept. No provider emits
|
||||
// a type-only wildcard — `reexport-wildcard` returns `kind: 'wildcard'`
|
||||
// with no `typeOnly` because `export type *` is unparseable by the
|
||||
// vendored grammar (documented on `ParsedImport`'s `wildcard` variant).
|
||||
// It is propagated so the day that gap closes does not silently
|
||||
// reintroduce this same defect for erasure. Do not delete it as dead
|
||||
// code; `typeOnlyFor` is the gate that decides whether it can ever be
|
||||
// set, and it is where the correspondence is enforced.
|
||||
...carriedPresenceFlags(edge),
|
||||
});
|
||||
}
|
||||
return expanded;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ export interface ReferenceSite {
|
|||
* otherwise, in which case resolution is unchanged.
|
||||
*/
|
||||
readonly rawQualifiedName?: string;
|
||||
/**
|
||||
* Top-level generic/template arguments the source wrote ON this reference —
|
||||
* `class UserValidator : IValidator<string>` yields `['string']` on the
|
||||
* `inherits` site whose `name` is `IValidator`.
|
||||
*
|
||||
* `name` is the BASE name and stays that way: every lookup in resolution is
|
||||
* keyed by it, and one declaration answers for every instantiation of itself.
|
||||
* This records what the erasure threw away, so a consumer that needs the
|
||||
* INSTANTIATION — receiver-bound interface dispatch, which must not fan a
|
||||
* `IValidator<string>` receiver out to an `IValidator<int>` implementor
|
||||
* (#2912) — can ask for it without re-parsing the source.
|
||||
*
|
||||
* Derived generically from the anchor capture's own text (see
|
||||
* `collectReferenceSites`), so no language query change is needed: an emitter
|
||||
* whose `@reference.inherits` anchor spans the whole base gets this for free,
|
||||
* and one whose anchor is the bare name simply leaves it absent.
|
||||
*
|
||||
* ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable
|
||||
* here, and only the first is safe to act on. Consumers must fail OPEN on
|
||||
* absence (keep the target), matching `SymbolDefinition.typeParameters`.
|
||||
*/
|
||||
readonly typeArguments?: readonly string[];
|
||||
/** Source-text range of this reference. */
|
||||
readonly atRange: Range;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ export interface SymbolDefinition {
|
|||
* Unavailable callables still participate in overload selection, but a
|
||||
* selected unavailable target must suppress edge emission. */
|
||||
isDeleted?: boolean;
|
||||
/** True when the declaration identity was synthesized rather than written in
|
||||
* source (for example an anonymous class). Consumers may use this only as a
|
||||
* conservative priority hint; it does not change graph-node identity. */
|
||||
isSynthetic?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||
|
|
|
|||
|
|
@ -119,8 +119,96 @@ export type ParsedImport =
|
|||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/** Provider-specific imported symbol category when module and symbol
|
||||
* namespaces have distinct resolution rules (for example PHP). */
|
||||
* namespaces have distinct resolution rules (for example PHP).
|
||||
*
|
||||
* **Not** the same fact as {@link ParsedImport.typeOnly} — see the note
|
||||
* on `typeOnly` below, which is documented on this variant. */
|
||||
readonly importedSymbolKind?: 'type' | 'function' | 'const';
|
||||
/**
|
||||
* Is this import ERASED before the module ever runs?
|
||||
*
|
||||
* TypeScript `import type { X } from './m'` and `import { type X }` are
|
||||
* deleted by `tsc`: no `require`/`import` for `./m` survives in the
|
||||
* emitted JavaScript, so the pair cannot force a module-INITIALIZATION
|
||||
* order and cannot participate in an init cycle. That is the one thing
|
||||
* `check --cycles` exists to find, so the fact has to survive from the
|
||||
* syntax down to the emitted `IMPORTS` edge — see `ImportEdge.typeOnly`
|
||||
* and `graph-bridge/imports-to-edges.ts`.
|
||||
*
|
||||
* **Distinct from `importedSymbolKind: 'type'`, which is NOT a substitute.**
|
||||
* That field is a resolution-NAMESPACE category (PHP's `use function` /
|
||||
* `use const` split), it exists only on this variant, and it says "the
|
||||
* thing imported is a type". A symbol being a type says nothing about
|
||||
* whether the import STATEMENT is erased, and PHP erases nothing at all.
|
||||
* This field is about the statement's runtime existence, not the symbol's
|
||||
* category.
|
||||
*
|
||||
* Set only by providers whose syntax marks it. Absent everywhere else,
|
||||
* which reads as "not erased" — the fail-safe direction, since it only
|
||||
* makes `check --cycles` over-report.
|
||||
*
|
||||
* That fail-safe matters more than it first looks, because an explicit
|
||||
* `type` is a SUFFICIENT signal of erasure and not a necessary one. With
|
||||
* neither `verbatimModuleSyntax` nor `importsNotUsedAsValues: preserve`
|
||||
* set — this repo sets neither — `tsc` also elides a plain
|
||||
* `import { SomeInterface }` whose bindings are every one of them used in
|
||||
* type position. Those statements are erased at run time and carry no
|
||||
* marker, so they stay tagged as initializing and `check --cycles` can
|
||||
* still report a cycle that cannot exist. Closing that gap needs
|
||||
* whole-program binding USE information, not import syntax, which is why
|
||||
* this field stops at what the syntax states.
|
||||
*/
|
||||
readonly typeOnly?: boolean;
|
||||
/**
|
||||
* Was this import written inside a function body — so that it runs only
|
||||
* when something CALLS that function, never while the module itself is
|
||||
* initializing?
|
||||
*
|
||||
* Python's `def f(): from x import Y` and a CommonJS
|
||||
* `function f() { const { Y } = require('./x'); }` are the spellings.
|
||||
* Both are syntactically ordinary imports — no `kind` tells them apart
|
||||
* from a top-level one, and nothing about the target does either. Only
|
||||
* their POSITION defers them.
|
||||
*
|
||||
* Not every language's imports are like that, and the rule is wrong for
|
||||
* the ones that are not: Rust's `use` and C/C++'s `#include` are legal
|
||||
* in a function body and are deferred by NOTHING, because neither is an
|
||||
* executed statement. Those providers opt out — see
|
||||
* `LanguageProvider.importsExecuteWhereWritten`, below.
|
||||
*
|
||||
* **Why this cannot be re-derived downstream — the whole reason the
|
||||
* field exists.** The natural place to decide it looks like the graph
|
||||
* bridge, by walking the scope the finalized edges hang off; that is
|
||||
* exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it
|
||||
* is dead code by construction. `finalize-algorithm.ts:295` publishes
|
||||
* every file's finalized edges as
|
||||
* `linkedByScope.set(file.moduleScope, …)`, so the map the bridge
|
||||
* receives is keyed by the file's **Module** scope and by nothing else:
|
||||
* the walk starts at a `Module` every time and answers `false` for every
|
||||
* import in the tree. Finalize cannot recover the position either —
|
||||
* `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with
|
||||
* no scope attached. The extractor is the last stage that still knows
|
||||
* where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the
|
||||
* fact here and it rides the edge from there — see
|
||||
* {@link ImportEdge.runsOnlyWhenCalled}.
|
||||
*
|
||||
* Consumed by `check --cycles`, which asks "can these modules be
|
||||
* initialized in any order?". A deferred import carries no
|
||||
* initialization order, and deferring one is the standard way to BREAK
|
||||
* an init cycle, so counting it reports the fix as the bug.
|
||||
*
|
||||
* Set by the central extractor for every language, not by providers —
|
||||
* except that a provider may declare that its imports do not execute
|
||||
* where they are written (`LanguageProvider.importsExecuteWhereWritten:
|
||||
* false`) and be skipped entirely. C, C++, Rust and COBOL do. A `#include`
|
||||
* or a `use` inside a function body is not deferred: the header is
|
||||
* spliced and the path alias is resolved before anything runs, so the
|
||||
* pair really is a dependency and the cycle it can form is real.
|
||||
*
|
||||
* Absent reads as "runs at initialization" — the fail-safe direction,
|
||||
* since it only makes `check --cycles` over-report.
|
||||
*/
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
/**
|
||||
* Set by providers when `targetRaw` already names the imported symbol
|
||||
* rather than only its containing module. Consumers that compose
|
||||
|
|
@ -128,6 +216,40 @@ export type ParsedImport =
|
|||
* duplicating `importedName`.
|
||||
*/
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
/**
|
||||
* Set by providers whose import syntax *also* republishes the name from
|
||||
* the importing module, so a third file can import it from there.
|
||||
*
|
||||
* Python has no dedicated re-export form: a module-level
|
||||
* `from pkg.impl import X` binds `X` locally **and** publishes it as
|
||||
* `pkg.X`, which is the standard way a package `__init__.py` declares
|
||||
* its public surface. Languages with an explicit form (TS `export … from`,
|
||||
* Rust `pub use`) emit `kind: 'reexport'` instead and leave this unset.
|
||||
*
|
||||
* **The flag must track actual republication, not syntax.** Only a
|
||||
* module-level statement publishes: the same `from m import X` inside a
|
||||
* `def` or `class` body binds locally and puts nothing in the module
|
||||
* namespace, so flagging it fabricates a re-export of a name no importer
|
||||
* can reach. `if` / `try` / `for` / `with` do not suppress it — Python
|
||||
* has no block scope. A provider that cannot tell these apart at
|
||||
* interpret time must carry the fact down from its capture emitter,
|
||||
* where the syntax node is still available.
|
||||
*
|
||||
* **Why not `kind: 'reexport'`.** Not because that form drops the local
|
||||
* binding — `materializeBindings` creates a module-scope `BindingRef`
|
||||
* for every linked edge, re-export included. It is that `reexport`
|
||||
* changes what the binding *is*: `origin` flips to `'reexport'`, which
|
||||
* carries different evidence weight and `ORIGIN_PRIORITY`, and it
|
||||
* misreports the parse-time syntax Python actually wrote. A flag adds
|
||||
* the export-surface fact without restating the import as something the
|
||||
* source does not say.
|
||||
*
|
||||
* Consumed by `buildReexportClosures` (`finalize-algorithm.ts`), which
|
||||
* also documents how ambiguous duplicates of one published name are
|
||||
* handled — the precedence rules that hold for an explicit re-export do
|
||||
* not carry over.
|
||||
*/
|
||||
readonly reexportsName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Per-name import with rename.
|
||||
|
|
@ -144,8 +266,18 @@ export type ParsedImport =
|
|||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly importedSymbolKind?: 'type' | 'function' | 'const';
|
||||
/** See the same field on the `named` variant — including why it is not
|
||||
* interchangeable with `importedSymbolKind`. Reaches this variant from
|
||||
* `import type D from './m'` and `import { type X as Y } from './m'`. */
|
||||
readonly typeOnly?: boolean;
|
||||
/** See the same field on the `named` variant. Reaches this variant from
|
||||
* Python's `def f(): from x import Y as Z` and a CommonJS
|
||||
* `function f() { const { Y: Z } = require('./x'); }`. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly targetIncludesImportedName?: boolean;
|
||||
/** See the same field on the `named` variant. */
|
||||
readonly reexportsName?: boolean;
|
||||
}
|
||||
/**
|
||||
* Qualified module handle, with or without rename. `importedName` is the
|
||||
|
|
@ -165,6 +297,12 @@ export type ParsedImport =
|
|||
/** Module being aliased (e.g. `numpy` in `import numpy as np`). */
|
||||
readonly importedName: string;
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. Reaches this variant from
|
||||
* TypeScript `import type * as N from './m'`. */
|
||||
readonly typeOnly?: boolean;
|
||||
/** See the same field on the `named` variant. Reaches this variant from
|
||||
* Python's `def f(): import numpy as np`. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
}
|
||||
/**
|
||||
* Syntactically-detectable parse-time re-export. Finalize may still produce
|
||||
|
|
@ -186,6 +324,19 @@ export type ParsedImport =
|
|||
readonly targetRaw: string;
|
||||
/** Set when the re-export renames the symbol (e.g. `export { X as Y } from './y'`). */
|
||||
readonly alias?: string;
|
||||
/** See the same field on the `named` variant. Reaches this variant from
|
||||
* TypeScript `export type { X } from './y'` and `export { type X } from './y'`. */
|
||||
readonly typeOnly?: boolean;
|
||||
/** See the same field on the `named` variant. NO spelling reaches this
|
||||
* variant today: the two providers that emit `reexport` are TypeScript
|
||||
* / JavaScript, whose `export … from` is a module-top-level-only
|
||||
* declaration, and Rust, whose `pub use` is a compile-time path alias
|
||||
* that its provider exempts from the position rule outright
|
||||
* (`LanguageProvider.importsExecuteWhereWritten`). Kept because the
|
||||
* extractor sets the field with no `switch` on `kind`, so a re-export
|
||||
* form that IS an executed statement would be tagged the moment one
|
||||
* appears — not because anything sets it now. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
}
|
||||
/**
|
||||
* Wildcard import — brings every exported name from the target module into
|
||||
|
|
@ -197,10 +348,26 @@ export type ParsedImport =
|
|||
* - Python `from foo import *` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
* - JS `export * from './foo'` → `{ kind: 'wildcard', targetRaw: './foo' }`
|
||||
* - Rust `pub use foo::*` → `{ kind: 'wildcard', targetRaw: 'foo' }`
|
||||
*
|
||||
* No `typeOnly` here on purpose. The one syntax that would set it,
|
||||
* TypeScript 5.0's `export type * from './m'`, is not parsed by the
|
||||
* vendored tree-sitter-typescript grammar — it yields an `ERROR` node
|
||||
* holding the bare `type` token, so the fact is not readable at the
|
||||
* statement level (see `typescript/import-decomposer.ts`). Add the field
|
||||
* with the grammar that can express it, not before.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'wildcard';
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. Present here although
|
||||
* `typeOnly` is not: erasure is a syntactic fact this spelling cannot
|
||||
* express, but POSITION is not — Ruby's `def f; require './m'; end` is
|
||||
* a wildcard (everything in the required file becomes visible) and IS
|
||||
* deferred. Python cannot reach it: `from x import *` inside a `def` is
|
||||
* a SyntaxError. Rust's fn-local `use foo::*` is legal but not
|
||||
* deferred — `use` does not execute
|
||||
* (`LanguageProvider.importsExecuteWhereWritten`). */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
}
|
||||
/**
|
||||
* Runtime-computed target — the import path is not a static literal at
|
||||
|
|
@ -217,6 +384,9 @@ export type ParsedImport =
|
|||
readonly localName: string;
|
||||
/** Source text of the unresolved expression when available; `null` otherwise. */
|
||||
readonly targetRaw: string | null;
|
||||
/** See the same field on the `named` variant. Set by position like every
|
||||
* other variant; this kind links no target, so nothing reads it here. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
}
|
||||
/**
|
||||
* Lazy / dynamic import whose target IS a static string literal at parse
|
||||
|
|
@ -238,6 +408,10 @@ export type ParsedImport =
|
|||
| {
|
||||
readonly kind: 'dynamic-resolved';
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. Redundant on this kind —
|
||||
* `import()` is already deferred wherever it is written — but set
|
||||
* uniformly, because position is decided without consulting `kind`. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
}
|
||||
/**
|
||||
* Bare-source / side-effect import that introduces no local name binding
|
||||
|
|
@ -253,6 +427,10 @@ export type ParsedImport =
|
|||
| {
|
||||
readonly kind: 'side-effect';
|
||||
readonly targetRaw: string;
|
||||
/** See the same field on the `named` variant. Reaches this variant from
|
||||
* a bare CommonJS `function f() { require('./polyfill'); }` — the ESM
|
||||
* spelling `import './polyfill'` cannot, being top-level only. */
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -348,6 +526,37 @@ export interface ImportEdge {
|
|||
| 'side-effect';
|
||||
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
|
||||
readonly transitiveVia?: readonly string[];
|
||||
/**
|
||||
* The import is erased before the module runs — see `ParsedImport`'s
|
||||
* `typeOnly` on the `named` variant for the full note, including why
|
||||
* `importedSymbolKind: 'type'` is a different fact and not a substitute.
|
||||
*
|
||||
* Carried straight from the `ParsedImport` by `makeEdgeDrafts`. The edge is
|
||||
* still emitted: a type-only import is a real source-level dependency that
|
||||
* `impact` and `trace` must see, and editing the target still breaks the
|
||||
* importer's typecheck. What the flag removes is the claim that the pair
|
||||
* forces an INITIALIZATION order.
|
||||
*/
|
||||
readonly typeOnly?: boolean;
|
||||
/**
|
||||
* The import was written inside a function body, so it runs only when that
|
||||
* function is called — never during module initialization. See
|
||||
* `ParsedImport`'s `runsOnlyWhenCalled` on the `named` variant for the full
|
||||
* note, including why the consumer cannot re-derive this from the scope tree
|
||||
* and therefore has to be told (`finalize-algorithm.ts:295`).
|
||||
*
|
||||
* Carried straight from the `ParsedImport` by `makeEdgeDrafts`, for the same
|
||||
* reason `typeOnly` is: the edge is where `graph-bridge/imports-to-edges.ts`
|
||||
* can still see it. The edge is still emitted either way — a deferred import
|
||||
* is a real dependency. What the flag removes is the claim that the pair
|
||||
* forces an INITIALIZATION order.
|
||||
*
|
||||
* Distinct from `kind === 'dynamic-resolved'`, which records the OTHER way an
|
||||
* import can be deferred (`import('./m')`). Neither implies the other: a
|
||||
* top-level `import()` is deferred with this flag unset, and a function-local
|
||||
* `from x import Y` is deferred with an ordinary `named` kind.
|
||||
*/
|
||||
readonly runsOnlyWhenCalled?: boolean;
|
||||
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
|
||||
readonly linkStatus?: 'unresolved';
|
||||
}
|
||||
|
|
|
|||
349
gitnexus-web/package-lock.json
generated
349
gitnexus-web/package-lock.json
generated
|
|
@ -8,15 +8,15 @@
|
|||
"name": "gitnexus-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -28,37 +28,37 @@
|
|||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"langchain": "^1.5.4",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.31.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.23",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"@vercel/node": "^5.10.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
"../gitnexus-shared": {
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
|
@ -98,9 +98,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.103.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz",
|
||||
"integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==",
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
|
|
@ -246,9 +246,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
|
|
@ -307,13 +307,6 @@
|
|||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bytecodealliance/preview2-shim": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.17.6.tgz",
|
||||
"integrity": "sha512-n3cM88gTen5980UOBAD6xDcNNL3ocTK8keab21bpx1ONdA+ARj7uD1qoFxOWCyKlkpSi195FH+GeAut7Oc6zZw==",
|
||||
"dev": true,
|
||||
"license": "(Apache-2.0 WITH LLVM-exception)"
|
||||
},
|
||||
"node_modules/@cfworker/json-schema": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||
|
|
@ -1123,25 +1116,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz",
|
||||
"integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==",
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.8.tgz",
|
||||
"integrity": "sha512-KZWgIf+04M9XZHhgH1rVJkqw/C26DM4a4jKk4Qc4HaSbRawN2Dw5nDffna+IoaU/50ohTdyB3HOz9g8XFQYF2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.103.0",
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.1"
|
||||
"@langchain/core": "^1.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz",
|
||||
"integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz",
|
||||
"integrity": "sha512-conzSEj9Zu1AyXJLXsSbgrtxtxinmI1yGqQ5CIJZSoV5rvv+yvQE/vgBnoySpBQ/bl3YPgj2FL/gbDjWykLSfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
@ -1172,13 +1165,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.8.tgz",
|
||||
"integrity": "sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==",
|
||||
"version": "1.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.9.tgz",
|
||||
"integrity": "sha512-EvD9rS66Cya09y6rbMgD3Ir8miAkJQFo7FyJOPRPO736Kz3y5TeyeBDOS8ctff/jRc788bPijHx2NVFM79Qqig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"@langchain/langgraph-sdk": "~1.9.26",
|
||||
"@langchain/langgraph-sdk": "~1.9.28",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
|
|
@ -1419,17 +1412,6 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@renovatebot/pep440": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.1.tgz",
|
||||
"integrity": "sha512-2FK1hF93Fuf1laSdfiEmJvSJPVIDHEUTz68D3Fi9s0IZrrpaEcj6pTFBTbYvsgC5du4ogrtf5re7yMMvrKNgkw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.9.0 || ^22.11.0 || ^24",
|
||||
"pnpm": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
|
|
@ -1517,9 +1499,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1536,9 +1515,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1555,9 +1531,6 @@
|
|||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1574,9 +1547,6 @@
|
|||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1593,9 +1563,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1612,9 +1579,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1865,9 +1829,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1884,9 +1845,6 @@
|
|||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1903,9 +1861,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1922,9 +1877,6 @@
|
|||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2091,9 +2043,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
|
||||
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2105,9 +2057,12 @@
|
|||
"redent": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"node": ">=22",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=10 <11"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
|
||||
|
|
@ -2146,9 +2101,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"version": "14.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
|
||||
"integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2589,9 +2544,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
|
@ -2638,13 +2593,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.32.3",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.32.3.tgz",
|
||||
"integrity": "sha512-rYk9EKq8ThkBC1vz38jZ8DmmxtKBjN6EfEOEz1ORL74PLVvET/l++R0tNmPrPg3eP+GI852KdArDmJRNCY6EOw==",
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.2.0.tgz",
|
||||
"integrity": "sha512-GwmtB31tBXQEzFw11grr8BKFCBdUORmYeooB0ZtonaCXZMZaPCHLBFTMFKsvaV6ZciQORPInRwXShbFvmnjqtg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@vercel/python-analysis": "0.11.1",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"es-module-lexer": "1.5.0"
|
||||
}
|
||||
|
|
@ -2657,9 +2611,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/error-utils": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
|
||||
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.1.tgz",
|
||||
"integrity": "sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
|
|
@ -2691,9 +2645,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.8.23",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.23.tgz",
|
||||
"integrity": "sha512-wigp1yONlJwFtPuyCrp6KI1umG78VhhEspNBXe2i9UOaxjjqLAR3DKiRQ/ivjvnDzV0SN7fuLxwLj+JcG0iwcQ==",
|
||||
"version": "5.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.2.tgz",
|
||||
"integrity": "sha512-YBXcoQVOh5O2ySXvzE+POhPEQEPMJJo4ctlMMdp5why/NIoa8m6gotv14j8Uo6D5qyZsnc+0+++JgUiV4mYB6w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2701,10 +2655,10 @@
|
|||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.32.3",
|
||||
"@vercel/error-utils": "2.2.0",
|
||||
"@vercel/build-utils": "14.2.0",
|
||||
"@vercel/error-utils": "2.2.1",
|
||||
"@vercel/nft": "1.10.0",
|
||||
"@vercel/static-config": "3.4.0",
|
||||
"@vercel/static-config": "3.4.1",
|
||||
"async-listen": "3.0.0",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"edge-runtime": "2.5.9",
|
||||
|
|
@ -2738,36 +2692,10 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/python-analysis": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz",
|
||||
"integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bytecodealliance/preview2-shim": "0.17.6",
|
||||
"@renovatebot/pep440": "4.2.1",
|
||||
"fs-extra": "11.1.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"minimatch": "10.1.1",
|
||||
"smol-toml": "1.5.2",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/python-analysis/node_modules/zod": {
|
||||
"version": "3.22.4",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
|
||||
"integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/static-config": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz",
|
||||
"integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.1.tgz",
|
||||
"integrity": "sha512-kJKTyOg25JDRgDkHEkc+vWlvURxmSQkVKyRPO4EEGD/8HpJT+4u9Z/VGxwnCZ6zZBxYPpma283qBsHwY0gXjfw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2788,9 +2716,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -3051,13 +2979,6 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
|
|
@ -3131,13 +3052,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
|
|
@ -4511,21 +4432,6 @@
|
|||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz",
|
||||
"integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -5197,19 +5103,6 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
|
|
@ -5265,9 +5158,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/undici": {
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -5319,19 +5212,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
|
|
@ -5363,13 +5243,13 @@
|
|||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langchain": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.6.tgz",
|
||||
"integrity": "sha512-pwuFmGOyiMezptLVLrpb5jILirvYPGHI5uJCFHL5K5WPxMy2XuPLI5QNMKtoHkdiL6a2dLebqugKw87cneaESw==",
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.4.tgz",
|
||||
"integrity": "sha512-9Rq6Ih77UOy3+7bCbxMJS16MRUJwfxuljU0yW2KOXDgEKWE8cmaZJE6ONEy4HdWGMsbj3qyv3vD5UvV7fvNksg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph": "^1.3.4",
|
||||
"@langchain/langgraph-checkpoint": "^1.0.4",
|
||||
"@langchain/langgraph": "^1.4.7",
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"langsmith": ">=0.5.0 <1.0.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
|
|
@ -5377,7 +5257,7 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.0"
|
||||
"@langchain/core": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
|
|
@ -5715,9 +5595,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
|
||||
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
|
||||
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -6884,9 +6764,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -7393,33 +7273,33 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.7"
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"version": "17.0.12",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
|
|
@ -7805,19 +7685,6 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz",
|
||||
"integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -7952,9 +7819,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
|
@ -8190,9 +8057,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.24.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.0.tgz",
|
||||
"integrity": "sha512-lVLNosgqo5EkGqh5XUDhGfsMSoO8K0BAN0TyJLvwNRSl4xWGZlCVYsAIpa/OpA3TvmnM01GWcoKmc3ZWo5wKKA==",
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -8293,16 +8160,6 @@
|
|||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
|
|
@ -8313,9 +8170,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@
|
|||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.8",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -38,37 +38,37 @@
|
|||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.4.6",
|
||||
"langchain": "^1.5.4",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.31.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.23",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"@vercel/node": "^5.10.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
|
|
@ -83,7 +83,7 @@
|
|||
},
|
||||
"@vercel/node": {
|
||||
"path-to-regexp": "6.3.0",
|
||||
"undici": "6.24.0"
|
||||
"undici": "6.28.0"
|
||||
},
|
||||
"@vercel/python-analysis": {
|
||||
"minimatch": "10.2.3",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@ import {
|
|||
fetchOpenRouterModels,
|
||||
} from '../core/llm/settings-service';
|
||||
import { getAuthToken, setAuthToken } from '../services/backend-client';
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
import type { LLMSettings, LLMProvider, MiniMaxThinkingMode } from '../core/llm/types';
|
||||
import {
|
||||
getMiniMaxModelCapabilities,
|
||||
MINIMAX_ANTHROPIC_BASE_URLS,
|
||||
MINIMAX_DOCS_ROOTS,
|
||||
MINIMAX_MODEL_IDS,
|
||||
} from '../core/llm/types';
|
||||
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
|
||||
import { ProviderConfigCard } from './settings/ProviderConfigCard';
|
||||
import { SecretInput } from './settings/SecretInput';
|
||||
|
|
@ -341,6 +347,20 @@ export const SettingsPanel = ({
|
|||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const miniMaxModel = settings.minimax?.model ?? MINIMAX_MODEL_IDS[0];
|
||||
const miniMaxCapabilities = getMiniMaxModelCapabilities(miniMaxModel);
|
||||
const configuredMiniMaxThinkingMode = settings.minimax?.thinkingMode;
|
||||
const miniMaxThinkingMode =
|
||||
configuredMiniMaxThinkingMode &&
|
||||
miniMaxCapabilities?.thinkingModes.includes(configuredMiniMaxThinkingMode)
|
||||
? configuredMiniMaxThinkingMode
|
||||
: (miniMaxCapabilities?.thinkingModes[0] ?? configuredMiniMaxThinkingMode ?? 'adaptive');
|
||||
const miniMaxBaseUrl = settings.minimax?.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en;
|
||||
const miniMaxDocsRoot =
|
||||
miniMaxBaseUrl === MINIMAX_ANTHROPIC_BASE_URLS.cn_zh
|
||||
? MINIMAX_DOCS_ROOTS.cn_zh
|
||||
: MINIMAX_DOCS_ROOTS.global_en;
|
||||
|
||||
const providers: LLMProvider[] = [
|
||||
'openai',
|
||||
'gemini',
|
||||
|
|
@ -864,7 +884,7 @@ export const SettingsPanel = ({
|
|||
value: settings.minimax?.apiKey ?? '',
|
||||
placeholder: t('settings:providers.minimax.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.minimax.helperText'),
|
||||
helperLink: 'https://platform.minimax.io',
|
||||
helperLink: miniMaxDocsRoot,
|
||||
helperLinkLabel: t('settings:providers.minimax.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['minimax'],
|
||||
onChange: (value) =>
|
||||
|
|
@ -875,16 +895,79 @@ export const SettingsPanel = ({
|
|||
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.minimax?.model ?? 'MiniMax-M2.5',
|
||||
value: miniMaxModel,
|
||||
placeholder: t('settings:providers.minimax.modelPlaceholder'),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: value },
|
||||
minimax: {
|
||||
...prev.minimax!,
|
||||
model: value,
|
||||
thinkingMode:
|
||||
getMiniMaxModelCapabilities(value)?.thinkingModes[0] ??
|
||||
prev.minimax?.thinkingMode,
|
||||
},
|
||||
})),
|
||||
helperText: t('settings:providers.minimax.helperModel'),
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:providers.minimax.endpoint')}
|
||||
</label>
|
||||
<select
|
||||
value={miniMaxBaseUrl}
|
||||
onChange={(event) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, baseUrl: event.target.value },
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
>
|
||||
<option value={MINIMAX_ANTHROPIC_BASE_URLS.global_en}>
|
||||
{t('settings:providers.minimax.endpoints.global')}
|
||||
</option>
|
||||
<option value={MINIMAX_ANTHROPIC_BASE_URLS.cn_zh}>
|
||||
{t('settings:providers.minimax.endpoints.china')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:providers.minimax.thinking')}
|
||||
</label>
|
||||
<select
|
||||
value={miniMaxThinkingMode}
|
||||
disabled={miniMaxCapabilities?.thinkingModes.length === 1}
|
||||
onChange={(event) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: {
|
||||
...prev.minimax!,
|
||||
thinkingMode: event.target.value as MiniMaxThinkingMode,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{(miniMaxCapabilities?.thinkingModes ?? ['adaptive', 'disabled']).map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{t(`settings:providers.minimax.thinkingModes.${mode}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{miniMaxCapabilities && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('settings:providers.minimax.capabilities', {
|
||||
contextWindow: miniMaxCapabilities.contextWindow.toLocaleString(),
|
||||
modalities: miniMaxCapabilities.inputModalities.join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ProviderConfigCard>
|
||||
)}
|
||||
|
||||
{/* DeepSeek Settings */}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { ChatOllama } from '@langchain/ollama';
|
|||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { createGraphRAGTools, type GraphRAGBackend } from './tools';
|
||||
import type {
|
||||
AgentUserContent,
|
||||
ProviderConfig,
|
||||
OpenAIConfig,
|
||||
AzureOpenAIConfig,
|
||||
|
|
@ -32,7 +33,9 @@ import type {
|
|||
DeepSeekConfig,
|
||||
AgentStreamChunk,
|
||||
AgentHistoryMessage,
|
||||
MiniMaxThinkingMode,
|
||||
} from './types';
|
||||
import { getMiniMaxModelCapabilities, MINIMAX_ANTHROPIC_BASE_URLS } from './types';
|
||||
import {
|
||||
type CodebaseContext,
|
||||
buildDynamicSystemPrompt,
|
||||
|
|
@ -275,14 +278,28 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
throw new Error('MiniMax API key is required but was not provided');
|
||||
}
|
||||
|
||||
const capabilities = getMiniMaxModelCapabilities(minimaxConfig.model);
|
||||
const requestedThinkingMode = minimaxConfig.thinkingMode;
|
||||
const thinkingMode: MiniMaxThinkingMode | undefined =
|
||||
requestedThinkingMode && capabilities?.thinkingModes.includes(requestedThinkingMode)
|
||||
? requestedThinkingMode
|
||||
: (capabilities?.thinkingModes[0] ?? requestedThinkingMode);
|
||||
const thinking =
|
||||
thinkingMode && thinkingMode !== 'always_on' ? { type: thinkingMode } : undefined;
|
||||
const temperature =
|
||||
thinkingMode === 'adaptive' || thinkingMode === 'always_on'
|
||||
? undefined
|
||||
: (minimaxConfig.temperature ?? 0.1);
|
||||
|
||||
return new ChatAnthropic({
|
||||
anthropicApiKey: minimaxConfig.apiKey,
|
||||
model: minimaxConfig.model,
|
||||
temperature: minimaxConfig.temperature ?? 0.1,
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
maxTokens: minimaxConfig.maxTokens ?? 8192,
|
||||
streaming: true,
|
||||
...(thinking ? { thinking } : {}),
|
||||
clientOptions: {
|
||||
baseURL: 'https://api.minimax.io/anthropic',
|
||||
baseURL: minimaxConfig.baseUrl ?? MINIMAX_ANTHROPIC_BASE_URLS.global_en,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -393,7 +410,7 @@ export const createGraphRAGAgent = (
|
|||
/**
|
||||
* Message type for agent conversation
|
||||
*/
|
||||
export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage;
|
||||
export type AgentMessage = { role: 'user'; content: AgentUserContent } | AgentHistoryMessage;
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
/** Capture assistant/tool messages for providers that require exact transcript replay. */
|
||||
|
|
@ -412,7 +429,9 @@ const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
|
|||
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
return new HumanMessage(message.content);
|
||||
return typeof message.content === 'string'
|
||||
? new HumanMessage(message.content)
|
||||
: new HumanMessage({ content: message.content as any });
|
||||
}
|
||||
if (message.role === 'tool') {
|
||||
return new ToolMessage({
|
||||
|
|
@ -542,6 +561,7 @@ export async function* streamAgentResponse(
|
|||
|
||||
// Handle content that can be string or array of content blocks
|
||||
let content: string = '';
|
||||
let thinkingContent: string = '';
|
||||
if (typeof rawContent === 'string') {
|
||||
content = rawContent;
|
||||
} else if (Array.isArray(rawContent)) {
|
||||
|
|
@ -550,6 +570,14 @@ export async function* streamAgentResponse(
|
|||
.filter((block: any) => block.type === 'text' || typeof block === 'string')
|
||||
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
|
||||
.join('');
|
||||
thinkingContent = rawContent
|
||||
.filter((block: any) => block?.type === 'thinking')
|
||||
.map((block: any) => block.thinking || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
if (thinkingContent) {
|
||||
yield { type: 'reasoning', reasoning: thinkingContent };
|
||||
}
|
||||
|
||||
// If chunk has content, stream it
|
||||
|
|
|
|||
|
|
@ -19,12 +19,32 @@ import {
|
|||
GLMConfig,
|
||||
DeepSeekConfig,
|
||||
ProviderConfig,
|
||||
MINIMAX_MODEL_IDS,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
import { resilientFetch } from 'gitnexus-shared';
|
||||
|
||||
const STORAGE_KEY = 'gitnexus-llm-settings';
|
||||
|
||||
const mergeMiniMaxSettings = (
|
||||
stored?: LLMSettings['minimax'],
|
||||
): NonNullable<LLMSettings['minimax']> => {
|
||||
const merged = {
|
||||
...DEFAULT_LLM_SETTINGS.minimax,
|
||||
...stored,
|
||||
};
|
||||
|
||||
if (!(MINIMAX_MODEL_IDS as readonly string[]).includes(merged.model ?? '')) {
|
||||
return {
|
||||
...merged,
|
||||
model: DEFAULT_LLM_SETTINGS.minimax?.model,
|
||||
thinkingMode: DEFAULT_LLM_SETTINGS.minimax?.thinkingMode,
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings => ({
|
||||
...DEFAULT_LLM_SETTINGS,
|
||||
...parsed,
|
||||
|
|
@ -52,10 +72,7 @@ const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings =>
|
|||
...DEFAULT_LLM_SETTINGS.openrouter,
|
||||
...parsed?.openrouter,
|
||||
},
|
||||
minimax: {
|
||||
...DEFAULT_LLM_SETTINGS.minimax,
|
||||
...parsed?.minimax,
|
||||
},
|
||||
minimax: mergeMiniMaxSettings(parsed?.minimax),
|
||||
glm: {
|
||||
...DEFAULT_LLM_SETTINGS.glm,
|
||||
...parsed?.glm,
|
||||
|
|
@ -437,7 +454,7 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
|||
case 'ollama':
|
||||
return ['llama3.2', 'llama3.1', 'mistral', 'codellama', 'deepseek-coder'];
|
||||
case 'minimax':
|
||||
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
|
||||
return [...MINIMAX_MODEL_IDS];
|
||||
case 'glm':
|
||||
return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5'];
|
||||
case 'deepseek':
|
||||
|
|
|
|||
|
|
@ -13,8 +13,12 @@
|
|||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import { NODE_TABLES, REL_TYPES } from 'gitnexus-shared';
|
||||
import type { EnrichedSearchResult, GrepResult } from '../../services/backend-client';
|
||||
import { NODE_TABLES, REL_TYPES, scoreImpactRisk, unusedAxesForImpactWalk } from 'gitnexus-shared';
|
||||
import type {
|
||||
EnrichedSearchResult,
|
||||
GrepOptions,
|
||||
GrepResponse,
|
||||
} from '../../services/backend-client';
|
||||
|
||||
/**
|
||||
* Tool names registered by createGraphRAGTools — kept in sync with each tool's `name`
|
||||
|
|
@ -44,7 +48,7 @@ export interface GraphRAGBackend {
|
|||
query: string,
|
||||
opts?: { limit?: number; mode?: 'hybrid' | 'semantic' | 'bm25'; enrich?: boolean },
|
||||
) => Promise<EnrichedSearchResult[]>;
|
||||
grep: (pattern: string, limit?: number) => Promise<GrepResult[]>;
|
||||
grep: (pattern: string, limit?: number, opts?: GrepOptions) => Promise<GrepResponse>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
}
|
||||
|
||||
|
|
@ -375,20 +379,22 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
}
|
||||
|
||||
const limit = maxResults ?? 100;
|
||||
const fullPattern = fileFilter
|
||||
? `(?=.*${fileFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}).*${pattern}`
|
||||
: pattern;
|
||||
|
||||
const results = await backendGrep(fullPattern, limit);
|
||||
const { results, timedOut } = await backendGrep(pattern, limit, {
|
||||
fileFilter,
|
||||
caseSensitive,
|
||||
});
|
||||
const timeoutMsg = timedOut
|
||||
? '\n\n(Scan timed out after a few seconds — results may be incomplete)'
|
||||
: '';
|
||||
|
||||
if (results.length === 0) {
|
||||
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}`;
|
||||
return `No matches for "${pattern}"${fileFilter ? ` in files matching "${fileFilter}"` : ''}${timeoutMsg}`;
|
||||
}
|
||||
|
||||
const formatted = results.map((r) => `${r.filePath}:${r.line}: ${r.text}`).join('\n');
|
||||
const truncatedMsg = results.length >= limit ? `\n\n(Showing first ${limit} results)` : '';
|
||||
|
||||
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}`;
|
||||
return `Found ${results.length} matches:\n\n${formatted}${truncatedMsg}${timeoutMsg}`;
|
||||
} catch (error) {
|
||||
return `Grep error: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
|
|
@ -396,16 +402,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
{
|
||||
name: 'grep',
|
||||
description:
|
||||
'Search for exact text patterns across all files using regex. Use for finding specific strings, error messages, TODOs, variable names, etc.',
|
||||
'Search file contents with a regular expression (server executes it as a real regex — alternation like "sign|Sign" works). Matches are case-insensitive unless caseSensitive is set. fileFilter keeps only files whose path contains the substring. Each call caps at maxResults matches (default 100) and the server stops after a few seconds (the tool will say so if the scan was incomplete), so prefer precise patterns over catch-alls.',
|
||||
schema: z.object({
|
||||
pattern: z
|
||||
.string()
|
||||
.describe('Regex pattern to search for (e.g., "TODO", "console\\.log", "API_KEY")'),
|
||||
.describe(
|
||||
'Regex pattern to search for (e.g., "TODO|FIXME", "console\\.log", "signOrder")',
|
||||
),
|
||||
fileFilter: z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.describe('Only search files containing this string (e.g., ".ts", "src/api")'),
|
||||
.describe(
|
||||
'Only search files whose path contains this substring (e.g., ".ts", "src/api", "Controller.java")',
|
||||
),
|
||||
caseSensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
|
@ -1219,7 +1229,7 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
const targetFileName = (targetFilePath || target).split('/').pop() || target;
|
||||
const baseName = targetFileName.replace(/\.[^/.]+$/, '');
|
||||
try {
|
||||
const hints = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
|
||||
const { results: hints } = await backendGrep(`\\b${escapeRegex(baseName)}\\b`, 15);
|
||||
const filtered = hints.filter((h) => h.filePath !== targetFilePath);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
|
|
@ -1275,6 +1285,9 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
stepCount: number | null;
|
||||
}> = [];
|
||||
let affectedClusters: Array<{ label: string; hits: number; impact: string }> = [];
|
||||
let processQueryFailed = false;
|
||||
let clusterQueryFailed = false;
|
||||
let clusterClassificationFailed = false;
|
||||
|
||||
if (trimmedIds.length > 0) {
|
||||
const processQuery = `
|
||||
|
|
@ -1302,9 +1315,23 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
: '';
|
||||
|
||||
const [processRes, clusterRes, directClusterRes] = await Promise.all([
|
||||
executeQuery(processQuery),
|
||||
executeQuery(clusterQuery),
|
||||
directClusterQuery ? executeQuery(directClusterQuery) : Promise.resolve([]),
|
||||
executeQuery(processQuery).catch((err) => {
|
||||
processQueryFailed = true;
|
||||
if (import.meta.env.DEV) console.warn('Impact process enrichment failed:', err);
|
||||
return [];
|
||||
}),
|
||||
executeQuery(clusterQuery).catch((err) => {
|
||||
clusterQueryFailed = true;
|
||||
if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err);
|
||||
return [];
|
||||
}),
|
||||
directClusterQuery
|
||||
? executeQuery(directClusterQuery).catch((err) => {
|
||||
clusterClassificationFailed = true;
|
||||
if (import.meta.env.DEV) console.warn('Impact cluster enrichment failed:', err);
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const directClusterSet = new Set<string>();
|
||||
|
|
@ -1323,7 +1350,11 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
affectedClusters = clusterRes.map((row: any) => {
|
||||
const label = Array.isArray(row) ? row[0] : row.label;
|
||||
const hits = Array.isArray(row) ? row[1] : row.hits;
|
||||
const impact = directClusterSet.has(label) ? 'direct' : 'indirect';
|
||||
const impact = clusterClassificationFailed
|
||||
? 'classification-unavailable'
|
||||
: directClusterSet.has(label)
|
||||
? 'direct'
|
||||
: 'indirect';
|
||||
return { label, hits, impact };
|
||||
});
|
||||
}
|
||||
|
|
@ -1331,19 +1362,25 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
const directCount = depth1.length;
|
||||
const processCount = affectedProcesses.length;
|
||||
const clusterCount = affectedClusters.length;
|
||||
let risk = 'LOW';
|
||||
if (directCount >= 30 || processCount >= 5 || clusterCount >= 5 || totalAffected >= 200) {
|
||||
risk = 'CRITICAL';
|
||||
} else if (
|
||||
directCount >= 15 ||
|
||||
processCount >= 3 ||
|
||||
clusterCount >= 3 ||
|
||||
totalAffected >= 100
|
||||
) {
|
||||
risk = 'HIGH';
|
||||
} else if (directCount >= 5 || totalAffected >= 30) {
|
||||
risk = 'MEDIUM';
|
||||
}
|
||||
const enrichmentCapped = allNodeIds.length > maxIdsForContext;
|
||||
const unusedAxes = unusedAxesForImpactWalk({
|
||||
isFileTarget: false,
|
||||
skipEnrichment: false,
|
||||
maxChunks: 10,
|
||||
processQueryFailed,
|
||||
moduleQueryFailed: clusterQueryFailed,
|
||||
impactedCount: totalAffected,
|
||||
enrichmentTruncated: enrichmentCapped,
|
||||
});
|
||||
const scored = scoreImpactRisk({
|
||||
direction,
|
||||
directCount,
|
||||
processCount,
|
||||
moduleCount: clusterCount,
|
||||
impactedCount: totalAffected,
|
||||
unusedAxes,
|
||||
});
|
||||
const { risk, riskSharedAxes, riskScale } = scored;
|
||||
|
||||
// ===== COMPACT TABULAR OUTPUT =====
|
||||
const lines: string[] = [
|
||||
|
|
@ -1351,22 +1388,42 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
`Confidence: High ${confidenceBuckets.high} | Medium ${confidenceBuckets.medium} | Low ${confidenceBuckets.low}`,
|
||||
``,
|
||||
`AFFECTED PROCESSES:`,
|
||||
...(affectedProcesses.length > 0
|
||||
? affectedProcesses.map(
|
||||
(p) =>
|
||||
`- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`,
|
||||
)
|
||||
: ['- None found']),
|
||||
...(processQueryFailed
|
||||
? ['- Unavailable (enrichment query failed)']
|
||||
: affectedProcesses.length > 0
|
||||
? affectedProcesses.map(
|
||||
(p) =>
|
||||
`- ${p.label} - BROKEN at step ${p.minStep ?? '?'} (${p.hits} symbols, ${p.stepCount ?? '?'} steps)`,
|
||||
)
|
||||
: ['- None found']),
|
||||
``,
|
||||
`AFFECTED CLUSTERS:`,
|
||||
...(affectedClusters.length > 0
|
||||
? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`)
|
||||
: ['- None found']),
|
||||
...(clusterQueryFailed
|
||||
? ['- Unavailable (enrichment query failed)']
|
||||
: affectedClusters.length > 0
|
||||
? affectedClusters.map((c) => `- ${c.label} (${c.impact}, ${c.hits} symbols)`)
|
||||
: ['- None found']),
|
||||
``,
|
||||
`RISK: ${risk}`,
|
||||
`RISK: ${risk} (edit gate — warn on HIGH/CRITICAL)`,
|
||||
`Shared-axes: ${riskSharedAxes} (File vs symbol compare only; do not waive a HIGH risk warning)`,
|
||||
`Note: this Graph-RAG surface expands File targets to in-file symbols before enrichment, so process/cluster axes are comparable here when enrichment succeeds. MCP File impact does not.`,
|
||||
...(riskScale.comparableAcrossKinds
|
||||
? []
|
||||
: [
|
||||
`Note: process/module axes were unused (${riskScale.unusedAxes.map((a) => a.reason).join(', ')}).`,
|
||||
]),
|
||||
...(risk === 'UNKNOWN' && (processQueryFailed || clusterQueryFailed)
|
||||
? ['Note: risk is unresolved because enrichment failed; retry before editing.']
|
||||
: []),
|
||||
...(enrichmentCapped
|
||||
? [`Note: process/cluster enrichment is partial (first ${maxIdsForContext} symbols).`]
|
||||
: []),
|
||||
...(clusterClassificationFailed
|
||||
? ['Note: direct/indirect cluster classification is unavailable.']
|
||||
: []),
|
||||
`- Direct callers: ${directCount}`,
|
||||
`- Processes affected: ${processCount}`,
|
||||
`- Clusters affected: ${clusterCount}`,
|
||||
`- Processes affected: ${processQueryFailed ? 'unavailable' : processCount}`,
|
||||
`- Clusters affected: ${clusterQueryFailed ? 'unavailable' : clusterCount}`,
|
||||
``,
|
||||
];
|
||||
|
||||
|
|
@ -1472,7 +1529,9 @@ relationTypes filter (optional):
|
|||
Additional output sections:
|
||||
- Affected processes (with step impact)
|
||||
- Affected clusters (direct/indirect)
|
||||
- Risk summary (based on direct callers, processes, clusters)`,
|
||||
- RISK is the edit gate: warn before edits on HIGH/CRITICAL; UNKNOWN requires retry or corroboration
|
||||
- Shared-axes risk compares File and symbol targets using direct/total counts only; it never waives the RISK gate
|
||||
- riskScale notes unavailable process/module axes. This Graph-RAG tool expands File targets to in-file symbols; MCP File impact does not`,
|
||||
schema: z.object({
|
||||
target: z.string().describe('Name of the function, class, or file to analyze'),
|
||||
direction: z
|
||||
|
|
|
|||
|
|
@ -20,6 +20,71 @@ export type LLMProvider =
|
|||
| 'glm'
|
||||
| 'deepseek';
|
||||
|
||||
export const MINIMAX_ANTHROPIC_BASE_URLS = {
|
||||
global_en: 'https://api.minimax.io/anthropic',
|
||||
cn_zh: 'https://api.minimaxi.com/anthropic',
|
||||
} as const;
|
||||
|
||||
export const MINIMAX_DOCS_ROOTS = {
|
||||
global_en: 'https://platform.minimax.io/docs',
|
||||
cn_zh: 'https://platform.minimaxi.com/docs',
|
||||
} as const;
|
||||
|
||||
export const MINIMAX_MODEL_IDS = ['MiniMax-M3', 'MiniMax-M2.7'] as const;
|
||||
|
||||
export type MiniMaxModelId = (typeof MINIMAX_MODEL_IDS)[number];
|
||||
export type MiniMaxThinkingMode = 'adaptive' | 'disabled' | 'always_on';
|
||||
export type MiniMaxInputModality = 'text' | 'image' | 'video';
|
||||
|
||||
export interface MiniMaxModelCapabilities {
|
||||
contextWindow: number;
|
||||
inputModalities: readonly MiniMaxInputModality[];
|
||||
thinkingModes: readonly MiniMaxThinkingMode[];
|
||||
}
|
||||
|
||||
export const MINIMAX_MODEL_CAPABILITIES: Record<MiniMaxModelId, MiniMaxModelCapabilities> = {
|
||||
'MiniMax-M3': {
|
||||
contextWindow: 1_000_000,
|
||||
inputModalities: ['text', 'image', 'video'],
|
||||
thinkingModes: ['adaptive', 'disabled'],
|
||||
},
|
||||
'MiniMax-M2.7': {
|
||||
contextWindow: 204_800,
|
||||
inputModalities: ['text'],
|
||||
thinkingModes: ['always_on'],
|
||||
},
|
||||
};
|
||||
|
||||
export const getMiniMaxModelCapabilities = (model: string): MiniMaxModelCapabilities | undefined =>
|
||||
MINIMAX_MODEL_CAPABILITIES[model as MiniMaxModelId];
|
||||
|
||||
export type MiniMaxMediaDetail = 'low' | 'default' | 'high';
|
||||
|
||||
export type MiniMaxMediaSource =
|
||||
| {
|
||||
type: 'url';
|
||||
url: string;
|
||||
detail?: MiniMaxMediaDetail;
|
||||
fps?: number;
|
||||
max_long_side_pixel?: number;
|
||||
}
|
||||
| {
|
||||
type: 'base64';
|
||||
media_type: string;
|
||||
data: string;
|
||||
detail?: MiniMaxMediaDetail;
|
||||
fps?: number;
|
||||
max_long_side_pixel?: number;
|
||||
};
|
||||
|
||||
export type AgentUserContent =
|
||||
| string
|
||||
| Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; source: MiniMaxMediaSource }
|
||||
| { type: 'video'; source: MiniMaxMediaSource }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
*/
|
||||
|
|
@ -94,7 +159,9 @@ export interface OpenRouterConfig extends BaseProviderConfig {
|
|||
export interface MiniMaxConfig extends BaseProviderConfig {
|
||||
provider: 'minimax';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
thinkingMode?: MiniMaxThinkingMode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -200,7 +267,9 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
|||
},
|
||||
minimax: {
|
||||
apiKey: '',
|
||||
model: 'MiniMax-M2.5',
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en,
|
||||
thinkingMode: 'adaptive',
|
||||
temperature: 0.1,
|
||||
},
|
||||
glm: {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
repoIdentity as repoIdentityOf,
|
||||
type BackendRepo,
|
||||
type ConnectResult,
|
||||
type GrepOptions,
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
||||
|
|
@ -671,7 +672,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const backend = {
|
||||
executeQuery,
|
||||
search: (query: string, opts?: any) => backendSearch(query, { ...opts, repo }),
|
||||
grep: (pattern: string, limit?: number) => backendGrep(pattern, repo, limit),
|
||||
grep: (pattern: string, limit?: number, opts?: GrepOptions) =>
|
||||
backendGrep(pattern, repo, limit, opts),
|
||||
readFile: (filePath: string) =>
|
||||
backendReadFile(filePath, { repo }).then((r) => r.content),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export const NODE_COLORS: Record<NodeLabel, string> = {
|
|||
Constructor: '#10b981', // Emerald - like Function
|
||||
Template: '#a78bfa', // Violet light - like Type
|
||||
Route: '#f43f5e', // Rose - like Process
|
||||
Destination: '#fb7185', // Rose light - like Route, the broker-side counterpart
|
||||
Tool: '#a855f7', // Purple - like Project
|
||||
BasicBlock: '#475569', // Slate darker - control-flow node (muted, taint/PDG substrate)
|
||||
};
|
||||
|
|
@ -79,6 +80,7 @@ export const NODE_SIZES: Record<NodeLabel, number> = {
|
|||
Constructor: 4, // Like Function
|
||||
Template: 3, // Like Type
|
||||
Route: 5, // Like Enum
|
||||
Destination: 5, // Like Route - the broker-side counterpart
|
||||
Tool: 5, // Like Enum
|
||||
BasicBlock: 2, // Tiny - control-flow node (taint/PDG substrate)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ describe('filterRepoFiles', () => {
|
|||
expect(r.droppedCount).toBe(4);
|
||||
});
|
||||
|
||||
it('excludes emitted _next output, including the Capacitor/Cordova copy', () => {
|
||||
// `.next` was listed but `_next` was not, so a mobile-wrapped Next.js app
|
||||
// uploaded its whole minified bundle against the server's caps for files
|
||||
// the analyzer then discards anyway (#3007).
|
||||
const input = [
|
||||
f('repo/android/app/src/main/assets/public/_next/static/chunks/main.js'),
|
||||
f('repo/ios/App/App/public/_next/static/chunks/framework.js'),
|
||||
f('repo/_next/static/chunks/x.js'),
|
||||
f('repo/src/index.ts'),
|
||||
f('repo/src/_nextgen/index.ts'),
|
||||
];
|
||||
const r = filterRepoFiles(input);
|
||||
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/src/_nextgen/index.ts']);
|
||||
expect(r.droppedCount).toBe(3);
|
||||
});
|
||||
|
||||
it('drops files over the per-file size cap', () => {
|
||||
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
|
||||
const r = filterRepoFiles(input);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ export const EXCLUDED_DIRS = new Set([
|
|||
'build',
|
||||
'out',
|
||||
'.next',
|
||||
// `.next` is the build CACHE, `_next` the EMITTED output — different
|
||||
// directories. A Capacitor/Cordova shell leaves the emitted bundle at
|
||||
// `<platform>/app/src/main/assets/public/_next/`, so without this the whole
|
||||
// minified tree is uploaded against the server's file/byte caps only to be
|
||||
// discarded by the analyzer's own ignore list (#3007).
|
||||
//
|
||||
// This pre-filter reads no repository ignore rules, so unlike the CLI walker
|
||||
// a `.gitnexusignore` negation cannot recover anything dropped here. Names
|
||||
// added below must therefore stay a subset of the analyzer's own list; see
|
||||
// `gitnexus/test/unit/upload-filter-ignore-drift.test.ts`.
|
||||
'_next',
|
||||
'.nuxt',
|
||||
'.cache',
|
||||
'coverage',
|
||||
|
|
|
|||
|
|
@ -76,8 +76,20 @@
|
|||
"apiKeyPlaceholder": "Enter your MiniMax API key",
|
||||
"helperText": "Get your API key from",
|
||||
"helperLinkLabel": "MiniMax Platform",
|
||||
"modelPlaceholder": "e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed",
|
||||
"helperModel": "Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)"
|
||||
"modelPlaceholder": "e.g., MiniMax-M3 or MiniMax-M2.7",
|
||||
"helperModel": "Available: MiniMax-M3 (default) and MiniMax-M2.7",
|
||||
"endpoint": "Regional endpoint",
|
||||
"endpoints": {
|
||||
"global": "Global (api.minimax.io)",
|
||||
"china": "China (api.minimaxi.com)"
|
||||
},
|
||||
"thinking": "Thinking mode",
|
||||
"thinkingModes": {
|
||||
"adaptive": "Adaptive",
|
||||
"disabled": "Disabled",
|
||||
"always_on": "Always on"
|
||||
},
|
||||
"capabilities": "{{contextWindow}} token context | Inputs: {{modalities}}"
|
||||
},
|
||||
"glm": {
|
||||
"apiKeyPlaceholder": "Enter your Z.AI API key"
|
||||
|
|
|
|||
|
|
@ -76,8 +76,20 @@
|
|||
"apiKeyPlaceholder": "输入 MiniMax API Key",
|
||||
"helperText": "从这里获取 API Key:",
|
||||
"helperLinkLabel": "MiniMax Platform",
|
||||
"modelPlaceholder": "例如:MiniMax-M2.5、MiniMax-M2.5-highspeed",
|
||||
"helperModel": "可用:MiniMax-M2.5(默认)、MiniMax-M2.5-highspeed(更快)"
|
||||
"modelPlaceholder": "例如:MiniMax-M3 或 MiniMax-M2.7",
|
||||
"helperModel": "可用:MiniMax-M3(默认)和 MiniMax-M2.7",
|
||||
"endpoint": "区域端点",
|
||||
"endpoints": {
|
||||
"global": "全球(api.minimax.io)",
|
||||
"china": "中国(api.minimaxi.com)"
|
||||
},
|
||||
"thinking": "思考模式",
|
||||
"thinkingModes": {
|
||||
"adaptive": "自适应",
|
||||
"disabled": "关闭",
|
||||
"always_on": "始终开启"
|
||||
},
|
||||
"capabilities": "{{contextWindow}} token 上下文 | 输入:{{modalities}}"
|
||||
},
|
||||
"glm": {
|
||||
"apiKeyPlaceholder": "输入 Z.AI API Key"
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ export interface GrepResult {
|
|||
text: string;
|
||||
}
|
||||
|
||||
/** Full `/api/grep` payload — `timedOut` is true when the 5s budget cut the scan short. */
|
||||
export interface GrepResponse {
|
||||
results: GrepResult[];
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
export interface JobProgress {
|
||||
phase: string;
|
||||
percent: number;
|
||||
|
|
@ -869,23 +875,37 @@ export const search = async (
|
|||
return (body.results ?? []) as EnrichedSearchResult[];
|
||||
};
|
||||
|
||||
/** Grep across file contents in the indexed repo. */
|
||||
/** Options for {@link grep} beyond pattern/repo/limit. */
|
||||
export interface GrepOptions {
|
||||
/** Only search files whose path contains this substring (case-insensitive). */
|
||||
fileFilter?: string | null;
|
||||
/** Case-sensitive matching (default: insensitive). */
|
||||
caseSensitive?: boolean;
|
||||
}
|
||||
|
||||
/** Grep across file contents in the indexed repo. Regex semantics server-side. */
|
||||
export const grep = async (
|
||||
pattern: string,
|
||||
repo?: string,
|
||||
limit?: number,
|
||||
): Promise<GrepResult[]> => {
|
||||
opts?: GrepOptions,
|
||||
): Promise<GrepResponse> => {
|
||||
const params = [
|
||||
`pattern=${encodeURIComponent(pattern)}`,
|
||||
repoParam(repo),
|
||||
limit ? `limit=${limit}` : '',
|
||||
opts?.fileFilter ? `fileFilter=${encodeURIComponent(opts.fileFilter)}` : '',
|
||||
opts?.caseSensitive ? 'caseSensitive=1' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('&');
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/grep?${params}`);
|
||||
await assertOk(response);
|
||||
const body = await response.json();
|
||||
return (body.results ?? []) as GrepResult[];
|
||||
const body = (await response.json()) as Partial<GrepResponse>;
|
||||
return {
|
||||
results: body.results ?? [],
|
||||
timedOut: body.timedOut === true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Result from reading a file, optionally with line range. */
|
||||
|
|
|
|||
|
|
@ -95,3 +95,34 @@ describe('streamAgentResponse abort', () => {
|
|||
expect(chunks).toEqual([{ type: 'error', error: 'Cannot abort the current transaction' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamAgentResponse content blocks', () => {
|
||||
const userMessage: AgentMessage[] = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
it('emits thinking blocks as reasoning', async () => {
|
||||
const agent = {
|
||||
stream: async function* () {
|
||||
yield [
|
||||
'messages',
|
||||
[
|
||||
{
|
||||
_getType: () => 'ai',
|
||||
content: [{ type: 'thinking', thinking: 'Reviewing the repository context.' }],
|
||||
tool_calls: [],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of streamAgentResponse(agent as any, userMessage)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'reasoning', reasoning: 'Reviewing the repository context.' },
|
||||
{ type: 'done', historyMessages: undefined },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
DeepSeekChatOpenAI,
|
||||
DeepSeekChatOpenAICompletions,
|
||||
} from '../../src/core/llm/deepseek-chat-model';
|
||||
import { MINIMAX_ANTHROPIC_BASE_URLS, MINIMAX_MODEL_IDS } from '../../src/core/llm/types';
|
||||
|
||||
describe('buildLangChainMessages', () => {
|
||||
it('reconstructs assistant tool-call turns for replay', () => {
|
||||
|
|
@ -50,6 +51,24 @@ describe('buildLangChainMessages', () => {
|
|||
]);
|
||||
expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather');
|
||||
});
|
||||
|
||||
it('preserves MiniMax image and video content blocks', () => {
|
||||
const content = [
|
||||
{ type: 'text' as const, text: 'Compare these inputs.' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: { type: 'url' as const, url: 'https://example.com/image.png' },
|
||||
},
|
||||
{
|
||||
type: 'video' as const,
|
||||
source: { type: 'url' as const, url: 'https://example.com/video.mp4', fps: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const [message] = buildLangChainMessages([{ role: 'user', content }]);
|
||||
|
||||
expect((message as any).content).toEqual(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeAgentHistoryMessages', () => {
|
||||
|
|
@ -206,6 +225,48 @@ it('drops reasoningContent from serialized assistant messages without tool calls
|
|||
});
|
||||
|
||||
describe('createChatModel', () => {
|
||||
it('configures MiniMax-M3 adaptive thinking on the China endpoint', () => {
|
||||
const model = createChatModel({
|
||||
provider: 'minimax',
|
||||
apiKey: 'minimax-test-key',
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh,
|
||||
thinkingMode: 'adaptive',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
|
||||
expect(model.model).toBe(MINIMAX_MODEL_IDS[0]);
|
||||
expect(model.clientOptions.baseURL).toBe(MINIMAX_ANTHROPIC_BASE_URLS.cn_zh);
|
||||
expect(model.thinking).toEqual({ type: 'adaptive' });
|
||||
expect(model.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports disabled thinking for MiniMax-M3', () => {
|
||||
const model = createChatModel({
|
||||
provider: 'minimax',
|
||||
apiKey: 'minimax-test-key',
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
thinkingMode: 'disabled',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
|
||||
expect(model.thinking).toEqual({ type: 'disabled' });
|
||||
expect(model.temperature).toBe(0.1);
|
||||
});
|
||||
|
||||
it('keeps MiniMax-M2.7 thinking always on', () => {
|
||||
const model = createChatModel({
|
||||
provider: 'minimax',
|
||||
apiKey: 'minimax-test-key',
|
||||
model: MINIMAX_MODEL_IDS[1],
|
||||
thinkingMode: 'disabled',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
|
||||
expect(model.invocationParams({}).thinking).toBeUndefined();
|
||||
expect(model.temperature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => {
|
||||
const model = createChatModel({
|
||||
provider: 'deepseek',
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const FORBIDDEN_TOOL_NAMES = [
|
|||
const stubBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
|
|
|
|||
75
gitnexus-web/test/unit/backend-client-grep.test.ts
Normal file
75
gitnexus-web/test/unit/backend-client-grep.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* `/api/grep` client: query params and `timedOut` must reach callers.
|
||||
* Dropping `timedOut` made a 5s partial scan look like a complete miss.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import { grep, setBackendUrl } from '../../src/services/backend-client';
|
||||
|
||||
const BASE = 'http://grep-client.test:4747';
|
||||
|
||||
const jsonOk = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('backend-client grep', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('forwards fileFilter and caseSensitive and returns timedOut', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
expect(url).toContain('/api/grep?');
|
||||
expect(url).toContain(`pattern=${encodeURIComponent('sign|Sign')}`);
|
||||
expect(url).toContain(`fileFilter=${encodeURIComponent('src/api')}`);
|
||||
expect(url).toContain('caseSensitive=1');
|
||||
expect(url).toContain('limit=12');
|
||||
return jsonOk({
|
||||
results: [{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }],
|
||||
timedOut: true,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const body = await grep('sign|Sign', '/repo', 12, {
|
||||
fileFilter: 'src/api',
|
||||
caseSensitive: true,
|
||||
});
|
||||
expect(body.results).toEqual([{ filePath: 'src/api.ts', line: 3, text: 'signOrder()' }]);
|
||||
expect(body.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('reports timedOut false when the server completed the scan', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
return jsonOk({ results: [] });
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await grep('TODO');
|
||||
expect(body).toEqual({ results: [], timedOut: false });
|
||||
});
|
||||
|
||||
it('does not send fileFilter when it is null or empty', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
expect(url).not.toContain('fileFilter=');
|
||||
return jsonOk({ results: [] });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
for (const fileFilter of ['', null] as const) {
|
||||
await grep('x', undefined, undefined, { fileFilter });
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
36
gitnexus-web/test/unit/grep-tool.test.ts
Normal file
36
gitnexus-web/test/unit/grep-tool.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
|
||||
|
||||
const noOpBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
function grepTool(backend: GraphRAGBackend) {
|
||||
return createGraphRAGTools(backend).find((candidate) => candidate.name === 'grep')!;
|
||||
}
|
||||
|
||||
describe('grep tool timeout contract', () => {
|
||||
it('says the scan was incomplete when the server sets timedOut with no hits', async () => {
|
||||
const grep = vi.fn(async () => ({ results: [], timedOut: true }));
|
||||
const output = await grepTool({ ...noOpBackend, grep }).invoke({ pattern: 'signOrder' });
|
||||
expect(output).toContain('No matches for "signOrder"');
|
||||
expect(output).toContain('results may be incomplete');
|
||||
});
|
||||
|
||||
it('still warns when a timed-out scan returned some hits below the limit', async () => {
|
||||
const grep = vi.fn(async () => ({
|
||||
results: [{ filePath: 'a.ts', line: 1, text: 'signOrder()' }],
|
||||
timedOut: true,
|
||||
}));
|
||||
const output = await grepTool({ ...noOpBackend, grep }).invoke({
|
||||
pattern: 'signOrder',
|
||||
maxResults: 100,
|
||||
});
|
||||
expect(output).toContain('Found 1 matches');
|
||||
expect(output).toContain('results may be incomplete');
|
||||
expect(output).not.toContain('Showing first');
|
||||
});
|
||||
});
|
||||
209
gitnexus-web/test/unit/impact-tool.test.ts
Normal file
209
gitnexus-web/test/unit/impact-tool.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createGraphRAGTools, type GraphRAGBackend } from '../../src/core/llm/tools';
|
||||
|
||||
const noOpBackend: GraphRAGBackend = {
|
||||
executeQuery: async () => [],
|
||||
search: async () => [],
|
||||
grep: async () => ({ results: [], timedOut: false }),
|
||||
readFile: async () => '',
|
||||
};
|
||||
|
||||
function impactTool(backend: GraphRAGBackend) {
|
||||
return createGraphRAGTools(backend).find((candidate) => candidate.name === 'impact')!;
|
||||
}
|
||||
|
||||
describe('Graph-RAG impact risk contract', () => {
|
||||
it('advertises the edit gate, shared axes, and MCP File difference', () => {
|
||||
const description = impactTool(noOpBackend).description;
|
||||
expect(description).toContain('RISK is the edit gate');
|
||||
expect(description).toContain('Shared-axes risk');
|
||||
expect(description).toContain('riskScale');
|
||||
expect(description).toContain('MCP File impact does not');
|
||||
});
|
||||
|
||||
it('renders failed enrichment as unavailable and fails the risk gate closed', async () => {
|
||||
const executeQuery = vi.fn(async (query: string) => {
|
||||
if (query.includes("WHERE n.name = 'target'")) {
|
||||
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
|
||||
}
|
||||
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
|
||||
return [
|
||||
{
|
||||
id: 'caller-id',
|
||||
name: 'caller',
|
||||
nodeType: 'Function',
|
||||
filePath: 'src/caller.ts',
|
||||
startLine: 4,
|
||||
edgeType: 'CALLS',
|
||||
confidence: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS')) throw new Error('process query failed');
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
|
||||
target: 'target',
|
||||
direction: 'upstream',
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
expect(output).toContain('AFFECTED PROCESSES:\n- Unavailable (enrichment query failed)');
|
||||
expect(output).not.toContain('AFFECTED PROCESSES:\n- None found');
|
||||
expect(output).toContain('RISK: UNKNOWN');
|
||||
expect(output).toContain('risk is unresolved because enrichment failed');
|
||||
expect(output).toContain('- Processes affected: unavailable');
|
||||
});
|
||||
|
||||
it('preserves proved CRITICAL risk when the cluster query fails', async () => {
|
||||
const executeQuery = vi.fn(async (query: string) => {
|
||||
if (query.includes("WHERE n.name = 'target'")) {
|
||||
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
|
||||
}
|
||||
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
|
||||
return [
|
||||
{
|
||||
id: 'caller-id',
|
||||
name: 'caller',
|
||||
nodeType: 'Function',
|
||||
filePath: 'src/caller.ts',
|
||||
edgeType: 'CALLS',
|
||||
confidence: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS')) {
|
||||
return Array.from({ length: 5 }, (_, index) => ({
|
||||
label: `process-${index}`,
|
||||
hits: 1,
|
||||
minStep: index + 1,
|
||||
stepCount: 5,
|
||||
}));
|
||||
}
|
||||
if (query.includes('MEMBER_OF') && query.includes('COUNT(DISTINCT s.id)')) {
|
||||
throw new Error('cluster query failed');
|
||||
}
|
||||
if (query.includes('MEMBER_OF')) return [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
|
||||
target: 'target',
|
||||
direction: 'upstream',
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
expect(output).toContain('RISK: CRITICAL');
|
||||
expect(output).toContain('AFFECTED CLUSTERS:\n- Unavailable (enrichment query failed)');
|
||||
expect(output).toContain('- Processes affected: 5');
|
||||
expect(output).toContain('- Clusters affected: unavailable');
|
||||
});
|
||||
|
||||
it('does not invent direct/indirect cluster classification after its query fails', async () => {
|
||||
const executeQuery = vi.fn(async (query: string) => {
|
||||
if (query.includes("WHERE n.name = 'target'")) {
|
||||
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
|
||||
}
|
||||
if (query.includes('MATCH (affected)-[r:CodeRelation]->(target)')) {
|
||||
return [
|
||||
{
|
||||
id: 'caller-id',
|
||||
name: 'caller',
|
||||
nodeType: 'Function',
|
||||
filePath: 'src/caller.ts',
|
||||
edgeType: 'CALLS',
|
||||
confidence: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) {
|
||||
throw new Error('classification query failed');
|
||||
}
|
||||
if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
|
||||
target: 'target',
|
||||
direction: 'upstream',
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
expect(output).toContain('- Core (classification-unavailable, 1 symbols)');
|
||||
expect(output).toContain('direct/indirect cluster classification is unavailable');
|
||||
expect(output).not.toContain('process/module axes were unused');
|
||||
});
|
||||
|
||||
it('treats successful File expansion as comparable because enrichment runs on member symbols', async () => {
|
||||
const executeQuery = vi.fn(async (query: string) => {
|
||||
if (query.includes("n.filePath CONTAINS 'src/target.ts'")) {
|
||||
return [{ id: 'file-id', nodeType: 'File', filePath: 'src/target.ts' }];
|
||||
}
|
||||
if (query.includes("callee.filePath = 'src/target.ts'")) {
|
||||
return [
|
||||
{
|
||||
id: 'caller-id',
|
||||
name: 'caller',
|
||||
nodeType: 'Function',
|
||||
filePath: 'src/caller.ts',
|
||||
edgeType: 'CALLS',
|
||||
confidence: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS')) {
|
||||
return [{ label: 'Build', hits: 1, minStep: 1, stepCount: 1 }];
|
||||
}
|
||||
if (query.includes('MEMBER_OF') && query.includes('RETURN DISTINCT')) {
|
||||
return [{ label: 'Core' }];
|
||||
}
|
||||
if (query.includes('MEMBER_OF')) return [{ label: 'Core', hits: 1 }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
|
||||
target: 'src/target.ts',
|
||||
direction: 'upstream',
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
expect(output).toContain('process/cluster axes are comparable here when enrichment succeeds');
|
||||
expect(output).toContain('- Processes affected: 1');
|
||||
expect(output).toContain('- Clusters affected: 1');
|
||||
expect(output).not.toContain('process/module axes were unused');
|
||||
});
|
||||
|
||||
it('surfaces the 500-symbol enrichment cap as partial', async () => {
|
||||
const executeQuery = vi.fn(async (query: string) => {
|
||||
if (query.includes("WHERE n.name = 'target'")) {
|
||||
return [{ id: 'target-id', nodeType: 'Function', filePath: 'src/target.ts' }];
|
||||
}
|
||||
const depth = query.includes('3 AS depth') ? 3 : query.includes('2 AS depth') ? 2 : 1;
|
||||
if (query.includes('CodeRelation') && query.includes(` ${depth} AS depth`)) {
|
||||
return Array.from({ length: 200 }, (_, index) => ({
|
||||
id: `d${depth}-${index}`,
|
||||
name: `node-${depth}-${index}`,
|
||||
nodeType: 'Function',
|
||||
filePath: `src/d${depth}-${index}.ts`,
|
||||
edgeType: 'CALLS',
|
||||
confidence: 1,
|
||||
}));
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS') || query.includes('MEMBER_OF')) return [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const output = await impactTool({ ...noOpBackend, executeQuery }).invoke({
|
||||
target: 'target',
|
||||
direction: 'upstream',
|
||||
maxDepth: 3,
|
||||
});
|
||||
|
||||
expect(output).toContain('process/cluster enrichment is partial (first 500 symbols)');
|
||||
expect(output).toContain('enrichment-truncated');
|
||||
expect(output).not.toContain('enrichment-budget-exhausted');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,12 @@ import {
|
|||
getAvailableModels,
|
||||
getProviderCapabilities,
|
||||
} from '../../src/core/llm/settings-service';
|
||||
import {
|
||||
getMiniMaxModelCapabilities,
|
||||
MINIMAX_ANTHROPIC_BASE_URLS,
|
||||
MINIMAX_MODEL_IDS,
|
||||
} from '../../src/core/llm/types';
|
||||
import { createChatModel } from '../../src/core/llm/agent';
|
||||
|
||||
describe('loadSettings', () => {
|
||||
it('returns defaults when nothing is stored', () => {
|
||||
|
|
@ -17,6 +23,11 @@ describe('loadSettings', () => {
|
|||
expect(settings.activeProvider).toBeDefined();
|
||||
expect(settings.openai).toBeDefined();
|
||||
expect(settings.ollama).toBeDefined();
|
||||
expect(settings.minimax).toMatchObject({
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en,
|
||||
thinkingMode: 'adaptive',
|
||||
});
|
||||
});
|
||||
|
||||
it('merges stored values with defaults', () => {
|
||||
|
|
@ -35,6 +46,30 @@ describe('loadSettings', () => {
|
|||
expect(settings.openai).toBeDefined();
|
||||
});
|
||||
|
||||
it('migrates unsupported legacy MiniMax models to the current default', () => {
|
||||
sessionStorage.setItem(
|
||||
'gitnexus-llm-settings',
|
||||
JSON.stringify({
|
||||
activeProvider: 'minimax',
|
||||
minimax: {
|
||||
apiKey: 'minimax-test-key',
|
||||
model: 'MiniMax-M2.5',
|
||||
temperature: 0.1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const settings = loadSettings();
|
||||
expect(settings.minimax).toMatchObject({
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
thinkingMode: 'adaptive',
|
||||
});
|
||||
|
||||
const model = createChatModel(getActiveProviderConfig()!) as any;
|
||||
expect(model.model).toBe(MINIMAX_MODEL_IDS[0]);
|
||||
expect(model.thinking).toEqual({ type: 'adaptive' });
|
||||
});
|
||||
|
||||
it('returns defaults on corrupted JSON', () => {
|
||||
sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{');
|
||||
const settings = loadSettings();
|
||||
|
|
@ -116,6 +151,26 @@ describe('getActiveProviderConfig', () => {
|
|||
expect(config!.provider).toBe('deepseek');
|
||||
});
|
||||
|
||||
it('returns the regional endpoint and thinking mode for MiniMax', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'minimax';
|
||||
settings.minimax = {
|
||||
...settings.minimax,
|
||||
apiKey: 'minimax-test-key',
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh,
|
||||
thinkingMode: 'disabled',
|
||||
};
|
||||
saveSettings(settings);
|
||||
|
||||
expect(getActiveProviderConfig()).toMatchObject({
|
||||
provider: 'minimax',
|
||||
model: MINIMAX_MODEL_IDS[0],
|
||||
baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh,
|
||||
thinkingMode: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for openrouter with empty API key', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'openrouter';
|
||||
|
|
@ -161,6 +216,20 @@ describe('getAvailableModels', () => {
|
|||
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
|
||||
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
|
||||
expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash');
|
||||
expect(getAvailableModels('minimax')).toEqual([...MINIMAX_MODEL_IDS]);
|
||||
});
|
||||
|
||||
it('describes MiniMax model input and thinking capabilities', () => {
|
||||
expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[0])).toEqual({
|
||||
contextWindow: 1_000_000,
|
||||
inputModalities: ['text', 'image', 'video'],
|
||||
thinkingModes: ['adaptive', 'disabled'],
|
||||
});
|
||||
expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[1])).toEqual({
|
||||
contextWindow: 204_800,
|
||||
inputModalities: ['text'],
|
||||
thinkingModes: ['always_on'],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array for unknown provider', () => {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,97 @@ All notable changes to GitNexus will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.6.10] - 2026-08-27
|
||||
|
||||
### Added
|
||||
|
||||
- **Spring framework modeling expanded end to end** — AOP transactions, caching and security (#2783), `@Bean` factories and `@Resource` injection (#2740), profiles/conditions/auto-configuration (#2678), constructor and standard injection (#2632), bean candidate inventory (#2494), configuration-property consumers, and non-HTTP handler entry points (#2891)
|
||||
- **Receiver chains typed from AST structure across all 14 languages**, with an explicit epistemic lower bound on what the graph can claim (#2708, #2744, #2747)
|
||||
- **Java enum constant bodies modeled as first-class instances**, with JLS 13.1 anonymous-class naming (#2558)
|
||||
- **More route surfaces indexed** — Java constant-based route paths such as `@PostMapping(ApiPathConstants.X)` (#2980) and JavaScript data route tables (#2972)
|
||||
- **MCP server hardening** — repository allowlist, fail-closed read-only mode, deterministic output budgets, and normalized `impact`/`context` aliases
|
||||
- **`bunx` lane so bun-only machines can run GitNexus** (#2765)
|
||||
- **Codex support** — hooks, plugin marketplace and setup (#2328, #2369) — plus CodeBuddy and Qoder coding-agent integrations (#2368)
|
||||
- **Skills mirrored to `.agents/skills/`** when an `.agents/` directory exists
|
||||
- **One-click Render deploy** (#2804)
|
||||
- **`serve` origin/proxy configuration is validated and port-scoped** (#2820)
|
||||
- **Expanded TypeScript/JavaScript taint sink model** (#2490)
|
||||
- **Wiki generation accepts explicit HTTP LLM hosts** (#2491)
|
||||
- **Embedding request-body dimensions configurable** via `GITNEXUS_EMBEDDING_REQUEST_DIMS` (#2574)
|
||||
- **Refreshed MiniMax model and endpoint configuration** (#2780)
|
||||
- **`MAX_CALLABLE_VALUE_TARGETS` and `MAX_PROPERTY_DISPATCH_FANOUT` configurable via env** (#2725, #2726)
|
||||
- **Opt-in `analyze --self-commit`** for AGENTS.md/CLAUDE.md churn (#2640)
|
||||
- **Buffer pool sized to the graph before the database opens**, with an adaptive size hint
|
||||
- **CI review agent runs as a coordinated reviewer swarm** on Sonnet 5 with structured, linked reviews (#2570, #2572), alongside the GitNexus Engineering Tool Kit skills (#2566) and an online skill-evolution loop (#2571)
|
||||
- **Icebug community-engine prototype behind a gate** (#2376)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`group sync` stops claiming matching it never did** — the advertised BM25/embedding cascade was config, help text and MCP schema with no matcher behind it; the unread `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` and `--skip-embeddings` surfaces are removed (#3020)
|
||||
- **Emitted Next.js build output is ignored during ingestion**, and the inert `public/build` entry is deleted (#3018)
|
||||
- **NestJS decorator routes are indexed** so `api_impact` and `route_map` stop reporting live endpoints as non-existent (#3017)
|
||||
- **Import resolution gated by real module configuration** instead of path-suffix guessing — TypeScript config (#2953, #2956), Java and Kotlin declared packages (#2955, #2990), Go module paths (#2984), PHP Composer autoload maps (#2987), Python `__init__.py` re-exports (#2864) and unaliased dotted namespace imports (#2826, #2828), and JavaScript module extensions (#3034)
|
||||
- **Interface dispatch is generic-instantiation aware** (#2912, #2939), fans out from Case 3b receivers (#2832, #2842) and from C# record interface calls (#2904), and resolves through generic-typed field receivers in every language (#2833, #2855)
|
||||
- **Go method sets modeled exactly** so interface satisfaction is decidable (#2813, #2829), out-of-repo package qualifiers resolve, and an undecided interface check is no longer reported as a decided negative (#2873, #2921)
|
||||
- **Go pointer-receiver calls resolve**, reporting the program boundary instead of hedging (#2766, #2782)
|
||||
- **Java record support** — graph nodes for `record_declaration`, component accessors, enum and record interface heritage (#2564, #2916, #2935, #2936), plus `E.CONST.method()` enum-constant receiver dispatch (#2561) and JLS binary-name identities for local classes, enums, records and interfaces (#2562, #2653)
|
||||
- **Rust module-qualified calls resolve against the module tree** (#2730, #2741), items are qualified by their enclosing `mod` chain (#2742, #2745), duplicate type names stay ambiguous in range binding (#2514, #2652), and `Box<dyn Trait>` names normalize
|
||||
- **Closure bindings are call sources in every language**, and function-local values carry their own identity (#2693, #2695, #2699, #2718)
|
||||
- **A named receiver's member never resolves lexically** (#2714), platform builtins stop resolving to unrelated same-file symbols (#2549), and inline constructor receivers are typed in every spelling (#2708, #2737)
|
||||
- **Python calls resolve through constructor-injected fields** (#2628) and module-imported classes (#2770)
|
||||
- **Package directories that repeat higher in the path resolve correctly** (#2881, #2929)
|
||||
- **`check` stops reporting erased and deferred imports as initialization cycles** (#2934)
|
||||
- **`detect_changes` no longer scales its query with the diff's hunk count** (#2915, #2930), and CR-only line-ending diffs are ignored (#2839)
|
||||
- **`group` stops reporting what could not be measured as a measurement of zero** (#3012), resolves HTTP consumers through configured clients and constant route tables (#3008), and preserves manifest-only impact crossings (#2784)
|
||||
- **`impact` and `context` are reproducible** — deterministic ordering on every capped query (#2787, #2796) — and Convex caller results are marked incomplete rather than empty (#3044)
|
||||
- **Object handler identity is preserved** during ingestion (#3046), nested source directories are discovered (#3043), and parse-node insertion is canonicalized
|
||||
- **Large-repo analyze OOM and the false worker-timeout cascade are fixed** (#2649, #2679)
|
||||
- **Single-writer lock on the index write path** (#2658, #2677), atomic index swap with read-pool staleness invalidation (#2614), and reliable large incremental writeback commits (#2409, #2425)
|
||||
- **Remote URLs are stripped of credentials before they are persisted** (#2914, #2928), and every registry write gets its own tmp path (#2888, #2920)
|
||||
- **Schema version derived from a DDL fingerprint** instead of a hand-incremented constant (#2798, #2808), and the scope-resolution relation cross product is fully declared (#2792, #2793)
|
||||
- **FTS reliability** — binary payloads stay out of the description column and an unbuildable index is confined to its own table (#2919), FTS-indexed DML is gated before the incremental writeback (#2841, #2854), analyze degrades instead of aborting on index-build failure (#2548), real LOAD errors surface and broken extension files self-heal (#2374, #2375), and Windows missing-dependency load failures are diagnosed (#2383)
|
||||
- **`VECTOR` is loaded only when needed** (#3045) and before the incremental writeback touches embedding rows (#2623, #2624)
|
||||
- **Buffer pool bounded instead of taking the native 80%-of-RAM default** (#2560), scaled by the OS page-size granule ratio (#2631, #2636), with a COPY-safe floor and actionable diagnostics for non-4K page sizes (#2424)
|
||||
- **`Napi::Error` SIGABRT on analyze eliminated** — C++ type lookups are indexed and workers terminate only at JS-safe points (#2432, #2436)
|
||||
- **Native-load failures fail closed**, including truncated-binary SIGBUS (#2441, #2651), and glibc-too-old loads are no longer misdiagnosed (#2672, #2689)
|
||||
- **Index staleness reporting fixed** — no false-stale status after analyze, with inline staleness in `query`/`context`/`impact`/`cypher` (#2655, #2668, #2683)
|
||||
- **Windows path handling** — the `\\?\` long-path prefix no longer breaks repo path matching (#2667, #2700), `parts` negation is honored (#2720), and missing-shadow errors let `serve` repo-switch recover (#2382, #2387)
|
||||
- **Embeddings survive partial failures** — unparseable 200 responses are retried (#2790, #2795), batch inserts are retry-safe (#2453), HTTP generation is resumable, resume checkpoints bind to their provider, and proxy-blocked installs self-heal (#2370, #2372)
|
||||
- **Custom HTTP embedding endpoint failures are reported as themselves**, not as Hugging Face download errors (#2385, #2386)
|
||||
- **Exact symbol content with 0-based line storage and 1-based MCP display** (#2377, #2379, #2380)
|
||||
- **`rename` reports every edit that apply writes** and reconciles its report on partial failure (#2605)
|
||||
- **Global registry transactions serialized across processes** (#2716)
|
||||
- **Swift indented conditional directives are preprocessed** so class bodies survive parsing (#2771), and Swift member-containment pairs are declared in the `CONTAINS` DDL (#2769)
|
||||
- **JavaScript `exports.foo = function () {}` CommonJS exports are indexed** (#2723, #2729), and `const X = () => {}` is no longer double-indexed as a Function plus an edgeless Const twin (#2687, #2691)
|
||||
- **JVM sibling injection is proximity-bounded** (#2732), and C#/Kotlin free calls are gated by instance ownership (#2563, #2654)
|
||||
- **Dart extension type symbols are extracted** (#2539), and declarations recover after embedded NUL bytes (#2430)
|
||||
- **CLI and hooks fail loudly on backend error payloads**, with an MCP query hint when the server owns the DB lock (#2396, #2397)
|
||||
- **Committed agent guides stop churning**, with an `--index-only` nudge (#2907, #2927), and `gitnexus-plan` artifacts publish on macOS without an interpreter (#2905, #2922)
|
||||
- **The 300-flows cap is removed for large repositories** (#2198)
|
||||
|
||||
### Changed
|
||||
|
||||
- **BREAKING: Node `^22.18.0 || >=24.11.0` is now the supported floor**; the `@types/uuid` stub is dropped
|
||||
- **BREAKING: the non-functional `group` matching knobs are gone** — `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` in `group.yaml`, the `gitnexus group sync --skip-embeddings` flag, and the MCP `group_sync` `skipEmbeddings` argument (#3020)
|
||||
- **Structural relationships are held out of the JS heap by default** during analyze (#2680, #2685)
|
||||
- **Global ignore support** — `core.excludesFile`, `.git/info/exclude`, and a user-level global ignore file are honored (#2606)
|
||||
- **Plugin manifests sync on every version bump** (#2445), and planning output under `docs/plans` is no longer tracked
|
||||
|
||||
### Performance
|
||||
|
||||
- **Import resolution indexed instead of scanned** — every scanning resolver with a consolidated memo (#2911), a per-run workspace index for Go/C#/Dart/Ruby (#2898), and Kotlin import resolution (#2872)
|
||||
- **MCP server startup drops the analyze-only language-provider closure** (#2802, #2806)
|
||||
- **C++ qualified namespace members indexed once per pipeline run** (#2788, #2794)
|
||||
- **Vendored Leiden O(communities × N) copy removed**, with Icebug wired to its real API (#2337, #2692)
|
||||
- **`core.excludesFile` / `info/exclude` resolution memoized** (#2606)
|
||||
|
||||
### Chore / Dependencies
|
||||
|
||||
- **`@ladybugdb/core` bumped to ^0.18.3** for the rel-property IN-predicate fix (#2508, #2634)
|
||||
- **Security overrides** — `sharp` >=0.35.0 for libvips vulnerabilities (#2993) and `adm-zip` >=0.6.0 for a memory-allocation vulnerability (#2992)
|
||||
- **~130 dependency bumps** across the CLI, web app and GitHub Actions, including `@modelcontextprotocol/sdk`, LangChain, Vite, Vitest, TypeScript, React and the Docker/CodeQL action suite
|
||||
- **CI hardening** — Windows shard watchdog widened with exit diagnostics (#2449), platform-sensitive matrix sharded to fix the Windows cross-platform timeout (#2394), and CI Report no longer dies silently when the tests job fails (#2728)
|
||||
|
||||
## [1.6.9] - 2026-07-04
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|||
| `group_list` | List configured repository groups |
|
||||
| `group_sync` | Rebuild a group's Contract Registry and cross-repo links |
|
||||
|
||||
> With one indexed repo, the `repo` param is optional. With multiple, specify which: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
> Read-only tools can omit `repo` when one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing into an unindexed nested Git checkout. Otherwise—and for mutating tools with multiple indexed repos and no MCP default—specify it explicitly: `query({search_query: "auth", repo: "my-app"})`. Per-repo tools also take an optional `branch` for indexes pinned with `gitnexus analyze --branch`; omitting it queries the workspace index, which follows your checked-out working tree. `explain` and `pdg_query` need an index built with `gitnexus analyze --pdg`.
|
||||
|
||||
## MCP Resources
|
||||
|
||||
|
|
@ -234,19 +234,22 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|||
gitnexus setup # Configure MCP for detected editors (one-time; use -c to select)
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze [path] --watch # Watch local files and serialize incremental refreshes
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus embeddings install # Fetch the optional local embedding stack on demand (--cuda, --force)
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
|
||||
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits (does not skip standard skills; use --skip-skills; community --skills files are unaffected)
|
||||
gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files
|
||||
gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16)
|
||||
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI
|
||||
gitnexus index # Register an existing .gitnexus/ folder into the global registry
|
||||
|
|
@ -256,6 +259,7 @@ gitnexus clean # Delete index for current repo
|
|||
gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: minimax/minimax-m2.5)
|
||||
gitnexus wiki --provider grok # Local Grok Build CLI (uses `grok login`, no API key)
|
||||
gitnexus wiki --base-url http://llama-box.local:8080/v1 --allow-insecure-connection llama-box.local
|
||||
# Allow an exact LAN/self-hosted HTTP LLM host; env: GITNEXUS_ALLOW_INSECURE_CONNECTION
|
||||
gitnexus doctor # Show runtime platform capabilities and embedding configuration
|
||||
|
|
@ -281,6 +285,70 @@ gitnexus group status <name> # Check staleness of repos in a group
|
|||
gitnexus group impact <name> --target <symbol> --repo <groupPath> # Cross-repo blast radius
|
||||
```
|
||||
|
||||
`gitnexus analyze --watch` requires a Git repository. It performs an initial
|
||||
analysis and then debounces scanner-admitted working-tree changes for 300 ms by
|
||||
default into serialized incremental refreshes. Events arriving during a run
|
||||
remain queued, and retryable failures retain the same batch with bounded
|
||||
backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
|
||||
until the control file is fixed. Watch refreshes update only the graph: they
|
||||
intentionally skip AGENTS.md / CLAUDE.md injection and standard skill
|
||||
installation. Run a one-shot `gitnexus analyze` when those generated files need
|
||||
updating. Stop watch mode with Ctrl+C.
|
||||
|
||||
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
|
||||
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
|
||||
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
|
||||
embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`,
|
||||
`--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`
|
||||
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a warning.
|
||||
|
||||
POSIX requests clone-first copy-and-swap publication when the live index has no
|
||||
orphan sidecars. Windows and sidecar fallback runs update in place: failures
|
||||
known to occur before writes are retried, while a failure that may have mutated
|
||||
the live index stops the watcher. Watch mode does not pull remotes. Running MCP
|
||||
and `serve` processes periodically check for a newly published index and reopen
|
||||
it without a restart. MCP checks are throttled to once every five seconds, so a
|
||||
tool call before the next check can briefly use the previous index.
|
||||
|
||||
### `gitnexus auto-sync`
|
||||
|
||||
`gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop.<ownerId>.json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`.
|
||||
|
||||
```yaml
|
||||
sync_interval_minutes: 10
|
||||
max_concurrency: 1
|
||||
repo_git_timeout: 10s
|
||||
analyze_timeout: 5m
|
||||
analyze_failure_threshold: 3
|
||||
projects:
|
||||
- local_path: /abs/path/to/repos
|
||||
branches: [master, main]
|
||||
overwrite_local_changes: false
|
||||
remote_urls:
|
||||
- git@github.com:owner/repo.git
|
||||
- git@gitlab.com:group/repo.git
|
||||
- git@gitee.com:owner/repo.git
|
||||
```
|
||||
|
||||
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
|
||||
|
||||
GraphQL contract matching is opt-in in the group's `group.yaml`:
|
||||
|
||||
```yaml
|
||||
detect:
|
||||
graphql: true
|
||||
```
|
||||
|
||||
The initial exact-only slice matches methods and properties on top-level NestJS `@Resolver`
|
||||
classes using imported `@Query`, `@Mutation`, and `@Subscription` decorators. Named
|
||||
`.graphql`/`.gql` operations are anchored by generated `<OperationName>Document` declarations;
|
||||
object, static `gql` template, and `TypedDocumentString` initializers must prove the operation name
|
||||
and root fields. Dynamic decorator names, anonymous operations, and ambiguous or missing graph
|
||||
anchors are deliberately omitted. Add common infrastructure fields such as `/health` to
|
||||
`matching.exclude_links_paths` to keep those GraphQL contracts visible without cross-linking them.
|
||||
|
||||
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
## Remote Embeddings
|
||||
|
|
@ -389,7 +457,7 @@ Installed automatically by both `gitnexus analyze` (per-repo) and `gitnexus setu
|
|||
LadybugDB native binary ships as a prebuild against that floor, so on an older host it cannot
|
||||
load and reinstalling does not help — see
|
||||
[Linux: `GLIBC_2.34' not found`](#linux-glibc_234-not-found).
|
||||
- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) *and*
|
||||
- **Windows, for full-text search:** the Microsoft Visual C++ 2015-2022 Redistributable (x64) _and_
|
||||
OpenSSL 3 (`libssl-3-x64.dll`, `libcrypto-3-x64.dll`) resolvable on `PATH` — see
|
||||
[Windows: full-text search unavailable](#windows-full-text-search-unavailable).
|
||||
|
||||
|
|
@ -666,17 +734,17 @@ For repositories with very large source files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BY
|
|||
|
||||
Four env vars expose the pool's resilience layers (respawn budget, cumulative-timeout cap, circuit breaker, startup handshake). Defaults are tuned for typical repos; bump them when an analyze legitimately needs more retries, or lower them to fail-fast on a known-bad shape.
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
| Variable | Default | Effect |
|
||||
| ----------------------------------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per slot before the slot is dropped from the active rotation. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Bounds exponentially-growing retry waits. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD` | `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, dispatches require a fresh pool. |
|
||||
| `GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS` | `30000` | Max wait at pool shutdown for a retired worker still inside native code — terminated at its next JS-safe point instead of mid-native-call, which would abort the process (`Napi::Error`, #2432). |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. Raise it on a slow or heavily loaded host where a full pool cold-starting concurrently needs more than 5s. |
|
||||
| `GITNEXUS_MEMORY` | `off` | unset (autopilot on) | `off` declines GitNexus's memory autopilot: analyze will neither re-run itself with a RAM-aware heap cap nor abort the parse before V8 enters its ineffective-mark-compact death spiral. Use it when you want to drive memory manually; to simply pin a heap size, pass Node's own `--max-old-space-size`, which is already honoured as your decision. |
|
||||
| `GITNEXUS_WORKER_HEAP_MB` | `clamp(512, RAM/2/poolSize, 4096)` | Per-worker V8 old-generation heap cap (#2649). Bounds pool RSS on large repos; a worker exceeding it dies with a real heap error handled by quarantine/respawn. |
|
||||
| `GITNEXUS_SERVER_ANALYZE_HEAP_MB` | `min(8192, auto cap)` | Heap for the web/MCP server's forked analyze worker (#2649). Defaults to the historical 8192 MB bounded by the machine/container's RAM-aware auto cap; set an absolute MB value to override. |
|
||||
| `GITNEXUS_CPP_CAPTURE_BUDGET_MS` | `20000` | Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning (#2432). `0` expires immediately. |
|
||||
|
||||
### Graph cleanup tuning
|
||||
|
||||
|
|
@ -690,8 +758,8 @@ Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions`
|
|||
|
||||
### Scope-resolution property-key dispatch cap
|
||||
|
||||
During scope resolution GitNexus synthesizes CALLS edges through *property-key
|
||||
dispatch* — call sites like `hooks.emitScopeCaptures()` where a property key is
|
||||
During scope resolution GitNexus synthesizes CALLS edges through _property-key
|
||||
dispatch_ — call sites like `hooks.emitScopeCaptures()` where a property key is
|
||||
registered by multiple definitions across the codebase. To keep this fan-in
|
||||
bounded, each property key is capped at **32 registrations**: a key registered
|
||||
by more than 32 distinct functions is skipped entirely (no CALLS are synthesized
|
||||
|
|
@ -699,8 +767,8 @@ through it), and the dropped key names are surfaced in the analyze log for
|
|||
operator visibility. The cap is calibrated at 2× this repo's own provider table
|
||||
(16 legitimate registrations, one per language provider).
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_MAX_PROPERTY_DISPATCH_FANOUT` | `32` | Per-property-key registration cap in the property-dispatch scope-resolution pass. Set to a positive integer to raise it for repositories whose provider/hook tables exceed the default and lose CALLS coverage on a legitimate key; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
|
|
@ -713,11 +781,11 @@ npx gitnexus analyze --force
|
|||
|
||||
### Scope-resolution dispatch-target cap
|
||||
|
||||
During scope resolution GitNexus resolves calls that flow through *callable
|
||||
values* — function/method references bound to variables, passed as arguments,
|
||||
During scope resolution GitNexus resolves calls that flow through _callable
|
||||
values_ — function/method references bound to variables, passed as arguments,
|
||||
or stored in maps/tables. To keep that inclusion-based resolution finite, each
|
||||
callable site is capped at **32 dispatch targets**. When a site gathers more
|
||||
candidates than the cap it is treated as **overflowed** and *all* of its call
|
||||
candidates than the cap it is treated as **overflowed** and _all_ of its call
|
||||
edges are dropped — a cliff, not a tail, so a repository with a legitimately
|
||||
wide dispatch table (a single callable site resolving to 33+ targets) loses
|
||||
that site's whole call chain. In that case `analyze` logs
|
||||
|
|
@ -727,8 +795,8 @@ candidate count, and the cap (32).
|
|||
|
||||
Raise the cap for such repositories:
|
||||
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Variable | Default | Effect |
|
||||
| ------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GITNEXUS_MAX_CALLABLE_VALUE_TARGETS` | `32` | Per-callable-site dispatch-target cap in the callable-value-flow scope-resolution pass. Set to a positive integer to raise it for repositories whose wide dispatch tables overflow the default and lose a whole call chain; non-integer or `< 1` values fall back to `32`. Lowering it tightens the overflow budget. |
|
||||
|
||||
```bash
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue