mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into feat/unified-deployment-enhancement
This commit is contained in:
commit
9a2134770f
590 changed files with 70966 additions and 7244 deletions
|
|
@ -5,9 +5,9 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `bunx`, else `npx`), so no package-manager assumption and no global install is required — including on a bun-only machine, which has no npm, npx or pnpm at all.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root, or `bunx gitnexus@latest analyze` on a bun-only machine. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`), or use `bunx gitnexus@latest analyze`, or `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
| Flag | Effect |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `--force` | Force full regeneration |
|
||||
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -53,6 +84,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
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.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -61,6 +100,7 @@ description: "Use when the user wants to know what will break if they change som
|
|||
```
|
||||
impact({
|
||||
target: "validateUser",
|
||||
repo: "my-app", // required once >1 repository is indexed
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
|
|
@ -84,10 +124,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)
|
||||
|
||||
|
|
@ -95,4 +151,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
|
|
@ -148,13 +148,19 @@ finding is NOT proof of safety.
|
|||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
Taint models cover four `SupportedLanguages` ids across three files:
|
||||
TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses
|
||||
`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model
|
||||
for the language you are targeting. The explicit
|
||||
`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four;
|
||||
it is not an import side effect.
|
||||
|
||||
The spec is hashable data (no functions). A sanitizer's `neutralizes` lists
|
||||
the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert
|
||||
the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript
|
||||
use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java
|
||||
model matches are covered by `python-model-match.test.ts` and
|
||||
`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
|
|
|
|||
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: |
|
||||
|
|
|
|||
138
.github/workflows/ci-tests.yml
vendored
138
.github/workflows/ci-tests.yml
vendored
|
|
@ -482,6 +482,14 @@ jobs:
|
|||
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 +497,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 +509,19 @@ jobs:
|
|||
run: node --import tsx bench/callable-value-flow/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,7 +529,118 @@ jobs:
|
|||
run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- 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.
|
||||
#
|
||||
|
|
@ -534,6 +666,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
|
||||
|
|
@ -546,6 +679,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 /
|
||||
|
|
@ -556,6 +690,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
|
||||
|
|
@ -565,6 +700,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
|
||||
|
|
@ -574,6 +710,7 @@ jobs:
|
|||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
|
||||
if: ${{ !cancelled() }}
|
||||
# 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
|
||||
|
|
@ -585,6 +722,7 @@ jobs:
|
|||
test/integration/cobol-pipeline-benchmark.test.ts
|
||||
test/integration/csharp-pipeline-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/rust-pipeline-benchmark.test.ts
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
|
|
@ -73,6 +73,6 @@ jobs:
|
|||
- '**/test/**/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
|
|
|||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
|
@ -148,7 +148,7 @@ jobs:
|
|||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -163,7 +163,7 @@ jobs:
|
|||
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
4
.github/workflows/scorecard.yml
vendored
4
.github/workflows/scorecard.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
|
@ -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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.github/workflows/trivy.yml
vendored
|
|
@ -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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
|
|
|||
|
|
@ -111,15 +111,16 @@ 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? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #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 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 the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **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"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
|
@ -128,7 +129,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
| `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 |
|
||||
|
|
@ -164,6 +164,57 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
};
|
||||
```
|
||||
|
||||
### Where routes come from
|
||||
|
||||
`route-extractors/` holds four independent ways a route can be discovered, all
|
||||
converging on the routes phase's `(method, url)` registry:
|
||||
|
||||
| Source | Shape | Examples |
|
||||
| --- | --- | --- |
|
||||
| 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 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
|
||||
dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with
|
||||
a path, a verb and a handler, and nothing else in the pipeline could see it.
|
||||
`route-extractors/dispatch-guard.ts` reads that shape; the transport, dedup and
|
||||
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
|
||||
than guessed at. A missing route is a coverage limit; an invented one is a lie.
|
||||
|
||||
Two rules there need more than one comparison to decide, and are worth knowing
|
||||
about before changing either:
|
||||
|
||||
- **Same-file constant folding.** `` pathname === `${basePath}/rules` `` is
|
||||
common enough that refusing it loses whole route modules — and loses them
|
||||
invisibly, since a module with unfoldable paths and a module with no routes
|
||||
produce the same empty answer. Folding is same-file, string literals only, one
|
||||
alias hop, and refuses on ambiguity (a name declared twice with different
|
||||
values is dropped, never guessed).
|
||||
- **Whole-repo reconciliation** (`reconcileDispatchGuardRoutes`, applied in the
|
||||
routes phase). A split route table — one module listing every path it
|
||||
recognises so the dispatcher can 404 early, handlers in others — otherwise
|
||||
lists every route twice, once verb-less with the table as its "handler". It
|
||||
applies to dispatch-guard routes only: a framework route with no verb is
|
||||
method-agnostic *by declaration*, which is a fact, not a weaker observation.
|
||||
|
||||
---
|
||||
|
||||
## Semantic model
|
||||
|
|
@ -214,6 +265,9 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
|
|||
│ emitReferencesViaLookup ── uses handledSites + deferred-site skip set
|
||||
│ emitPropertyDispatchCalls ── registration USES + conservative CALLS
|
||||
│ emitCallableValueFlow ── assigned/passed callable invocation CALLS
|
||||
│ emitImportedValueReferences ── cross-file value reads via finalized imports
|
||||
│ emitUniqueNamePropertyAccesses ── LAST-RESORT property reads by name,
|
||||
│ narrowed same-file → direct-import, refusing to choose otherwise
|
||||
│ emitImportEdges
|
||||
▼
|
||||
KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES)
|
||||
|
|
@ -232,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)
|
||||
|
|
|
|||
|
|
@ -62,15 +62,16 @@ 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? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #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 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 the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **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"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
|
@ -79,7 +80,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
|
|
|
|||
|
|
@ -126,5 +126,7 @@ ENV GITNEXUS_HOME=/data/gitnexus \
|
|||
|
||||
EXPOSE 4747
|
||||
|
||||
# Bind to 0.0.0.0 so the server is reachable from the host's mapped port.
|
||||
CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"]
|
||||
# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render
|
||||
# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still
|
||||
# reaches it. Platforms can rely on this instead of a dockerCommand override.
|
||||
CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental. That same line also appears — regardless of write-set size, even for a one-file change — when a LadybugDB extension the existing index depends on cannot load on this machine (VECTOR, #2623; FTS, #2841), because a DB carrying those indexes refuses all row-level DML until the extension is loaded; run `gitnexus doctor` for live extension status and re-run with `GITNEXUS_LBUG_EXTENSION_INSTALL=auto` (with network access) to allow one bounded install attempt. The rebuild is one-shot: it clears the indexes, so the next run goes back to the incremental plan.
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
|
@ -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.
|
||||
|
||||
### 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.
|
||||
- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`.
|
||||
- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
|
||||
|
||||
### MCP lists no repos
|
||||
|
||||
- **Trigger:** MCP stderr says no indexed repos.
|
||||
|
|
|
|||
13
MIGRATION.md
13
MIGRATION.md
|
|
@ -106,6 +106,19 @@ Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json`
|
|||
with identical content. A pre-existing repo that only has `meta.json` gets
|
||||
`gitnexus.json` bootstrapped from it on the first run.
|
||||
|
||||
### Process ids are not stable across this release
|
||||
|
||||
`Process` ids are positional (`proc_<idx>_<entry>`), and this release changes
|
||||
both which execution flows are detected and the order they are selected in:
|
||||
tracing is depth-first, sibling branches follow source order, and selection
|
||||
round-robins across terminals so one flow cannot take every slot. A given
|
||||
`proc_7_handle` before the upgrade is not the same flow afterwards.
|
||||
|
||||
Nothing in GitNexus persists or joins on a raw process id across a re-index —
|
||||
the MCP resource keys by label — so this is one-time index churn rather than a
|
||||
broken consumer. If you have external tooling that stored a process id, re-
|
||||
resolve it by label after the next analyze.
|
||||
|
||||
### What about rollback?
|
||||
|
||||
Downgrading to an older GitNexus version is safe: `meta.json` is always
|
||||
|
|
|
|||
24
README.md
24
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.
|
||||
|
||||
|
|
@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
|
|||
|
||||
</details>
|
||||
|
||||
### Deploy to Render
|
||||
|
||||
Deploy GitNexus in one click:
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
|
||||
|
||||
The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin.
|
||||
|
||||
At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans.
|
||||
|
||||
The deploy generates an access token, and the UI asks for it on first use:
|
||||
|
||||
1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/).
|
||||
2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab.
|
||||
3. Load the site and paste the token into the prompt (or the settings panel).
|
||||
|
||||
Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy.
|
||||
|
||||
The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render).
|
||||
|
||||
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
|
||||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
| | **CLI + MCP** (recommended) | **Web UI** |
|
||||
|
|
|
|||
10
RUNBOOK.md
10
RUNBOOK.md
|
|
@ -66,6 +66,16 @@ npx gitnexus analyze
|
|||
|
||||
No `--embeddings` flag needed — a retained checkpoint forces embedding generation for 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.
|
||||
|
||||
**Collapsed graph write (analyze exits NON-ZERO and says INCOMPLETE):** A run can finish writing metadata while only a fraction of the relationships it produced are readable back from the index — edges collapsing to a small share of what was built, or a `CodeRelation` table that never materialized (which reads as a persisted count of zero). Because the metadata IS written and the DB does hold rows, nothing looks broken: queries answer with missing edges rather than an error, which is a confident empty answer rather than a failure. `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`, the analyze summary prints `Repository indexed INCOMPLETELY` with the expected and persisted counts, and the CLI exits non-zero so automation is not told an unusable index is fine.
|
||||
|
||||
Recovery is a full rebuild:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable.
|
||||
|
||||
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
|
||||
|
||||
---
|
||||
|
|
|
|||
13
SECURITY.md
13
SECURITY.md
|
|
@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you
|
|||
- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
|
||||
- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
|
||||
|
||||
### Hosted Deploys on Render
|
||||
|
||||
The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect:
|
||||
|
||||
- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
|
|
@ -22,18 +25,430 @@ function jsonForScriptTag(obj) {
|
|||
.replace(/&/g, '\\u0026');
|
||||
}
|
||||
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
|
||||
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
|
||||
const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200);
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`,
|
||||
// Warnings echo operator input back, so strip control characters (log forging)
|
||||
// and cap the length first.
|
||||
function sanitizeForLog(value) {
|
||||
return (
|
||||
String(value)
|
||||
// The line-break strip is redundant with the range below, but CodeQL's
|
||||
// js/log-injection recognizes only this shape as a sanitizer: a global
|
||||
// replace of a literal \n with the empty string.
|
||||
.replace(/\n/g, '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.slice(0, 200)
|
||||
);
|
||||
}
|
||||
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
|
||||
|
||||
// console.error is asynchronous when stderr is a pipe, so pairing it with
|
||||
// process.exit can drop the one message explaining the refusal. writeSync isn't.
|
||||
function exitWithRefusal(message) {
|
||||
writeSync(2, `${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `value` if it's a usable http/https URL, else null + a warning naming `label`.
|
||||
// `rawForLog` lets a caller that normalized first echo back the operator's input.
|
||||
function validHttpUrl(label, value, rawForLog = value) {
|
||||
if (!value) return null;
|
||||
if (isValidUrl(value)) return value;
|
||||
const safeRaw = sanitizeForLog(rawForLog);
|
||||
console.warn(`[gitnexus-web] ${label} "${safeRaw}" is not a valid http/https URL -- ignoring.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric env var. Every consumer below reads <= 0 as "disabled", so obeying a
|
||||
// typo like -1 would switch a timeout off silently. Warn and use the default.
|
||||
function numberFromEnv(label, fallback, min = 0) {
|
||||
const raw = process.env[label];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
console.warn(
|
||||
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is not a number -- using ${fallback}.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
if (n < min) {
|
||||
console.warn(
|
||||
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is below the minimum ${min} -- using ${fallback}.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
|
||||
// its own public origin — same-origin API calls via the proxy below, no config.
|
||||
const backendUrlVar =
|
||||
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
|
||||
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
||||
// Optional same-origin reverse proxy for the API server. On a split deploy
|
||||
// (public web service, private API) the browser must reach the API without a
|
||||
// cross-origin request, since its CORS allowlist and write-route guard only
|
||||
// admit same-host origins. So the browser targets THIS origin and we forward
|
||||
// /api/* to GITNEXUS_UPSTREAM_URL. Unset → no proxy (docker-compose default).
|
||||
// A scheme-less host:port — what Render's `fromService: hostport` yields —
|
||||
// gets http:// prepended.
|
||||
const rawUpstream = process.env.GITNEXUS_UPSTREAM_URL;
|
||||
const rawUpstreamUrl = rawUpstream
|
||||
? /^https?:\/\//.test(rawUpstream)
|
||||
? rawUpstream
|
||||
: `http://${rawUpstream}`
|
||||
: null;
|
||||
const upstreamBase = validHttpUrl('GITNEXUS_UPSTREAM_URL', rawUpstreamUrl, rawUpstream);
|
||||
// The one origin this proxy will ever connect to (see proxyToUpstream).
|
||||
const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
|
||||
|
||||
// The Bearer token every /api/* request must carry. The private upstream has no
|
||||
// auth of its own and loses its Origin guard one hop below (see
|
||||
// proxyToUpstream), so the gate belongs here. The browser holds it — never
|
||||
// inject it next to `backendUrl`. Blank-is-absent follows resolveAuthToken
|
||||
// (gitnexus/src/mcp/http-transport.ts).
|
||||
const authToken = process.env.GITNEXUS_SERVE_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.
|
||||
if (upstreamBase && !authToken) {
|
||||
exitWithRefusal(
|
||||
'[gitnexus-web] Refusing to start: GITNEXUS_UPSTREAM_URL is set without ' +
|
||||
'GITNEXUS_SERVE_AUTH_TOKEN. The proxy would expose every indexed repo — ' +
|
||||
'index, read source, and delete — to anyone with this URL. Set a token, ' +
|
||||
'or unset GITNEXUS_UPSTREAM_URL to serve static assets only.',
|
||||
);
|
||||
}
|
||||
|
||||
// Rejected requests never reach the upstream limiter, so guesses are free. A
|
||||
// throttle would add per-address state to a stateless proxy and a lockout an
|
||||
// attacker can aim at a real user; a length floor makes guessing hopeless and
|
||||
// only ever rejects a hand-picked token.
|
||||
const MIN_AUTH_TOKEN_LENGTH = 32;
|
||||
if (authToken && authToken.length < MIN_AUTH_TOKEN_LENGTH) {
|
||||
exitWithRefusal(
|
||||
`[gitnexus-web] Refusing to start: GITNEXUS_SERVE_AUTH_TOKEN is shorter than ` +
|
||||
`${MIN_AUTH_TOKEN_LENGTH} characters. It is the only thing standing between the ` +
|
||||
'public internet and every indexed repo, and a failed guess is not rate-limited. ' +
|
||||
'Use a generated random value.',
|
||||
);
|
||||
}
|
||||
|
||||
// Whether an inbound X-Forwarded-For may be believed (see clientAddressFor).
|
||||
// Default off, so a wrong deployment fails toward over-restriction rather than
|
||||
// toward an address the caller picks. `true` is rejected as it is server-side
|
||||
// (resolveTrustProxy, which also takes hop counts and so rejects `yes`/`on`
|
||||
// too): it reads as "trust the whole chain".
|
||||
function resolveTrustXff(raw) {
|
||||
const value = raw?.trim();
|
||||
if (!value) return false;
|
||||
if (/^(1|yes|on)$/i.test(value)) return true;
|
||||
if (/^(0|no|off|false)$/i.test(value)) return false;
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_PROXY_TRUST_XFF "${sanitizeForLog(value)}" is not a recognized ` +
|
||||
'boolean -- ignoring the inbound X-Forwarded-For chain. Set 1 only when a load balancer ' +
|
||||
'that appends the real peer sits in front of this service.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const trustInboundXff = resolveTrustXff(process.env.GITNEXUS_PROXY_TRUST_XFF);
|
||||
|
||||
// Idle timeout for a proxied request → 504. Socket activity (SSE heartbeats)
|
||||
// resets it, so long-lived streams are unaffected. 0 disables.
|
||||
const proxyTimeoutMs = numberFromEnv('GITNEXUS_PROXY_TIMEOUT_MS', 120000);
|
||||
|
||||
// nginx's client_body_timeout equivalent: how long to wait for a replayable
|
||||
// client body before 400. Defaults to the idle timeout; 0 disables.
|
||||
const proxyClientBodyTimeoutMs = numberFromEnv(
|
||||
'GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS',
|
||||
proxyTimeoutMs,
|
||||
);
|
||||
|
||||
// Bounded connection-retry, to ride out the few-second window where a
|
||||
// single-instance upstream (private server + disk ⇒ no zero-downtime deploy)
|
||||
// is restarting. Attempts of 1 disables it, and body buffering with it.
|
||||
const proxyRetryAttempts = numberFromEnv('GITNEXUS_PROXY_RETRY_ATTEMPTS', 3, 1);
|
||||
const proxyRetryEnabled = proxyRetryAttempts > 1;
|
||||
const proxyRetryMaxBodyBytes = numberFromEnv('GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES', 256 * 1024);
|
||||
// Never connected ⇒ the upstream got nothing ⇒ safe to replay any method.
|
||||
const preConnectRetryCodes = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
|
||||
// Failed after connecting ⇒ the upstream may already be working on it, so
|
||||
// replay only idempotent methods (RFC 7231 §4.2.2) to avoid double-execution.
|
||||
const postConnectRetryCodes = new Set(['ECONNRESET', 'ETIMEDOUT']);
|
||||
const idempotentMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']);
|
||||
|
||||
// Buffer a request body, capped. Resolves null on overflow, client error, or
|
||||
// timeout — one "unreadable body" contract, which the caller maps to 400.
|
||||
// Listeners detach once settled so a later pipe of the same request is clean.
|
||||
function readBodyCapped(req, cap, timeoutMs) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
req.removeListener('data', onData);
|
||||
req.removeListener('end', onEnd);
|
||||
req.removeListener('error', onError);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolvePromise(value);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
const onEnd = () => finish(Buffer.concat(chunks));
|
||||
const onError = () => finish(null);
|
||||
req.on('data', onData);
|
||||
req.on('end', onEnd);
|
||||
req.on('error', onError);
|
||||
// Hard cap regardless of idle activity; Node's requestTimeout is the outer
|
||||
// backstop.
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
console.warn(`[gitnexus-web] client body read timed out after ${timeoutMs}ms`);
|
||||
finish(null);
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Constant-time Bearer check, mirroring createAuthMiddleware in
|
||||
// gitnexus/src/mcp/http-transport.ts — dummy comparison included, so an absent
|
||||
// or wrong-length header costs the same and the timing can't leak the length.
|
||||
// Duplicated because this file is plain ESM and can't import from gitnexus/src.
|
||||
function authorized(req) {
|
||||
if (!authToken) return true; // static-only: no proxy, nothing to gate
|
||||
const header = req.headers['authorization'];
|
||||
const expected = Buffer.from(`Bearer ${authToken}`);
|
||||
if (typeof header !== 'string') {
|
||||
timingSafeEqual(Buffer.alloc(expected.length), expected);
|
||||
return false;
|
||||
}
|
||||
const provided = Buffer.from(header);
|
||||
if (provided.length !== expected.length) {
|
||||
timingSafeEqual(Buffer.alloc(expected.length), expected);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
// WWW-Authenticate names the scheme; the stable `code` is what the web client
|
||||
// dispatches on, not message text. The body must not distinguish "no token
|
||||
// configured" from "wrong token". `Connection: close` because we answer before
|
||||
// reading the body, which Node would otherwise drain (as with the 400 below).
|
||||
function sendUnauthorized(res) {
|
||||
const body = JSON.stringify({ error: 'unauthorized', code: 'unauthorized' });
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'WWW-Authenticate': 'Bearer',
|
||||
Connection: 'close',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// Fail a proxied request. Once headers are sent the body is partially written
|
||||
// and can't be replaced, so the socket is all we can destroy.
|
||||
function failGateway(res, status, message) {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
} else {
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Hop-by-hop headers (RFC 7230 §6.1) describe one connection, so a proxy must
|
||||
// not forward them in either direction; Node sets its own per hop.
|
||||
const hopByHopHeaders = [
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
];
|
||||
|
||||
function stripHopByHopHeaders(headers) {
|
||||
// §6.1 also lets `Connection` name additional single-hop headers, which the
|
||||
// fixed list below can't cover. Node lowercases header keys on both the
|
||||
// server and client side, so a lowercased name indexes `headers` directly.
|
||||
for (const listed of String(headers.connection ?? '').split(',')) {
|
||||
const name = listed.trim().toLowerCase();
|
||||
if (name) delete headers[name];
|
||||
}
|
||||
for (const name of hopByHopHeaders) delete headers[name];
|
||||
return headers;
|
||||
}
|
||||
|
||||
// The client address this proxy vouches for upstream. The API keys its rate
|
||||
// limiter off req.ip, so forwarding a client-supplied X-Forwarded-For would let
|
||||
// anyone rotate a fake address per request. Which entry is real depends on a
|
||||
// deployment fact this process can't observe (is anything in front appending the
|
||||
// peer?), so the operator asserts it via GITNEXUS_PROXY_TRUST_XFF; until then we
|
||||
// forward the socket peer.
|
||||
function clientAddressFor(req) {
|
||||
if (!trustInboundXff) return req.socket.remoteAddress || null;
|
||||
const forwarded = String(req.headers['x-forwarded-for'] ?? '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
return forwarded || req.socket.remoteAddress || null;
|
||||
}
|
||||
|
||||
// Forward an `/api/*` request upstream, streaming both bodies (SSE / chunked
|
||||
// graph streams) untouched. Retries connect failures when the body is replayable.
|
||||
async function proxyToUpstream(req, res) {
|
||||
let upstream;
|
||||
try {
|
||||
upstream = new URL(req.url, upstreamBase);
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
// The `/api/` route guard keeps req.url host-relative, so resolution can't
|
||||
// leave upstreamBase. Asserting it here means the SSRF boundary doesn't rest
|
||||
// on that two-step argument: one legitimate destination, checked locally.
|
||||
if (upstream.origin !== upstreamOrigin) {
|
||||
console.error(`[gitnexus-web] refusing to proxy off-origin target ${upstream.origin}`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
const isHttps = upstream.protocol === 'https:';
|
||||
const requestFn = isHttps ? httpsRequest : httpRequest;
|
||||
const headers = stripHopByHopHeaders({ ...req.headers });
|
||||
// Terminate the browser origin: the API admits Origin-less requests as
|
||||
// trusted server-to-server calls. Nothing is lost — the browser only ever
|
||||
// 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.
|
||||
delete headers.authorization;
|
||||
headers.host = upstream.host;
|
||||
// Replace, never forward, the inbound chain (see clientAddressFor).
|
||||
const clientAddress = clientAddressFor(req);
|
||||
if (clientAddress) headers['x-forwarded-for'] = clientAddress;
|
||||
else delete headers['x-forwarded-for'];
|
||||
|
||||
// A retry replays the body, so buffer it up front — but only when small and
|
||||
// of known length. Larger/unknown bodies (multipart uploads) stream once with
|
||||
// no retry; an upload is never buffered.
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const isIdempotentMethod = idempotentMethods.has(method);
|
||||
// A request has a body iff it frames one (RFC 7230 §3.3.3). Keying off the
|
||||
// method sends a bodyless DELETE down the stream-once path and gives up a
|
||||
// replay that costs nothing.
|
||||
const hasBody =
|
||||
req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined;
|
||||
const len = Number(req.headers['content-length']);
|
||||
const bufferable =
|
||||
proxyRetryEnabled && Number.isFinite(len) && len >= 0 && len <= proxyRetryMaxBodyBytes;
|
||||
let bodyBuf = hasBody ? null : Buffer.alloc(0);
|
||||
if (hasBody && bufferable) {
|
||||
bodyBuf = await readBodyCapped(req, proxyRetryMaxBodyBytes, proxyClientBodyTimeoutMs);
|
||||
if (bodyBuf === null) {
|
||||
// Overflow, client error, and timeout all collapse to 400 (not 413/408).
|
||||
// `Connection: close` lets Node drop the socket after the 400 flushes,
|
||||
// rather than half-open draining a stalled upload until requestTimeout.
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(400, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Connection: 'close',
|
||||
});
|
||||
res.end('Bad request');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// bodyBuf === null means "stream the live request once, no retry".
|
||||
const retryEligible = bodyBuf !== null;
|
||||
|
||||
const attempt = (n) => {
|
||||
let timedOut = false;
|
||||
const upstreamReq = requestFn(
|
||||
{
|
||||
protocol: upstream.protocol,
|
||||
hostname: upstream.hostname,
|
||||
port: upstream.port || (isHttps ? 443 : 80),
|
||||
method: req.method,
|
||||
path: upstream.pathname + upstream.search,
|
||||
headers,
|
||||
},
|
||||
(upstreamRes) => {
|
||||
// Pipe rather than buffer, so SSE / chunked streams reach the browser
|
||||
// incrementally. Node re-derives Transfer-Encoding for this hop.
|
||||
const responseHeaders = stripHopByHopHeaders({ ...upstreamRes.headers });
|
||||
res.writeHead(upstreamRes.statusCode || 502, responseHeaders);
|
||||
upstreamRes.on('error', () => res.destroy());
|
||||
upstreamRes.pipe(res);
|
||||
},
|
||||
);
|
||||
upstreamReq.on('error', (err) => {
|
||||
if (timedOut) return; // 504 already sent by the timeout handler below
|
||||
// Only before any response byte reaches the browser — once headers are
|
||||
// sent the body is partially written and can't be replayed.
|
||||
const retryableError =
|
||||
preConnectRetryCodes.has(err.code) ||
|
||||
(isIdempotentMethod && postConnectRetryCodes.has(err.code));
|
||||
if (retryEligible && !res.headersSent && n < proxyRetryAttempts && retryableError) {
|
||||
const delay = 250 * 2 ** (n - 1); // 250ms, 500ms, ...
|
||||
console.warn(
|
||||
`[gitnexus-web] upstream ${sanitizeForLog(err.code)}; retry ${n}/${proxyRetryAttempts - 1} in ${delay}ms`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
// The client may have aborted during the backoff window; don't fire a
|
||||
// fresh upstream request nobody is waiting for anymore.
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
attempt(n + 1);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
console.error('[gitnexus-web] upstream proxy error:', sanitizeForLog(err.message));
|
||||
failGateway(res, 502, 'Bad gateway');
|
||||
});
|
||||
if (proxyTimeoutMs > 0) {
|
||||
upstreamReq.setTimeout(proxyTimeoutMs, () => {
|
||||
timedOut = true;
|
||||
console.error(`[gitnexus-web] upstream proxy timeout after ${proxyTimeoutMs}ms`);
|
||||
failGateway(res, 504, 'Gateway timeout');
|
||||
upstreamReq.destroy();
|
||||
});
|
||||
}
|
||||
if (bodyBuf !== null) {
|
||||
// Replayable body already buffered; write it fresh on each attempt.
|
||||
if (bodyBuf.length) upstreamReq.write(bodyBuf);
|
||||
upstreamReq.end();
|
||||
} else {
|
||||
// Non-retryable: stream the live request once.
|
||||
req.on('error', () => upstreamReq.destroy());
|
||||
req.pipe(upstreamReq);
|
||||
}
|
||||
};
|
||||
attempt(1);
|
||||
}
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
|
|
@ -68,6 +483,23 @@ const spaFallback = resolve(root, 'index.html');
|
|||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
// Same-origin API proxy; everything else falls through to the SPA below.
|
||||
if (upstreamBase && (urlPath === '/api' || urlPath.startsWith('/api/'))) {
|
||||
// Before body buffering and the upstream socket, so an unauthenticated
|
||||
// request costs nothing upstream. Static assets are never gated: the UI has
|
||||
// to load in order to prompt for the token.
|
||||
if (!authorized(req)) {
|
||||
sendUnauthorized(res);
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget, so guard the boundary against unhandledRejection.
|
||||
proxyToUpstream(req, res).catch((err) => {
|
||||
console.error('[gitnexus-web] proxy handler crashed:', sanitizeForLog(err?.message ?? err));
|
||||
failGateway(res, 502, 'Bad gateway');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { connect } from 'node:net';
|
||||
import http, { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
|
@ -263,3 +264,825 @@ it('does not inject config into static assets', async () => {
|
|||
assert.equal(res.body, 'body{}');
|
||||
});
|
||||
});
|
||||
// -- API reverse proxy (GITNEXUS_UPSTREAM_URL) -----------------------------
|
||||
|
||||
// Every proxy fixture below runs the server with this token: the proxy refuses
|
||||
// to start without one, and refuses one under 32 characters.
|
||||
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
|
||||
const TEST_BEARER = `Bearer ${TEST_AUTH_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.
|
||||
function rawRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
|
||||
// Send an explicit Content-Length like a browser fetch() does — the proxy
|
||||
// only buffers (and so only retries) bodies of known length.
|
||||
const outHeaders = { ...headers };
|
||||
if (
|
||||
body !== undefined &&
|
||||
!Object.keys(outHeaders).some((h) => h.toLowerCase() === 'content-length')
|
||||
) {
|
||||
outHeaders['content-length'] = String(Buffer.byteLength(body));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port, path, method, headers: outHeaders },
|
||||
(res) => {
|
||||
let respBody = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
respBody += chunk;
|
||||
});
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: respBody }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body !== undefined) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// An authenticated /api/* call. An explicit `authorization` header wins, so the
|
||||
// auth tests can send a wrong one.
|
||||
function apiRequest(port, path, { headers = {}, ...rest } = {}) {
|
||||
const hasAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization');
|
||||
return rawRequest(port, path, {
|
||||
...rest,
|
||||
headers: hasAuth ? headers : { ...headers, authorization: TEST_BEARER },
|
||||
});
|
||||
}
|
||||
|
||||
const respondOk = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"ok":true}');
|
||||
};
|
||||
|
||||
// Every proxy test needs the same four parts: a dist/ to serve, a fake upstream,
|
||||
// a docker-server pointed at it, and teardown that leaks neither a process nor a
|
||||
// temp dir. They differ only in how the upstream misbehaves.
|
||||
//
|
||||
// upstream request handler, replaceable mid-test via `ctx.handler`;
|
||||
// null points the proxy at a port nothing ever listens on
|
||||
// listenAfterMs bind the upstream this late, so the first attempt(s) hit
|
||||
// ECONNREFUSED (a single-instance restart window)
|
||||
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
|
||||
// `fromService: { property: hostport }` yields it
|
||||
// env extra environment for docker-server.mjs
|
||||
//
|
||||
// `ctx` collects what the upstream saw (calls, last request, last body) plus the
|
||||
// proxy's stderr, so assertions read off one object.
|
||||
async function withProxy(
|
||||
{ upstream = respondOk, listenAfterMs = 0, schemeless = false, env = {} } = {},
|
||||
fn,
|
||||
) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-proxy-'));
|
||||
await mkdir(join(dir, 'dist'), { recursive: true });
|
||||
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
|
||||
|
||||
const ctx = { calls: 0, received: null, body: null, stderr: '', handler: upstream };
|
||||
// Read the forwarded request to completion before handing it to the handler,
|
||||
// so no test has to repeat that plumbing to assert on headers or body.
|
||||
const server = upstream
|
||||
? createServer((req, res) => {
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
ctx.calls += 1;
|
||||
ctx.body = body;
|
||||
ctx.received = { method: req.method, url: req.url, headers: req.headers, body };
|
||||
ctx.handler(req, res);
|
||||
});
|
||||
})
|
||||
: null;
|
||||
|
||||
// A late (or never) bind needs its port reserved up front; otherwise let the
|
||||
// OS assign one at listen time.
|
||||
const upstreamPort =
|
||||
server && listenAfterMs === 0
|
||||
? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
|
||||
: await getFreePort();
|
||||
const bindTimer =
|
||||
server && listenAfterMs > 0
|
||||
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
|
||||
: null;
|
||||
|
||||
const port = await getFreePort();
|
||||
const target = `127.0.0.1:${upstreamPort}`;
|
||||
const proc = spawnServerWithEnv(dir, port, {
|
||||
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
|
||||
...env,
|
||||
});
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
ctx.stderr += chunk;
|
||||
});
|
||||
try {
|
||||
await waitForServer(port);
|
||||
await fn(port, ctx);
|
||||
} finally {
|
||||
if (bindTimer) clearTimeout(bindTimer);
|
||||
await killAndWait(proc);
|
||||
if (server?.listening) {
|
||||
server.closeAllConnections?.();
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('proxies /api/* requests to the upstream server', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info?x=1');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /"ok":true/);
|
||||
assert.equal(ctx.received.url, '/api/info?x=1', 'path + query forwarded verbatim');
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the request method and body to the upstream', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"q":"hello"}',
|
||||
});
|
||||
assert.equal(ctx.received.method, 'POST');
|
||||
assert.equal(ctx.received.body, '{"q":"hello"}');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips the browser Origin and Referer before forwarding to the API', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { origin: 'https://gitnexus-web.onrender.com', referer: 'https://x/y' },
|
||||
});
|
||||
assert.equal(
|
||||
ctx.received.headers.origin,
|
||||
undefined,
|
||||
'Origin must be stripped so the API treats it as a trusted server-to-server call',
|
||||
);
|
||||
assert.equal(ctx.received.headers.referer, undefined, 'Referer must be stripped');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers before forwarding to the API', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: {
|
||||
'keep-alive': 'timeout=5',
|
||||
upgrade: 'h2c',
|
||||
'proxy-authorization': 'Basic abc',
|
||||
te: 'trailers',
|
||||
},
|
||||
});
|
||||
assert.equal(ctx.received.headers['keep-alive'], undefined);
|
||||
assert.equal(ctx.received.headers.upgrade, undefined);
|
||||
assert.equal(ctx.received.headers['proxy-authorization'], undefined);
|
||||
assert.equal(ctx.received.headers.te, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('strips request headers that Connection names as single-hop', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
// RFC 7230 §6.1 lets Connection name hop-by-hop headers beyond the
|
||||
// well-known eight, and those must not be forwarded either. Against a fixed
|
||||
// list alone, x-custom-hop reaches the upstream.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { connection: 'x-custom-hop', 'x-custom-hop': 'private' },
|
||||
});
|
||||
assert.equal(ctx.received.headers['x-custom-hop'], undefined);
|
||||
// Connection itself is always re-derived by Node for the upstream hop, so
|
||||
// assert the client's value didn't survive rather than that it's absent.
|
||||
assert.notEqual(ctx.received.headers.connection, 'x-custom-hop');
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a spoofed X-Forwarded-For chain to the load balancer entry when XFF is trusted', async () => {
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: '1' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
// With a load balancer in front, only the last entry is the LB's; the rest
|
||||
// is client-supplied and would otherwise let a caller fake req.ip and evade
|
||||
// the API's rate limits.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
|
||||
});
|
||||
assert.equal(ctx.received.headers['x-forwarded-for'], '203.0.113.9');
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is unset', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
// With nothing in front of the proxy, the whole chain is the caller's to
|
||||
// write, so popping it would forward an address they chose.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is off', async () => {
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: 'off' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and falls back to ignoring XFF when GITNEXUS_PROXY_TRUST_XFF is "true"', async () => {
|
||||
// Rejected for the same reason resolveTrustProxy rejects it server-side: it
|
||||
// reads as "trust everything", the configuration this knob exists to make
|
||||
// deliberate.
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: 'true' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
assert.match(
|
||||
ctx.stderr,
|
||||
/GITNEXUS_PROXY_TRUST_XFF "true" is not a recognized boolean/,
|
||||
'an unrecognized value must warn rather than fail silently',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the socket peer, not the rotating header, on every authenticated request', async () => {
|
||||
// A caller rotating X-Forwarded-For per request earns a fresh limiter key
|
||||
// upstream unless this proxy overwrites it. Hitting the API server directly
|
||||
// would test its own trust-proxy handling instead of this hop.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const forwarded = [];
|
||||
for (const spoofed of ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4']) {
|
||||
await apiRequest(port, '/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': spoofed },
|
||||
body: '{"q":"hi"}',
|
||||
});
|
||||
forwarded.push(ctx.received.headers['x-forwarded-for']);
|
||||
}
|
||||
assert.equal(ctx.calls, 4);
|
||||
for (const address of forwarded) {
|
||||
assert.match(
|
||||
address,
|
||||
/127\.0\.0\.1$/,
|
||||
'every request must key off the socket peer, not the value the client rotated',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('sets X-Forwarded-For from the socket peer when the client sends none', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info');
|
||||
assert.match(
|
||||
ctx.received.headers['x-forwarded-for'],
|
||||
/127\.0\.0\.1$/,
|
||||
'the API must always see a proxy-derived client address',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers from the upstream response', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'X-Late' });
|
||||
res.end('ok');
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.trailer, undefined, 'Trailer describes the upstream hop only');
|
||||
assert.equal(res.body, 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips response headers that Connection names as single-hop', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
Connection: 'x-upstream-hop',
|
||||
'x-upstream-hop': 'internal',
|
||||
});
|
||||
res.end('ok');
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['x-upstream-hop'], undefined, 'named on the upstream hop only');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT proxy non-/api routes (still serves the SPA)', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/some/app/route');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
assert.equal(ctx.calls, 0, 'non-/api requests must not reach the upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('streams a chunked upstream response through to the client', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.write('data: one\n\n');
|
||||
setTimeout(() => {
|
||||
res.write('data: two\n\n');
|
||||
res.end();
|
||||
}, 20);
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/stream');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'text/event-stream');
|
||||
assert.match(res.body, /data: one/);
|
||||
assert.match(res.body, /data: two/);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a scheme-less host:port upstream (Render fromService hostport)', async () => {
|
||||
await withProxy({ schemeless: true }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.url, '/api/info', 'scheme-less upstream should still be proxied');
|
||||
});
|
||||
});
|
||||
|
||||
it('serves RENDER_EXTERNAL_URL as the backend origin when GITNEXUS_BACKEND_URL is unset', async () => {
|
||||
await withInjectionServer(
|
||||
{ RENDER_EXTERNAL_URL: 'https://gitnexus-web.onrender.com' },
|
||||
async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
// Assert on the parsed value, not a substring of the page: a bare
|
||||
// includes() would also pass if the URL appeared in a comment.
|
||||
const injected = /window\.__GITNEXUS_CONFIG__=(\{.*?\});/.exec(res.body)?.[1];
|
||||
assert.ok(injected, 'Expected __GITNEXUS_CONFIG__ in response body');
|
||||
assert.equal(JSON.parse(injected).backendUrl, 'https://gitnexus-web.onrender.com');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 504 when the upstream does not respond within the timeout', async () => {
|
||||
// Upstream accepts the connection but never responds — an idle hang.
|
||||
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '300' };
|
||||
await withProxy({ upstream: () => {}, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 504);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 502 when the upstream is unreachable', async () => {
|
||||
// Retry disabled so this fails fast (the unreachable-upstream contract).
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '1' };
|
||||
await withProxy({ upstream: null, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 502);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Connection-retry across an upstream restart window ---------------------
|
||||
//
|
||||
// `listenAfterMs: 400` binds the upstream late, so the first attempt hits
|
||||
// ECONNREFUSED and must be retried — a single-instance restart. The default 3
|
||||
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
|
||||
|
||||
it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 200, 'first attempt should ride out the restart gap');
|
||||
assert.match(res.body, /"ok":true/);
|
||||
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
|
||||
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a bodyless DELETE, which frames no body to replay', async () => {
|
||||
// Retry eligibility follows RFC 7230 §3.3.3 framing. A DELETE with neither
|
||||
// Content-Length nor Transfer-Encoding has nothing to buffer, so it replays
|
||||
// safely even though it isn't a GET.
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/repo', { method: 'DELETE' });
|
||||
assert.equal(res.status, 200, 'a bodyless DELETE must ride out the restart gap');
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default retry budget when the knob is out of range', async () => {
|
||||
// A negative attempt count is a typo. Obeying it would turn every restart
|
||||
// window into a 502, silently.
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '-1' };
|
||||
await withProxy({ listenAfterMs: 400, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and keeps the default when a timeout knob is negative', async () => {
|
||||
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '-1' };
|
||||
await withProxy({ upstream: null, env }, async (_port, ctx) => {
|
||||
// Every consumer reads <= 0 as "disabled", so an unvalidated -1 removes the
|
||||
// idle timeout and lets a proxied request hang forever.
|
||||
assert.match(
|
||||
ctx.stderr,
|
||||
/GITNEXUS_PROXY_TIMEOUT_MS "-1" is below the minimum 0 -- using 120000/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry after the client aborts during the backoff window', async () => {
|
||||
// The client aborts (~100ms) while a retry is pending, before the upstream
|
||||
// binds (~400ms). The backoff guard must cancel it — otherwise the retry
|
||||
// lands after the bind and runs a job nobody is waiting on.
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
await new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/analyze',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': '12',
|
||||
authorization: TEST_BEARER,
|
||||
},
|
||||
});
|
||||
req.on('error', () => {}); // aborting surfaces a local socket error; ignore
|
||||
req.write('{"repo":"x"}');
|
||||
req.end();
|
||||
// Abort after the first attempt has failed-and-scheduled (ECONNREFUSED is
|
||||
// near-instant) but well before the upstream binds at ~400ms.
|
||||
setTimeout(() => {
|
||||
req.destroy();
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
// Wait past the upstream bind + full retry budget (~750ms) so a leaked retry
|
||||
// would already have landed.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
assert.equal(ctx.calls, 0, 'aborted request must not be retried against the upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 502 after exhausting the retry budget when the upstream stays down', async () => {
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream: null, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 502, 'genuinely-down upstream still returns 502 after the budget');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry a POST that connects then resets before responding', async () => {
|
||||
// The upstream accepts the connection, reads the whole request, then dies
|
||||
// before sending any response byte — an instance that received the job and
|
||||
// crashed/restarted mid-flight. Because the reset arrives AFTER connecting and
|
||||
// POST is non-idempotent, replaying could run the job twice, so the proxy must
|
||||
// NOT retry: the upstream sees exactly one call and the browser gets 502.
|
||||
const upstream = (_req, res) => res.socket.destroy();
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 502, 'post-connection reset on a POST fails fast, no retry');
|
||||
// Give any (erroneous) retry a chance to fire before asserting.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(
|
||||
ctx.calls,
|
||||
1,
|
||||
'non-idempotent POST must not be replayed after the upstream got it',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry after the upstream starts streaming, then drops mid-body', async () => {
|
||||
// Send headers + a partial body, then abruptly destroy the socket.
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.write('{"partial":');
|
||||
setTimeout(() => res.socket.destroy(), 20);
|
||||
};
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream, env }, async (port, ctx) => {
|
||||
// Settle on end OR on the mid-body abort/error, so the dropped connection
|
||||
// can't hang the test. What matters is that the proxy did NOT replay the
|
||||
// request (no duplicate job): the upstream must see exactly 1 call.
|
||||
await new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/analyze',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': '12',
|
||||
authorization: TEST_BEARER,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', resolve);
|
||||
res.on('aborted', resolve);
|
||||
res.on('error', resolve);
|
||||
},
|
||||
);
|
||||
req.on('error', resolve);
|
||||
req.write('{"repo":"x"}');
|
||||
req.end();
|
||||
});
|
||||
// Give any (erroneous) retry a chance to fire before asserting.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(ctx.calls, 1, 'must not replay once the response body has started');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT buffer or retry a body larger than the retry cap', async () => {
|
||||
// Tiny cap so a modest body exceeds it and is streamed, not buffered.
|
||||
const env = { GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES: '16' };
|
||||
const bigBody = 'x'.repeat(1024);
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: bigBody,
|
||||
});
|
||||
assert.equal(res.status, 200, 'over-cap body is streamed straight through');
|
||||
assert.equal(ctx.body.length, bigBody.length, 'full body reaches upstream (not capped)');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 when the client declares a body but never finishes sending it', async () => {
|
||||
// A live upstream, so a failure to reach it can't be mistaken for the body
|
||||
// timeout. It must see zero requests: the proxy never connects because the
|
||||
// buffering read times out first. The dedicated knob is set (leaving the
|
||||
// upstream idle timeout at its default) to prove the two tune independently.
|
||||
const env = { GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS: '300' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
// Raw socket (not http.request, which would auto-finish the body): send a
|
||||
// Content-Length: 100 request but only 10 bytes, then hold the socket open.
|
||||
// We never close our side — the proxy must close it for us once the body
|
||||
// read times out (via `Connection: close`), rather than holding the
|
||||
// half-open connection until the server requestTimeout reaps it.
|
||||
const { status, serverClosed, raw } = await new Promise((resolve) => {
|
||||
const sock = connect(port, '127.0.0.1', () => {
|
||||
sock.write(
|
||||
'POST /api/analyze HTTP/1.1\r\n' +
|
||||
'Host: 127.0.0.1\r\n' +
|
||||
'Content-Type: application/json\r\n' +
|
||||
`Authorization: ${TEST_BEARER}\r\n` +
|
||||
'Content-Length: 100\r\n' +
|
||||
'\r\n' +
|
||||
'x'.repeat(10), // fewer than 100 bytes, then stall
|
||||
);
|
||||
});
|
||||
let buf = '';
|
||||
let status = null;
|
||||
// Fail-safe: if the proxy never closes on its own, report serverClosed
|
||||
// false (so the assertion fails cleanly) instead of hanging the test.
|
||||
const guard = setTimeout(() => {
|
||||
sock.destroy();
|
||||
resolve({ status, serverClosed: false, raw: buf });
|
||||
}, 2000);
|
||||
sock.setEncoding('utf8');
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
if (status === null) {
|
||||
const m = buf.split('\r\n', 1)[0].match(/^HTTP\/\d\.\d (\d{3})/);
|
||||
if (m) status = Number(m[1]);
|
||||
}
|
||||
});
|
||||
// The server closing its side (Connection: close) ends our socket; treat
|
||||
// any teardown initiated by the server as "closed promptly".
|
||||
sock.on('error', () => {}); // a reset may precede 'close'; swallow it
|
||||
sock.on('close', () => {
|
||||
clearTimeout(guard);
|
||||
resolve({ status, serverClosed: true, raw: buf });
|
||||
});
|
||||
});
|
||||
assert.equal(status, 400, 'stalled body read must be bounded and return 400, not hang');
|
||||
assert.ok(
|
||||
serverClosed,
|
||||
'proxy must close the half-open connection promptly, not hold it until requestTimeout',
|
||||
);
|
||||
assert.match(
|
||||
raw.toLowerCase(),
|
||||
/connection: close/,
|
||||
'the 400 for a stalled body must advertise Connection: close',
|
||||
);
|
||||
assert.equal(ctx.calls, 0, 'proxy must not connect upstream when the body never arrives');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Token gate at the public edge (GITNEXUS_SERVE_AUTH_TOKEN) --------------
|
||||
//
|
||||
// The proxy terminates the browser Origin, so the API's own write guard can't
|
||||
// see a cross-site request coming. The token replaces it, checked on the way in.
|
||||
|
||||
it('answers an /api/* request with no Authorization header with a well-formed 401', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/api/health');
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.headers['www-authenticate'], 'Bearer');
|
||||
assert.match(res.headers['content-type'], /application\/json/);
|
||||
// The UI dispatches on the stable code, not on message text.
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'unauthorized', code: 'unauthorized' });
|
||||
assert.equal(ctx.calls, 0, 'an unauthenticated request must cost nothing upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the connection on a rejected request rather than draining its body', async () => {
|
||||
// The 401 is answered before the body is read, so without Connection: close
|
||||
// Node drains up to 64KB of an unauthenticated upload to keep the socket
|
||||
// reusable. Same reasoning as the stalled-body 400 above.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: '/etc' }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.headers.connection, 'close');
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a wrong token of the same length', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const wrong = 'x'.repeat(TEST_AUTH_TOKEN.length);
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: `Bearer ${wrong}` },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a wrong token of a different length', async () => {
|
||||
// The unequal-length branch takes a different path through the comparison
|
||||
// (dummy compare, no timingSafeEqual on the real buffers) and still must 401.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: 'Bearer short' },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the raw token without the Bearer prefix', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: TEST_AUTH_TOKEN },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an /api/* request that carries the correct token', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: TEST_BEARER },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
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');
|
||||
assert.equal(ctx.received.headers.authorization, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
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) => {
|
||||
for (const path of ['/', '/index.html', '/some/app/route']) {
|
||||
const res = await rawRequest(port, path);
|
||||
assert.equal(res.status, 200, `${path} must be served without a token`);
|
||||
assert.match(res.body, /spa/);
|
||||
}
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Run docker-server.mjs to completion and report how it exited. Used for the
|
||||
// boot-time refusal, which never reaches a listening state.
|
||||
function runUntilExit(cwd, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, [serverScript], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
let stderr = '';
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.on('exit', (code) => resolve({ code, stderr }));
|
||||
// A server that starts instead of refusing never exits, so name that failure
|
||||
// here rather than letting it surface as a timeout or a null exit code.
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('docker-server.mjs kept running; it was expected to refuse and exit'));
|
||||
}, 5000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
async function withDistDir(fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-boot-'));
|
||||
await mkdir(join(dir, 'dist'), { recursive: true });
|
||||
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
|
||||
try {
|
||||
await fn(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('refuses to start when the proxy is enabled without a token', async () => {
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: undefined,
|
||||
});
|
||||
assert.equal(code, 1, 'an unauthenticated public proxy must fail closed at boot');
|
||||
assert.match(stderr, /Refusing to start/);
|
||||
assert.match(stderr, /GITNEXUS_SERVE_AUTH_TOKEN/);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to start when the token is short enough to guess', async () => {
|
||||
// Nothing rate-limits a failed token, so a weak one is guessable at network
|
||||
// speed. The floor is what makes the missing limiter safe.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: 'hunter2',
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(stderr, /shorter than 32 characters/);
|
||||
assert.ok(!stderr.includes('hunter2'), 'the refusal must never echo the token');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a whitespace-only token as absent rather than as a short one', async () => {
|
||||
// ' ' trims to empty, so this must hit the missing-token refusal, not the
|
||||
// length one.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: ' ',
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(stderr, /is set without GITNEXUS_SERVE_AUTH_TOKEN/);
|
||||
});
|
||||
});
|
||||
|
||||
it('starts normally with neither the proxy nor a token configured', async () => {
|
||||
// docker-compose's default: static assets only, nothing to gate, no refusal.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const proc = spawnServerWithEnv(dir, port, { GITNEXUS_SERVE_AUTH_TOKEN: undefined });
|
||||
try {
|
||||
await waitForServer(port);
|
||||
const res = await rawRequest(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
} finally {
|
||||
await killAndWait(proc);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: Emit the missing `CALLS` edge for Python's unaliased multi-segment namespace import (`import pkg.db` + `pkg.db.session_scope()`), issue #2826.
|
||||
> Evidence verified at commit b2cd1c2ad637657125248c0dd2046de71ceea965; GitNexus index 13 commits behind HEAD, refresh skipped: every cited path is byte-identical between the index commit (1ef6447e) and the pinned commit — verified by blob-id comparison, so no graph claim here rests on drifted content. PDG layer absent from this index (`MATCH ()-[r:CodeRelation {type:'CDG'}]->() RETURN count(r)` → 0); `--pdg` upgrade skipped, source reads substitute at higher evidence strength.
|
||||
> Evidence provenance schema 2; global dirty digest 0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c; cited-path manifest 13 sorted entries; exact generated plan path excluded.
|
||||
|
||||
## 1. Objective
|
||||
|
||||
`import pkg.db` followed by `pkg.db.session_scope()` must emit a `CALLS` edge from the caller to `session_scope`, matching the three sibling import spellings that already resolve (`from pkg.db import session_scope`, `import pkg.db as pdb`, `from pkg import db`). Two same-package imports in one file (`import pkg.a` + `import pkg.b`) must not cross-resolve, and no shared file under `gitnexus/src/core/ingestion/` may name a language (AGENTS.md §42).
|
||||
|
||||
## 2. Current Behaviour
|
||||
|
||||
The failure is a **key/lookup mismatch inside one map**, not a missing resolution path.
|
||||
|
||||
For `import pkg.db`, `splitImportStmt` emits one match with `@import.source` = the whole `dotted_name` text `"pkg.db"` `[verified]` (`gitnexus/src/core/ingestion/languages/python/import-decomposer.ts:46-54`). `interpretPythonImport`'s `'plain'` arm then splits it `[verified]` (`gitnexus/src/core/ingestion/languages/python/interpret.ts:33-42`):
|
||||
|
||||
```ts
|
||||
case 'plain': {
|
||||
// `import numpy`
|
||||
if (sourceCap === undefined) return null;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a`
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`finalizeImportEdges` carries both halves onto the edge: `localName` verbatim, and `targetExportedName = parsed.importedName` for `kind === 'namespace'` `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:398-406, 434-447`). So the finalized `ImportEdge` is `{ localName: 'pkg', targetExportedName: 'pkg.db', targetFile: 'pkg/db.py', kind: 'namespace' }`.
|
||||
|
||||
`collectNamespaceTargets` keys **only on `localName`** `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts:44-57`), producing `{'pkg' → ['pkg/db.py']}`.
|
||||
|
||||
At the call site, Python's query binds the attribute's `object` field with a wildcard — `object: (_) @reference.receiver` `[verified]` (`gitnexus/src/core/ingestion/languages/python/query.ts:267-270`) — so for `pkg.db.session_scope()` the receiver node is the inner `attribute`, and `extractExplicitReceiver` takes its raw text `[verified]` (`gitnexus/src/core/ingestion/scope-extractor.ts:1235-1239`): `receiverName === 'pkg.db'`.
|
||||
|
||||
`emitReceiverBoundCalls` then walks its cases `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts:404-421, 546-655, 831-848`):
|
||||
|
||||
- **Case 0 (compound receiver)** fires because `receiverName.includes('.')` (line 563-567). It asks `resolveCompoundReceiverClass` for a **class**; `pkg.db` names a module, so it returns `undefined`, sets `compoundReceiverUnresolved = true`, and — critically — does **not** `handledSites.add`, so control falls through (lines 577, 622-655).
|
||||
- **Case 1 (namespace receiver)** runs `namespaceTargets.get('pkg.db')` (line 832). The map holds `'pkg'`. Miss.
|
||||
- **Case 1.5** needs `provider.resolveQualifiedReceiverMember`, implemented only by the C++ provider `[verified]` (`gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts:399-406`; `context` on that symbol shows one outgoing call to `resolveCppQualifiedNamespaceMember` and no other implementer). Python leaves it undefined, so the case is skipped.
|
||||
|
||||
No later case types a module receiver, so the site drops. Reproduced on both `origin/main` and PR #2810's head; PR #2810 changes Python receiver *typing* (`languages/python/receiver-binding.ts`) and does not touch this path `[verified]` by running the repro against both trees.
|
||||
|
||||
The three sibling spellings resolve because each binds a **single-segment** local name: `pdb` (alias arm), `session_scope` (named binding, not a receiver at all), and `db` (reclassified to `kind: 'namespace'` by #2770's `isNamespaceImport` hook, keying the map on `'db'`).
|
||||
|
||||
## 3. Relevant Architecture
|
||||
|
||||
`collectNamespaceTargets` is the shared, language-neutral bridge between finalized import edges and receiver resolution. Its contract note already states that `ImportEdge.kind === 'namespace'` is authoritative and that providers may reclassify into it — that reclassification hook (`isNamespaceImport`) is #2770's extension point `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107`).
|
||||
|
||||
Its output feeds three consumers, all per-file (`fileCompoundOpts`, `receiver-bound-calls.ts:405-406`):
|
||||
|
||||
1. `emitReceiverBoundCalls` Case 1 — namespace-receiver member calls (`receiver-bound-calls.ts:832`);
|
||||
2. `resolveConstructionExpressionClass` — namespace-qualified construction `pkg.db.Model()` (`compound-receiver.ts:245-260`);
|
||||
3. `resolveCompoundReceiverClass`'s namespace-qualified-constructor disambiguation `options.namespaceTargets?.has(objExpr)` (`compound-receiver.ts:759-766`).
|
||||
|
||||
AGENTS.md line 42 is the binding constraint: *"Shared code in `gitnexus/src/core/ingestion/` must not name languages — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks."* `[verified]`
|
||||
|
||||
## 4. GitNexus Findings
|
||||
|
||||
- `context({name: 'collectNamespaceTargets', repo: 'GitNexus'})` — `epistemic: "exact"`; incoming calls are exactly two: `emitReceiverBoundCalls` (`.../passes/receiver-bound-calls.ts`) and a test-local `build` in `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts`. `[graph]` These are the d=1 dependents; the two `compound-receiver.ts` consumers reach the map by parameter rather than by call, so they do not appear here and were found by source grep `[verified]`.
|
||||
- `context({name: 'resolveQualifiedReceiverMember', repo: 'GitNexus'})` — resolves to a single definition at `languages/cpp/scope-resolver.ts:399`, `outgoing.calls: [resolveCppQualifiedNamespaceMember]`, no incoming. `[graph]` Confirms the Case-1.5 hook is C++-only, matching the issue reporter's read of the published bundle.
|
||||
- `cypher({statement: "MATCH ()-[r:CodeRelation {type: 'CDG'}]->() RETURN count(r)"})` — `| cdg_rows | 0 |`. `[graph]` The index carries no PDG layer; §5 is therefore empty by fact, not by omission.
|
||||
- Related tests located by directory listing `[verified]`: `test/fixtures/lang-resolution/` already holds `python-module-import`, `python-bare-import`, `python-plain-import-alias`, `python-multi-segment-ancestor-import`, `python-function-local-namespace-import`, `python-class-body-namespace-import`, and #2770's `python-from-module-alias`. `test/integration/resolvers/python.test.ts` is the convention-matching home for the new assertions (#2770 added its coverage there, +38 lines).
|
||||
|
||||
## 5. Statement-Level PDG Findings
|
||||
|
||||
Empty by fact: the current index has zero `CDG` rows, so no statement-level slice exists to build. A `--pdg` re-index was deliberately not run — it is the largest fixed cost available to this session, the analyzer holds no writer lock against a live MCP server (#2658), and every constraint the slice would supply (which case gates the namespace lookup, whether Case 0's failure falls through) was read directly from source at higher evidence strength in §2.
|
||||
|
||||
## 6. Proposed Changes
|
||||
|
||||
### 6.1 `collectNamespaceTargets` — also key on the dotted access path
|
||||
|
||||
- **File:** `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts`
|
||||
- **Symbol:** `collectNamespaceTargets` (source-verified)
|
||||
- **Responsibility:** map every receiver spelling that names an imported module to that module's file(s).
|
||||
- **Change:** inside the existing edge loop, after recording `edge.localName`, also record `edge.targetExportedName` **when it contains a dot and its first dot-separated segment equals `edge.localName`**. Same array-dedupe as the existing key.
|
||||
- **Why this is language-neutral (AGENTS.md §42):** the condition names no language. It encodes one structural fact — *a namespace binding whose exported module name is a dotted path rooted at the local name is also reachable under that whole path.* Verified against every other namespace-emitting provider at the pinned commit `[verified]`:
|
||||
- TypeScript `import * as X from './y'` → `localName 'X'`, `importedName './y'`; first segment `''` ≠ `'X'` → no key (`languages/typescript/interpret.ts:77-81, 118-122`).
|
||||
- C# `using System.Collections.Generic` → `localName 'Generic'` (last segment), `importedName 'System.Collections.Generic'`; first segment `'System'` ≠ `'Generic'` → no key (`languages/csharp/interpret.ts:33-37, 62-66`).
|
||||
- Go / Rust / Ruby → `localName === importedName`, no dot → no key (`languages/{go,rust,ruby}/interpret.ts`).
|
||||
- Python `import pkg.db` → `'pkg' === 'pkg.db'.split('.')[0]` → key `'pkg.db'` added. This is the only provider the predicate admits today.
|
||||
- **Constraint:** additive only. The existing `localName` key must keep its current value and ordering so no currently-resolving site changes target.
|
||||
- **Two-package safety:** `import pkg.a` + `import pkg.b` in one file yields `{'pkg' → ['pkg/a.py','pkg/b.py'], 'pkg.a' → ['pkg/a.py'], 'pkg.b' → ['pkg/b.py']}`. Receiver `pkg.a` hits exactly one file; the ambiguous `'pkg'` bucket is only reachable by a receiver literally spelled `pkg`, which is unchanged from today. `[inferred]` — pinned by a test in §8.
|
||||
|
||||
### 6.2 `isNamespaceNameShadowed` — test the root segment, not the dotted path
|
||||
|
||||
- **File:** `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts`
|
||||
- **Symbol:** `isNamespaceNameShadowed` (source-verified, lines 152-183) and its one call site at line 250.
|
||||
- **Defect this fix activates:** the guard walks the scope chain looking for a binding, type binding, lexical name, or owned def **named exactly `namespaceName`**. With 6.1 in place, `namespaceName` can be `'pkg.db'`, but Python binds only `pkg` — so a local `pkg = something` that genuinely shadows the import would fail to suppress the namespace interpretation, and the "verified namespace is authoritative" branch (line 249-259) would return a wrong class instead of declining.
|
||||
- **Change:** shadow-test the first dot-separated segment of `namespaceName` (identical behaviour for the single-segment names it sees today, since root === whole name).
|
||||
- **Not scope creep:** 6.1 is what first routes a dotted name into this guard; shipping 6.1 without it introduces the false positive.
|
||||
|
||||
### 6.3 No change required in `receiver-bound-calls.ts`
|
||||
|
||||
Case 1's lookup already uses the full dotted `receiverName` and Case 0's failure already falls through to it (`receiver-bound-calls.ts:577, 622-655, 832`) `[verified]`. Recorded here so the executor does not "fix" a path that is already correct.
|
||||
|
||||
## 7. Implementation Sequence
|
||||
|
||||
1. **Add the failing fixture and assertions first.** Create `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/` (files in §8) and a `describe` block in `gitnexus/test/integration/resolvers/python.test.ts` following the file's existing `writeFixtureRepo` + `mkdtempSync` convention. Confirm the dotted row fails and all three control rows pass. Delete the scratch `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` in this step — its content is superseded by the fixture-backed tests.
|
||||
2. **Implement 6.1** in `namespace-targets.ts`, and update its header contract note to state that a namespace edge may be keyed both by its local name and by a dotted access path rooted at that name. Re-run the step-1 tests: the dotted row must flip to passing with the controls still green.
|
||||
3. **Implement 6.2** in `compound-receiver.ts` with the shadowing test from §8 (a local `pkg = Decoy()` must suppress, not misresolve).
|
||||
4. **Run the regression surface**: full resolver + scope-resolution integration suites, both packages' `tsc --noEmit`.
|
||||
5. **Regenerate recorded baselines once, last.** Run each `--check` gate; regenerate only the baselines that actually moved (`bench/receiver-resolution/baseline.json` is the expected one — this change adds resolved edges). Per plan-template §7, this is deliberately the final step so intermediate commits do not churn and re-drift the artifacts.
|
||||
|
||||
## 8. Test Strategy
|
||||
|
||||
**New fixture** `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/`:
|
||||
|
||||
| file | contents |
|
||||
| --- | --- |
|
||||
| `pkg/__init__.py` | empty |
|
||||
| `pkg/db.py` | `def session_scope(): ...` |
|
||||
| `pkg/cache.py` | `def session_scope(): ...` — the decoy that makes cross-resolution detectable |
|
||||
| `caller_dotted.py` | `import pkg.db` + `def uses_dotted(): return pkg.db.session_scope()` |
|
||||
| `caller_from.py`, `caller_alias.py`, `caller_frommod.py` | the three sibling controls from the issue |
|
||||
| `caller_two_pkgs.py` | `import pkg.db` **and** `import pkg.cache`, one function calling each |
|
||||
| `caller_deep.py` | `import pkg.sub.deep` + `pkg.sub.deep.f()` (3-segment) |
|
||||
| `caller_shadowed.py` | module-level `import pkg.db`, then a function with a local `pkg = Decoy()` before `pkg.db.session_scope()` |
|
||||
|
||||
**Scenarios** (input → action → expected):
|
||||
|
||||
1. `caller_dotted.py` → run pipeline → `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope`, `reason: 'import-resolved'`. **This is the issue's acceptance row.**
|
||||
2. The three sibling callers → same run → all three still resolve to `pkg/db.py:session_scope`. Regression control: a run where the controls also broke would prove nothing about row 1.
|
||||
3. `caller_two_pkgs.py` → `pkg.db.session_scope()` resolves **only** to `pkg/db.py` and `pkg.cache.session_scope()` **only** to `pkg/cache.py`; assert the absence of the crossed pair explicitly, not just the presence of the right one.
|
||||
4. `caller_deep.py` → 3-segment receiver resolves — proves the predicate is not hard-coded to two segments.
|
||||
5. `caller_shadowed.py` → **no** edge from the shadowed function to `pkg/db.py` (6.2's guard). Fails loudly if 6.2 regresses.
|
||||
6. Cross-language non-regression: the existing TypeScript / C# / Go namespace-import resolver tests must stay green unchanged — that is the executable proof the new key is not minted for them.
|
||||
|
||||
**Tests to update:** `gitnexus/test/integration/resolvers/python.test.ts` (add the describe block). `gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts` is a direct `collectNamespaceTargets` caller — re-run it; extend it only if its expectations enumerate map keys exhaustively.
|
||||
|
||||
**Verification commands** (each verified to exist in `gitnexus/package.json` / `.github/workflows/ci-tests.yml` at the pinned commit):
|
||||
|
||||
```bash
|
||||
# from gitnexus/ — pretest:integration runs scripts/build.js, so the parse worker exists
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers
|
||||
npm run test:unit -- test/unit/scope-resolution
|
||||
npx tsc --noEmit # and the same in ../gitnexus-shared
|
||||
node --import tsx bench/receiver-resolution/measure.mjs --check
|
||||
node --import tsx bench/python-scope/measure.mjs --check
|
||||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
node --import tsx bench/scope-capture/measure.mjs --check
|
||||
```
|
||||
|
||||
`GITNEXUS_WORKER_READY_TIMEOUT_MS=60000` is required on this host: the default 5000 ms worker-ready deadline fails as a crash-loop here (observed while reproducing the issue), which is environmental, not a code fault.
|
||||
|
||||
## 9. Risk and Impact Analysis
|
||||
|
||||
Accounting for every direct (d=1) dependent of the changed map:
|
||||
|
||||
| d=1 dependent | risk | mitigation |
|
||||
| --- | --- | --- |
|
||||
| `emitReceiverBoundCalls` Case 1 (`receiver-bound-calls.ts:832`) | New keys make previously-dropped sites resolve. A wrong target would be a *new* false edge. | The predicate admits only Python's `import a.b` shape; each new key maps to exactly one file per import statement. §8 scenario 3 pins non-crossing. |
|
||||
| `resolveConstructionExpressionClass` (`compound-receiver.ts:245-260`) | `pkg.db.Model()` now takes the "verified namespace is authoritative" branch, which deliberately does **not** fall through on a miss or ambiguity — so a wrong key would convert a working heuristic resolution into a silent decline. | The branch requires `namespaceFiles.length > 0`, i.e. the import genuinely resolved. Ambiguity still returns `undefined` (`namespaceMatches.length === 1` guard). Shadowing is fixed by 6.2. |
|
||||
| `resolveCompoundReceiverClass` namespace-constructor disambiguation (`compound-receiver.ts:759-766`) | `namespaceTargets.has(objExpr)` now true for dotted namespaces, routing `pkg.db.Model(x).run()` into the construction interpretation. | Correct by intent — that branch exists precisely to make a namespace-qualified bare constructor safe. Behaviour change, so §8 should include a construction row if the fixture's cost is low. |
|
||||
| `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts:build` | May assert exact map contents. | Re-run in step 4; extend rather than weaken if it enumerates keys. |
|
||||
| C++ provider | Case 1 is skipped entirely for C++ (`provider.resolveQualifiedReceiverMember !== undefined`), but the two `compound-receiver.ts` consumers are **not** provider-gated. | C++ `#include` does not produce a `kind: 'namespace'` edge with a dotted `targetExportedName` rooted at its local name; the predicate declines. Covered by the existing C++ suites plus `bench/cpp-qualified-ns/measure.mjs --check`. |
|
||||
|
||||
**Recorded-artifact risk:** `bench/receiver-resolution/measure.mjs --check` gates both a shape matrix and a drop-count arm; new resolved edges are expected to move the count arm and the gate fails on drift. Regenerating in step 5 only (per §7) keeps intermediate commits clean. `bench/python-scope/*` and `bench/scope-capture/*` fingerprint captures and import-target resolution — neither is touched by this change, so a movement there is a signal to stop and investigate, not to regenerate.
|
||||
|
||||
**Performance:** one extra `Map.set` per multi-segment namespace import per file; the loop is already O(module import edges). No new traversal.
|
||||
|
||||
**No schema/version impact:** this changes what the resolver produces, not how it is stored. Existing indexes need a re-analyze to show the new edges — matching the note PR #2810 carried for the same reason.
|
||||
|
||||
## 10. Files Expected to Change
|
||||
|
||||
| File | Symbols | Reason |
|
||||
| ---- | ------- | ------ |
|
||||
| `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` | `collectNamespaceTargets` | Add the dotted-access-path key (§6.1) and update the contract note |
|
||||
| `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` | `isNamespaceNameShadowed` | Shadow-test the root segment (§6.2) |
|
||||
| `gitnexus/test/integration/resolvers/python.test.ts` | new `describe` block | Issue acceptance row + controls + regression rows |
|
||||
| `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/**` | — | New fixture (§8) |
|
||||
| `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` | — | Delete; superseded by the fixture-backed tests |
|
||||
| `gitnexus/bench/receiver-resolution/baseline.json` | — | Regenerate once, final step, only if `--check` moves |
|
||||
|
||||
## 11. Reusable Implementation Context
|
||||
|
||||
```yaml
|
||||
implementation_context:
|
||||
task_summary: >
|
||||
Python `import pkg.db` + `pkg.db.session_scope()` emits no CALLS edge (#2826).
|
||||
Root cause: collectNamespaceTargets keys its map only on ImportEdge.localName
|
||||
('pkg'), while the receiver text is the full dotted path ('pkg.db'). Fix by
|
||||
additionally keying on ImportEdge.targetExportedName when it is dotted and
|
||||
rooted at localName — a predicate no other provider satisfies — plus a
|
||||
root-segment fix to the shadow guard the new key first exposes.
|
||||
acceptance_criteria:
|
||||
- 'CALLS edge uses_dotted -> pkg/db.py:session_scope with reason import-resolved'
|
||||
- 'The three sibling spellings (from-import, alias, from-module-attr) still resolve'
|
||||
- 'import pkg.a + import pkg.b in one file do not cross-resolve'
|
||||
- 'A local binding shadowing the package root suppresses the namespace interpretation'
|
||||
- 'No shared file under gitnexus/src/core/ingestion/ names a language (AGENTS.md §42)'
|
||||
|
||||
evidence_provenance:
|
||||
schema_version: 2
|
||||
head_commit: 'b2cd1c2ad637657125248c0dd2046de71ceea965'
|
||||
generated_plan_path: 'docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md'
|
||||
global_dirty_digest:
|
||||
algorithm: 'sha256'
|
||||
canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records'
|
||||
value: '0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c'
|
||||
cited_path_manifest:
|
||||
- path: '.github/workflows/ci-tests.yml'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
index_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
worktree_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'AGENTS.md'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
index_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
worktree_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
index_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
worktree_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus-shared/src/scope-resolution/types.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
index_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
worktree_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/import-decomposer.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
index_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
worktree_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/interpret.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
index_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
worktree_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/query.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
index_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
worktree_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-extractor.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
index_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
worktree_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
index_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
worktree_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
index_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
worktree_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
index_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
worktree_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
index_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
worktree_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts'
|
||||
object_kind: { head: absent, index: absent, worktree: absent, untracked: regular }
|
||||
state: 'untracked'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'absent'
|
||||
index_digest: 'absent'
|
||||
worktree_digest: 'absent'
|
||||
untracked_digest: 'sha256:6fe3a74a69db12a1a0aeceef2b32eb0c04d5e4880fc93b7b840348118e70078c'
|
||||
|
||||
primary_symbols:
|
||||
- symbol: 'collectNamespaceTargets'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
lines: '39-57'
|
||||
role: 'The defect site — builds the receiver-name → target-file map keyed only on localName'
|
||||
- symbol: 'interpretPythonImport'
|
||||
file: 'gitnexus/src/core/ingestion/languages/python/interpret.ts'
|
||||
lines: '33-42'
|
||||
role: 'Splits `import a.b` into localName "a" / importedName "a.b"; source of both halves'
|
||||
- symbol: 'emitReceiverBoundCalls'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts'
|
||||
lines: '404-421, 546-655, 831-848'
|
||||
role: 'Case 0 declines on a module receiver and falls through; Case 1 does the failing map lookup'
|
||||
- symbol: 'isNamespaceNameShadowed'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
lines: '152-183'
|
||||
role: 'Shadow guard that must test the root segment once dotted keys exist'
|
||||
- symbol: 'finalizeImportEdges'
|
||||
file: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts'
|
||||
lines: '398-406, 434-447'
|
||||
role: 'Carries importedName onto ImportEdge.targetExportedName for namespace edges'
|
||||
|
||||
related_symbols:
|
||||
- symbol: 'resolveQualifiedReceiverMember'
|
||||
relationship: 'ScopeResolver hook, C++-only implementer'
|
||||
relevance: 'Case 1.5 — deliberately NOT the fix path; implementing it for Python would duplicate what Case 1 already does'
|
||||
- symbol: 'resolveConstructionExpressionClass'
|
||||
relationship: 'consumes namespaceTargets by parameter'
|
||||
relevance: 'Second consumer of the map; gains correct pkg.db.Model() resolution'
|
||||
- symbol: 'resolveCompoundReceiverClass'
|
||||
relationship: 'consumes namespaceTargets by parameter (compound-receiver.ts:759-766)'
|
||||
relevance: 'Third consumer; has() now true for dotted namespaces'
|
||||
- symbol: 'isNamespaceImport'
|
||||
relationship: 'finalize hook added by #2770'
|
||||
relevance: 'Prior art — how the from-pkg-import-db sibling was made to resolve'
|
||||
- symbol: 'build'
|
||||
relationship: 'test-of collectNamespaceTargets'
|
||||
relevance: 'test/unit/scope-resolution/python/python-module-namespace-construction.test.ts — re-run after the change'
|
||||
|
||||
execution_path:
|
||||
- 'splitImportStatement emits one match per imported name; @import.source = full dotted_name text'
|
||||
- 'interpretPythonImport plain arm → ParsedImport{kind:namespace, localName:first-segment, importedName:full-dotted}'
|
||||
- 'finalizeImportEdges → ImportEdge{localName, targetExportedName=importedName, targetFile, kind:namespace}'
|
||||
- 'collectNamespaceTargets builds Map keyed on localName only ← DEFECT'
|
||||
- 'scope-extractor extractExplicitReceiver takes raw text of the attribute object → "pkg.db"'
|
||||
- 'emitReceiverBoundCalls Case 0 declines (module, not class), falls through without marking handled'
|
||||
- 'Case 1 map lookup on "pkg.db" misses; Case 1.5 skipped (no Python hook); site drops silently'
|
||||
|
||||
pdg_constraints: [] # index has zero CDG rows; no --pdg layer to slice
|
||||
|
||||
architectural_patterns:
|
||||
- pattern: 'Provider reclassification at finalize instead of shared-code special-casing'
|
||||
example_location: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107 (isNamespaceImport, #2770)'
|
||||
usage_guidance: 'Considered and rejected here: the information needed is already on the finalized edge, so no new hook is warranted'
|
||||
- pattern: 'Verified namespace is authoritative — do not fall through to workspace-wide simple-name heuristics'
|
||||
example_location: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts:245-259'
|
||||
usage_guidance: 'Because that branch declines rather than guessing, a wrong key costs a lost edge, not a wrong one — but the shadow guard must be right'
|
||||
- pattern: 'Fixture + assertions in test/integration/resolvers/python.test.ts'
|
||||
example_location: 'gitnexus/test/integration/resolvers/python.test.ts:562-600 (vendored-django guard)'
|
||||
usage_guidance: 'mkdtempSync + writeFixtureRepo + afterAll rmSync; assert both presence of the right edge and absence of the wrong one'
|
||||
|
||||
files_to_modify:
|
||||
- file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
symbols: ['collectNamespaceTargets']
|
||||
intended_change: 'Additionally key the map on edge.targetExportedName when it contains a dot and its first segment equals edge.localName; keep the existing localName key unchanged; update the header contract note'
|
||||
- file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
symbols: ['isNamespaceNameShadowed']
|
||||
intended_change: 'Shadow-test the first dot-separated segment of namespaceName (no-op for single-segment names)'
|
||||
- file: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
symbols: []
|
||||
intended_change: 'Add a describe block covering the six §8 scenarios'
|
||||
- file: 'gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/'
|
||||
symbols: []
|
||||
intended_change: 'New fixture per the §8 table'
|
||||
- file: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts'
|
||||
symbols: []
|
||||
intended_change: 'Delete — superseded by the fixture-backed tests'
|
||||
|
||||
tests:
|
||||
- file: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
scenarios:
|
||||
- 'import pkg.db + pkg.db.session_scope() → run pipeline → CALLS uses_dotted → pkg/db.py:session_scope, reason import-resolved'
|
||||
- 'three sibling spellings in the same repo → run pipeline → all still resolve to pkg/db.py:session_scope (control)'
|
||||
- 'import pkg.db AND import pkg.cache in one file, both defining session_scope → each call resolves only to its own module; assert the crossed pair is ABSENT'
|
||||
- 'import pkg.sub.deep + pkg.sub.deep.f() → 3-segment receiver resolves'
|
||||
- 'module-level import pkg.db shadowed by a function-local pkg = Decoy() → NO edge to pkg/db.py'
|
||||
- 'existing TypeScript/C#/Go namespace-import resolver tests → unchanged green (no key minted for them)'
|
||||
- file: 'gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts'
|
||||
scenarios:
|
||||
- 'Re-run unchanged; extend only if it enumerates map keys exhaustively'
|
||||
|
||||
verification_commands:
|
||||
- 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts'
|
||||
- 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers'
|
||||
- 'cd gitnexus && npm run test:unit -- test/unit/scope-resolution'
|
||||
- 'cd gitnexus && npx tsc --noEmit'
|
||||
- 'cd gitnexus-shared && npx tsc --noEmit'
|
||||
- 'cd gitnexus && node --import tsx bench/receiver-resolution/measure.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/python-scope/measure.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/python-scope/import-target-fingerprint.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/scope-capture/measure.mjs --check'
|
||||
|
||||
risks:
|
||||
- 'New map keys reach three consumers, two of them by parameter rather than by call — the graph d=1 list alone under-reports them'
|
||||
- 'compound-receiver treats a verified namespace as authoritative and declines instead of falling through, so a bad key loses edges silently'
|
||||
- 'bench/receiver-resolution/baseline.json is expected to move; regenerate ONCE in the final step'
|
||||
- 'Default 5000 ms worker-ready timeout crash-loops on this host; export GITNEXUS_WORKER_READY_TIMEOUT_MS=60000'
|
||||
|
||||
assumptions:
|
||||
- 'Every non-Python provider fails the dotted-rooted-at-localName predicate. CHECK: grep "kind: .namespace." across gitnexus/src/core/ingestion/languages/*/interpret.ts and confirm localName is never the first segment of a dotted importedName. Verified at b2cd1c2ad for typescript, csharp, go, rust, ruby.'
|
||||
- 'python.test.ts is no longer gated behind REGISTRY_PRIMARY_PYTHON. CHECK: grep REGISTRY_PRIMARY in that file — zero hits at b2cd1c2ad, so it runs unconditionally.'
|
||||
- 'The GitNexus index is 13 commits behind but byte-identical on every cited path. CHECK: git rev-parse 1ef6447e:<path> vs b2cd1c2ad:<path>.'
|
||||
|
||||
open_questions:
|
||||
- 'Should the misleading localName-only key for a dotted import (pkg → pkg/db.py) be removed? It can produce a false positive today: pkg.helper() resolves into pkg/db.py if db.py happens to define helper. Deferred — a separate behaviour change needing its own regression pass.'
|
||||
- 'Python`s `import a.b.c` also makes `a.b` reachable. The proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Deferred as a narrower follow-up.'
|
||||
- 'C# `using System.Collections.Generic` + `System.Collections.Generic.List` is the same class of gap and is deliberately NOT addressed here (its localName is the last segment, so the predicate declines). Worth its own issue.'
|
||||
|
||||
avoid:
|
||||
- 'Do not repeat full repository discovery'
|
||||
- 'Do not replace established patterns without evidence'
|
||||
- 'Do not implement resolveQualifiedReceiverMember for Python — Case 1 already does this job; a second path would double-resolve'
|
||||
- 'Do not change ImportEdge.localName for dotted imports (interpret.ts:38) — it is the deliberate `import a.b.c exposes a` semantics and other consumers depend on it'
|
||||
- 'Do not name a language in gitnexus/src/core/ingestion/ shared code (AGENTS.md §42)'
|
||||
- 'Do not regenerate bench baselines per step — only once, in the final step'
|
||||
- 'Do not weaken an existing test to accommodate the new keys; extend it instead'
|
||||
```
|
||||
|
||||
## 12. Assumptions and Open Questions
|
||||
|
||||
**Assumptions** (each re-checkable cheaply by the executor):
|
||||
|
||||
1. Every non-Python namespace-emitting provider fails the `dotted && first segment === localName` predicate. Verified at `b2cd1c2ad` for TypeScript, C#, Go, Rust and Ruby by reading each `interpret.ts`; JavaScript, Java and PHP emit no `kind: 'namespace'` import there. **Re-check:** grep `kind: 'namespace'` across `gitnexus/src/core/ingestion/languages/*/interpret.ts`.
|
||||
2. `python.test.ts` runs unconditionally — no `REGISTRY_PRIMARY_PYTHON` gate remains at the pinned commit (zero grep hits). An older parity-leg convention no longer applies.
|
||||
3. The index's 13-commit lag is harmless here because every cited path is byte-identical at the index commit and the pinned commit.
|
||||
|
||||
**Open questions / explicitly deferred:**
|
||||
|
||||
- **The bogus first-segment key.** For `import pkg.db`, the map still holds `'pkg' → ['pkg/db.py']`, so `pkg.helper()` would resolve into `pkg/db.py` if that file happens to define `helper` — a pre-existing false positive this plan does **not** fix. Removing it is a separate behaviour change with its own regression surface (`python-multi-segment-ancestor-import`, `python-bare-import`). Worth pinning the current behaviour in a test so it is visible rather than silent.
|
||||
- **`import a.b.c` also binds `a.b`.** Python makes intermediate packages reachable; the proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Narrower follow-up.
|
||||
- **C# has the mirror-image gap.** `using System.Collections.Generic` + `System.Collections.Generic.List` fails the predicate because C# sets `localName` to the *last* segment. Deliberately out of scope; deserves its own issue.
|
||||
- **Construction coverage.** §8 does not currently include a `pkg.db.Model()` row. Add one if the fixture cost is trivial — that path (`compound-receiver.ts:245-260`) changes behaviour and is otherwise untested by this plan.
|
||||
|
||||
## 13. Definition of Done
|
||||
|
||||
1. `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope` (`reason: 'import-resolved'`) is emitted, asserted by a fixture-backed test in `python.test.ts`.
|
||||
2. All three sibling control rows still resolve in the same run.
|
||||
3. `import pkg.a` + `import pkg.b` in one file resolve only to their own modules; the crossed pair is asserted **absent**.
|
||||
4. A 3-segment receiver resolves; a package root shadowed by a local binding does **not**.
|
||||
5. The scratch `repro-2826-python-dotted-import.test.ts` is deleted.
|
||||
6. No file under `gitnexus/src/core/ingestion/` names a language.
|
||||
7. `npm run test:integration -- test/integration/resolvers` and `npm run test:unit -- test/unit/scope-resolution` pass; `tsc --noEmit` clean in both packages.
|
||||
8. Every bench `--check` in §8 passes, with `bench/receiver-resolution/baseline.json` regenerated exactly once in the final commit if and only if it moved — and any movement in `python-scope`/`scope-capture` investigated rather than regenerated.
|
||||
|
|
@ -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'}). ` +
|
||||
|
|
|
|||
|
|
@ -5,9 +5,19 @@
|
|||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
* 3. npm 11+ without pnpm but with bunx → `bunx` (dodges the same crash)
|
||||
* 4. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 5. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 6. bun-only → `bunx`
|
||||
* 7. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The bun branches exist because a Node toolchain is no longer implied: on a
|
||||
* bun-only machine npm, npx and pnpm are all absent, so every rung above
|
||||
* resolved to `npx` and the emitted command could not run at all. `bunx` is
|
||||
* bun's install-free one-shot runner and needs no allow-build equivalent — bun
|
||||
* skips lifecycle scripts unconditionally for a `bunx` fetch, which the native
|
||||
* loader recovers from directly (see core/lbug/native-check.ts). Both bun rungs
|
||||
* gate on `bunx` actually running, not merely existing on PATH — see `hasBun`.
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
|
|
@ -42,7 +52,12 @@ const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
|||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
// under a second, so the realistic cost is far lower. The bun rungs add at most
|
||||
// one more 1s probe (`bunx --version`), reached only when pnpm is unusable and
|
||||
// npm is 11+ or unreadable, for a ~8s theoretical cap. That cap needs an absent
|
||||
// pnpm to burn its full second, which only Windows can do (`shell: true` spawns
|
||||
// cmd.exe); on POSIX an absent pnpm ENOENTs in ~1ms, so the real ceiling is
|
||||
// unmoved.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
|
|
@ -104,9 +119,17 @@ function resolveOnPath(
|
|||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// One spawn of `<command> --version` → { ran, major, minor } (versions null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
//
|
||||
// `ran` is liveness, kept separate from the version because a PATH hit proves a
|
||||
// file exists, not that it works, and the two answers differ: a banner-printing
|
||||
// or oddly-versioned tool is alive with `major: null`, while a stale shim left by
|
||||
// a partial uninstall is neither. Only the bun rung consults `ran` today (see
|
||||
// hasBun) — it is the one runner with no version to read, so a dedicated probe is
|
||||
// its only liveness signal; pnpm gets the same evidence for free from the version
|
||||
// spawn it must make anyway, and deliberately forgives an unreadable one (#1939).
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
|
|
@ -131,11 +154,13 @@ function probeVersion(command) {
|
|||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
ran: true,
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
// Spawn failure, non-zero exit, or the timeout — the command did not run.
|
||||
return { ran: false, major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +201,14 @@ function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* Resolve `gitnexus` | `pnpm` | `bun` | `npx`. `GITNEXUS_INVOCATION` forces a
|
||||
* mode (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
* inject `{ npmMajor, pnpmMajor, bunPresent, bunRuns }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx' || forced === 'bun') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
|
@ -202,12 +227,33 @@ function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
|||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// bun usability is resolved lazily: only the two branches below can select it,
|
||||
// so a machine with pnpm, or with npm < 11, never pays the PATH scan or the
|
||||
// spawn. `bunx` (not `bun`) is probed because `bunx` is what the resolved
|
||||
// command actually runs. Two gates, `&&`-ordered cheapest first: a spawn-free
|
||||
// PATH scan, then liveness — a PATH hit alone would route a present-but-broken
|
||||
// shim to a command that can only fail at execution time.
|
||||
let bunCache;
|
||||
const hasBun = () => {
|
||||
if (bunCache === undefined) {
|
||||
const present = 'bunPresent' in deps ? Boolean(deps.bunPresent) : Boolean(probe('bunx'));
|
||||
bunCache = present && ('bunRuns' in deps ? Boolean(deps.bunRuns) : probeVersion('bunx').ran);
|
||||
}
|
||||
return bunCache;
|
||||
};
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// Same crash, no pnpm to fall back on: bunx is install-free and unaffected.
|
||||
if (npmMajor !== null && npmMajor >= 11 && hasBun()) return 'bun';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
// Neither npm nor pnpm — bunx is the only install-free runner left. Without
|
||||
// this rung a bun-only machine fell through to `npx`, which is not installed
|
||||
// there, so the emitted command failed with "npx: command not found".
|
||||
if (hasBun()) return 'bun';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
|
@ -218,8 +264,25 @@ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
|||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* bun's install-free one-shot runner. Deliberately flag-free: bun has no
|
||||
* per-invocation `--allow-build` equivalent (`--trust` is a `bun add`/`bun
|
||||
* install` flag that writes trustedDependencies into a project package.json,
|
||||
* which a one-shot `bunx` has nowhere to put), so the skipped lifecycle copy is
|
||||
* recovered by the native loader instead of by the invocation.
|
||||
*/
|
||||
function formatBunxCommand(gitnexusArgs) {
|
||||
return `bunx ${NPX_REF} ${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
|
||||
|
|
@ -238,7 +301,8 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
const mightUsePnpm =
|
||||
forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx' && forced !== 'bun');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
|
|
@ -252,6 +316,7 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
if (mode === 'bun') return formatBunxCommand(`analyze${suffix}`);
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +333,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'bun') return { program: 'bunx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
|
|
@ -279,6 +345,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatBunxCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
|
|
@ -291,7 +358,7 @@ module.exports = {
|
|||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `bunx`, else `npx`), so no package-manager assumption and no global install is required — including on a bun-only machine, which has no npm, npx or pnpm at all.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root, or `bunx gitnexus@latest analyze` on a bun-only machine. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`), or use `bunx gitnexus@latest analyze`, or `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
|
|||
| 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) |
|
||||
| `--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) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -53,6 +84,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
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.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
@ -61,6 +100,7 @@ description: "Use when the user wants to know what will break if they change som
|
|||
```
|
||||
impact({
|
||||
target: "validateUser",
|
||||
repo: "my-app", // required once >1 repository is indexed
|
||||
direction: "upstream",
|
||||
minConfidence: 0.8,
|
||||
maxDepth: 3
|
||||
|
|
@ -84,10 +124,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)
|
||||
|
||||
|
|
@ -95,4 +151,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.
|
||||
|
|
|
|||
|
|
@ -148,13 +148,19 @@ finding is NOT proof of safety.
|
|||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
Taint models cover four `SupportedLanguages` ids across three files:
|
||||
TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses
|
||||
`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model
|
||||
for the language you are targeting. The explicit
|
||||
`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four;
|
||||
it is not an import side effect.
|
||||
|
||||
The spec is hashable data (no functions). A sanitizer's `neutralizes` lists
|
||||
the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert
|
||||
the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript
|
||||
use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java
|
||||
model matches are covered by `python-model-match.test.ts` and
|
||||
`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,37 +58,49 @@ 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** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
|
@ -73,6 +115,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"})
|
||||
|
||||
|
|
@ -81,10 +124,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)
|
||||
|
||||
|
|
@ -92,4 +151,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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
|||
|
||||
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
|
||||
// Data model (RFC §2)
|
||||
export type { ParameterTypeClass, SymbolDefinition } from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ParameterTypeClass,
|
||||
SymbolDefinition,
|
||||
TypeParameter,
|
||||
} from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ScopeId,
|
||||
DefId,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,6 +24,38 @@ export interface ParameterTypeClass {
|
|||
templateArguments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared generic/template TYPE PARAMETER — `T` in `class Box<T extends
|
||||
* Repo>`, `template <class T> struct Vec`, `interface Repo<T>`.
|
||||
*
|
||||
* NOT the same axis as `SymbolDefinition.templateArguments`, and conflating the
|
||||
* two is the defect this shape exists to end. `templateArguments` records the
|
||||
* arguments a declaration was written AGAINST (`template <> struct Vec<bool>` →
|
||||
* `['bool']`); `typeParameters` records the parameters it was written IN TERMS
|
||||
* OF. A declaration can carry both — a C++ partial specialization
|
||||
* `template <class T> struct Vec<T*>` has `templateArguments: ['T*']` AND
|
||||
* `typeParameters: [{name: 'T'}]` — and that pairing is precisely what tells a
|
||||
* partial specialization apart from the full specialization `template <> struct
|
||||
* Vec<T*>`, which carries the identical `templateArguments` and NO parameters.
|
||||
*/
|
||||
export interface TypeParameter {
|
||||
/** The parameter's declared name, exactly as written (`T`, `Ts`, `TKey`). */
|
||||
name: string;
|
||||
/**
|
||||
* The declared upper bound / constraint, verbatim and un-split, when the
|
||||
* declaration states one inline: `T extends Repo` → `Repo`, `T : Repo` →
|
||||
* `Repo`, `T extends Repo & Closeable` → `Repo & Closeable`.
|
||||
*
|
||||
* VERBATIM because the intersection/compound spellings differ per language
|
||||
* and a shared consumer that wants the first bound can take the first token
|
||||
* itself, while one that wants to round-trip the source cannot recover what a
|
||||
* split threw away. Absent when the parameter is unbounded, and absent when
|
||||
* the bound is declared OUT OF LINE (C# `where T : IRepo`, Kotlin/Rust
|
||||
* `where` clauses) — see `parseTypeParameterList`.
|
||||
*/
|
||||
bound?: string;
|
||||
}
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
|
|
@ -48,6 +80,18 @@ export interface SymbolDefinition {
|
|||
declaredType?: string;
|
||||
/** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
|
||||
templateArguments?: string[];
|
||||
/**
|
||||
* Declared generic/template TYPE PARAMETERS, in DECLARATION ORDER — see
|
||||
* {@link TypeParameter} for how this differs from `templateArguments`.
|
||||
*
|
||||
* ORDER IS LOAD-BEARING: substitution is positional (`Repo<User>` binds the
|
||||
* FIRST parameter), so a set or a name-keyed map would discard exactly the
|
||||
* information this carries. Absent for a non-generic declaration and for every
|
||||
* language whose captures do not populate it, so a reader MUST treat absence
|
||||
* as "unknown", never as "not generic" — the two are indistinguishable here
|
||||
* and only the first is safe to act on.
|
||||
*/
|
||||
typeParameters?: TypeParameter[];
|
||||
/** Per-language constraint payload for template / generic overloads
|
||||
* (e.g. C++ `enable_if_t<P, T>` predicate trees, C++20 `requires` clauses).
|
||||
* Opaque to shared code — the producing language adapter owns the shape
|
||||
|
|
@ -63,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';
|
||||
}
|
||||
|
|
|
|||
335
gitnexus-web/package-lock.json
generated
335
gitnexus-web/package-lock.json
generated
|
|
@ -11,14 +11,14 @@
|
|||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@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.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -28,44 +28,44 @@
|
|||
"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",
|
||||
"mermaid": "^11.15.0",
|
||||
"lucide-react": "^1.28.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.10",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"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.2.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
"wait-on": "^9.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -289,9 +289,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@braintree/sanitize-url": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz",
|
||||
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
|
|
@ -1172,13 +1172,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"
|
||||
},
|
||||
|
|
@ -1330,12 +1330,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
|
||||
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
|
|
@ -1404,19 +1404,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@renovatebot/pep440": {
|
||||
|
|
@ -1741,47 +1741,47 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1795,9 +1795,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1811,9 +1811,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1827,9 +1827,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1843,9 +1843,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1859,9 +1859,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1878,9 +1878,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1897,9 +1897,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1916,9 +1916,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1935,9 +1935,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
|
|
@ -2024,9 +2024,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2040,9 +2040,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2056,14 +2056,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
|
|
@ -2589,9 +2589,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": {
|
||||
|
|
@ -2788,9 +2788,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": {
|
||||
|
|
@ -3458,9 +3458,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
||||
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
|
|
@ -4009,9 +4009,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
|
@ -4109,9 +4109,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -4173,9 +4173,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
|
|
@ -4899,12 +4899,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
|
|
@ -5333,9 +5333,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.27",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz",
|
||||
"integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
|
|
@ -5363,13 +5363,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 +5377,7 @@
|
|||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.0"
|
||||
"@langchain/core": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
|
|
@ -5715,9 +5715,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.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -6126,26 +6126,26 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
|
||||
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
|
||||
"version": "11.16.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.1",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.25",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
|
|
@ -7211,35 +7211,35 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
|
|
@ -7393,34 +7393,34 @@
|
|||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -7933,9 +7933,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
|
|
@ -8527,15 +8527,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
|
@ -8550,14 +8541,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
|
||||
"integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.1.0.tgz",
|
||||
"integrity": "sha512-PymrLXHLBM1Ju/Xspb2ADUhbPSMvbnuNvy/mN2hWtpbJ3da0h3Ky1LqwKPG5QSVR57liyO0iUpfipYl/s5qNvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"axios": "^1.18.1",
|
||||
"joi": "^18.2.3",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
|
|
|
|||
|
|
@ -21,14 +21,14 @@
|
|||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@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.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -38,44 +38,44 @@
|
|||
"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",
|
||||
"mermaid": "^11.15.0",
|
||||
"lucide-react": "^1.28.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.10",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"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.2.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@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",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
"wait-on": "^9.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/static-config": {
|
||||
|
|
|
|||
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal file
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState } from 'react';
|
||||
import { Key } from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAuthToken, setAuthToken } from '../services/backend-client';
|
||||
import { SecretInput } from './settings/SecretInput';
|
||||
|
||||
interface AccessTokenPromptProps {
|
||||
/** Called after the token is stored, so the caller can re-probe immediately. */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown instead of the "start a server" guide when the backend answers 401:
|
||||
* the deploy is up, it just needs the access token its operator generated.
|
||||
*
|
||||
* The token is held in sessionStorage for this browser session only — see
|
||||
* AUTH_TOKEN_STORAGE_KEY. Nothing here logs it or puts it in a URL.
|
||||
*/
|
||||
export const AccessTokenPrompt = ({ onSubmit }: AccessTokenPromptProps) => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [token, setToken] = useState(getAuthToken);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAuthToken(token);
|
||||
onSubmit?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="animate-fade-in rounded-3xl border border-border-default bg-surface p-7"
|
||||
>
|
||||
<div className="mb-5 text-center">
|
||||
<div className="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
|
||||
<Key className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
{t('accessToken.title')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-relaxed text-text-secondary">
|
||||
{t('accessToken.promptHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SecretInput
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
label={t('accessToken.label')}
|
||||
placeholder={t('accessToken.placeholder')}
|
||||
revealLabel={t('accessToken.reveal')}
|
||||
hideLabel={t('accessToken.hide')}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 w-full cursor-pointer rounded-xl bg-accent px-4 py-3 text-sm font-medium text-white shadow-glow-soft transition-all hover:bg-accent/90 hover:shadow-glow"
|
||||
>
|
||||
{t('accessToken.connect')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 border-t border-border-subtle pt-4 text-center text-xs leading-relaxed text-text-muted">
|
||||
{t('accessToken.sessionNote')}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type BackendRepo,
|
||||
} from '../services/backend-client';
|
||||
import { useBackend } from '../hooks/useBackend';
|
||||
import { AccessTokenPrompt } from './AccessTokenPrompt';
|
||||
import { OnboardingGuide } from './OnboardingGuide';
|
||||
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
|
||||
import { RepoLanding } from './RepoLanding';
|
||||
|
|
@ -147,6 +148,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
const {
|
||||
isConnected,
|
||||
isProbing,
|
||||
isUnauthorized,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
isPolling,
|
||||
|
|
@ -310,8 +312,20 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* The backend is up but gated — asking for a token is the only useful
|
||||
thing to show. The "run gitnexus serve" guide would be wrong advice. */}
|
||||
{isUnauthorized && !isConnected && (
|
||||
<AccessTokenPrompt
|
||||
onSubmit={() => {
|
||||
// The polling chain is already running while disconnected; it
|
||||
// picks up the new token on its next tick and auto-connects.
|
||||
if (!isPolling) startPolling();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Crossfade between phases */}
|
||||
{displayPhase && (
|
||||
{!isUnauthorized && displayPhase && (
|
||||
<Crossfade activeKey={displayPhase}>
|
||||
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
|
||||
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,17 @@ import {
|
|||
getAvailableModels,
|
||||
fetchOpenRouterModels,
|
||||
} from '../core/llm/settings-service';
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
import { getAuthToken, setAuthToken } from '../services/backend-client';
|
||||
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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
|
|
@ -253,6 +261,8 @@ export const SettingsPanel = ({
|
|||
const { t } = useTranslation(['common', 'settings']);
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
/** Deploy access token. Stored outside LLM settings, persisted on Save. */
|
||||
const [authToken, setAuthTokenState] = useState(getAuthToken);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
// Ollama connection state
|
||||
|
|
@ -275,6 +285,7 @@ export const SettingsPanel = ({
|
|||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings(loadSettings());
|
||||
setAuthTokenState(getAuthToken());
|
||||
setSaveStatus('idle');
|
||||
setOllamaError(null);
|
||||
}
|
||||
|
|
@ -315,6 +326,10 @@ export const SettingsPanel = ({
|
|||
const handleSave = () => {
|
||||
try {
|
||||
saveSettings(settings);
|
||||
// The token persists on Save with everything else, not per keystroke: it
|
||||
// is the only affordance this panel gives for "committed", and a
|
||||
// half-typed token would otherwise ride the next probe.
|
||||
setAuthToken(authToken);
|
||||
setSaveStatus('saved');
|
||||
onSettingsSaved?.();
|
||||
if (saveTimerRef.current) {
|
||||
|
|
@ -332,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',
|
||||
|
|
@ -372,6 +401,25 @@ export const SettingsPanel = ({
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
{/* Deploy access token. Rendered unconditionally, unlike the Local
|
||||
Server block below, which only appears when a caller passes the
|
||||
backend-URL props. An empty token is a valid state — a local
|
||||
`gitnexus serve` or `docker compose` deploy has no gate. */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
{t('settings:accessToken.label')}
|
||||
</label>
|
||||
<SecretInput
|
||||
value={authToken}
|
||||
onChange={setAuthTokenState}
|
||||
label={t('settings:accessToken.label')}
|
||||
placeholder={t('settings:accessToken.placeholder')}
|
||||
revealLabel={t('settings:accessToken.reveal')}
|
||||
hideLabel={t('settings:accessToken.hide')}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">{t('settings:accessToken.hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Local Server */}
|
||||
{backendUrl !== undefined && onBackendUrlChange && (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -836,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) =>
|
||||
|
|
@ -847,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 */}
|
||||
|
|
|
|||
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal file
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react';
|
||||
import { Eye, EyeOff } from '@/lib/lucide-icons';
|
||||
|
||||
interface SecretInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Accessible name for the field — the visible `<label>` is the caller's. */
|
||||
label: string;
|
||||
/** Accessible name for the toggle in each of its two states. */
|
||||
revealLabel: string;
|
||||
hideLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masked text field with a reveal toggle, for secrets the user pastes in:
|
||||
* deploy access tokens, provider API keys. `type="password"` until the user
|
||||
* asks otherwise, and the value is never logged or put in a URL by anything
|
||||
* here.
|
||||
*
|
||||
* Reveal state lives inside the component, so a caller that unmounts the field
|
||||
* (the settings panel returns `null` when closed) reopens masked.
|
||||
*/
|
||||
export const SecretInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
revealLabel,
|
||||
hideLabel,
|
||||
}: SecretInputProps) => {
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={isRevealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-11 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRevealed((prev) => !prev)}
|
||||
aria-label={isRevealed ? hideLabel : revealLabel}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
{isRevealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,21 @@ export const DEFAULT_BACKEND_URL =
|
|||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* sessionStorage key for the deploy access token sent as
|
||||
* `Authorization: Bearer <token>` on every `/api/*` request.
|
||||
*
|
||||
* sessionStorage, not localStorage: `core/llm/settings-service.ts` already
|
||||
* migrated provider API keys off localStorage and deletes the legacy copy. A
|
||||
* deploy token is the same class of secret, so it gets the same one-session
|
||||
* lifetime.
|
||||
*
|
||||
* Never a cookie: the browser attaches cookies to cross-site requests and
|
||||
* forwards them blind, and the public edge strips `Origin` before proxying, so
|
||||
* no CSRF backstop is left to catch it. A header is not sent automatically.
|
||||
*/
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
|
||||
|
||||
/**
|
||||
* Default node-count above which the WebUI connects in chat-only mode (skips
|
||||
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -1233,7 +1233,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
}
|
||||
}
|
||||
|
||||
return `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.${multipleMatchWarning}`;
|
||||
// An empty UPSTREAM walk is not evidence of disuse — it is the absence
|
||||
// of evidence. The symbol may be reached only through a reference class
|
||||
// the index does not record (a property access on a plain object, a
|
||||
// dynamic dispatch, a call from a language whose resolver is weaker
|
||||
// here). The Node/MCP path reports `risk: UNKNOWN` with a `riskNote`
|
||||
// for exactly this case; this surface answers in prose rather than an
|
||||
// enum, so it carries the same MEANING rather than the same field —
|
||||
// saying "appears to be unused" here is the identical false certainty.
|
||||
//
|
||||
// Downstream keeps its wording: no outgoing dependencies really does
|
||||
// describe the symbol itself, not a claim about the rest of the repo.
|
||||
return direction === 'upstream'
|
||||
? `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This does NOT establish the symbol is unused — an empty caller set can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). Confirm with a text search before treating it as dead code.${multipleMatchWarning}`
|
||||
: `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be self-contained (no outgoing dependencies).${multipleMatchWarning}`;
|
||||
}
|
||||
|
||||
const depth1 = byDepth.get(1) || [];
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
readFile as backendReadFile,
|
||||
startEmbeddings as backendStartEmbeddings,
|
||||
streamEmbeddingProgress,
|
||||
probeBackend,
|
||||
probeBackendStatus,
|
||||
// Aliased: switchRepo declares a local `let repoIdentity` that would shadow
|
||||
// a plain named import of this helper.
|
||||
repoIdentity as repoIdentityOf,
|
||||
|
|
@ -516,7 +516,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}, []);
|
||||
|
||||
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
|
||||
return probeBackend();
|
||||
return (await probeBackendStatus()) === 'ok';
|
||||
}, []);
|
||||
|
||||
// Embedding methods — now trigger server-side via /api/embed
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
|
||||
import { probeBackendStatus, setBackendUrl as setServiceUrl } from '../services/backend-client';
|
||||
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
|
||||
|
||||
// ── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
|
@ -13,6 +13,12 @@ export interface UseBackendResult {
|
|||
isConnected: boolean;
|
||||
/** Currently checking connection */
|
||||
isProbing: boolean;
|
||||
/**
|
||||
* The last probe got a 401 from the public edge's token gate. The deploy is
|
||||
* reachable; it just needs an access token. Use it to prompt for one instead
|
||||
* of telling the user to start a server that is already running.
|
||||
*/
|
||||
isUnauthorized: boolean;
|
||||
/** Current backend URL */
|
||||
backendUrl: string;
|
||||
/** Start polling for server availability (setTimeout chain, visibility-aware) */
|
||||
|
|
@ -36,6 +42,7 @@ export function useBackend(): UseBackendResult {
|
|||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||
|
||||
// Race-condition guard: monotonically increasing probe ID
|
||||
const probeIdRef = useRef(0);
|
||||
|
|
@ -47,13 +54,15 @@ export function useBackend(): UseBackendResult {
|
|||
setIsProbing(true);
|
||||
|
||||
try {
|
||||
const ok = await probeBackend();
|
||||
const status = await probeBackendStatus();
|
||||
if (id !== probeIdRef.current) return false;
|
||||
setIsConnected(ok);
|
||||
return ok;
|
||||
setIsConnected(status === 'ok');
|
||||
setIsUnauthorized(status === 'unauthorized');
|
||||
return status === 'ok';
|
||||
} catch {
|
||||
if (id === probeIdRef.current) {
|
||||
setIsConnected(false);
|
||||
setIsUnauthorized(false);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
|
|
@ -147,6 +156,7 @@ export function useBackend(): UseBackendResult {
|
|||
return {
|
||||
isConnected,
|
||||
isProbing,
|
||||
isUnauthorized,
|
||||
backendUrl,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
|
|||
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
|
||||
case 'not_found':
|
||||
return t('errors:backend.notFound', { defaultValue: fallback });
|
||||
case 'unauthorized':
|
||||
return t('errors:backend.unauthorized', { defaultValue: fallback });
|
||||
case 'origin_blocked':
|
||||
return t('errors:backend.originBlocked', { defaultValue: fallback });
|
||||
case 'client':
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"timeout": "The server took too long to respond. Try again in a moment.",
|
||||
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
|
||||
"notFound": "The requested repository or resource was not found.",
|
||||
"unauthorized": "This GitNexus deploy requires an access token. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable, then paste it into Settings.",
|
||||
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
|
||||
"client": "Request failed: {{message}}",
|
||||
"server": "Server error: {{message}}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@
|
|||
"connected": "Connected",
|
||||
"notConnected": "Not connected",
|
||||
"runServeHint": "Run `gitnexus serve` to connect the web UI to a local backend.",
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Paste your deploy access token",
|
||||
"hint": "Required only for a gated deploy. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable. Leave empty for a local server.",
|
||||
"title": "This deploy requires an access token",
|
||||
"promptHint": "The GitNexus server is running but gated. Find the token in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable.",
|
||||
"connect": "Connect",
|
||||
"reveal": "Show access token",
|
||||
"hide": "Hide access token",
|
||||
"sessionNote": "Stored for this browser session only. You will re-enter it in a new tab or after closing the browser."
|
||||
},
|
||||
"provider": "Provider",
|
||||
"apiKey": "API Key",
|
||||
"learnMore": "Learn more",
|
||||
|
|
@ -65,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"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"timeout": "服务器响应超时,请稍后重试。",
|
||||
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
|
||||
"notFound": "未找到请求的仓库或资源。",
|
||||
"unauthorized": "GitNexus 部署需要访问令牌。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看,然后粘贴到「设置」中。",
|
||||
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
|
||||
"client": "请求失败:{{message}}",
|
||||
"server": "服务器错误:{{message}}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@
|
|||
"connected": "已连接",
|
||||
"notConnected": "未连接",
|
||||
"runServeHint": "运行 `gitnexus serve` 将 Web UI 连接到本地后端。",
|
||||
"accessToken": {
|
||||
"label": "访问令牌",
|
||||
"placeholder": "粘贴部署访问令牌",
|
||||
"hint": "仅在启用访问控制的部署中需要。可在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中找到。本地服务器请留空。",
|
||||
"title": "此部署需要访问令牌",
|
||||
"promptHint": "GitNexus 服务器正在运行,但已启用访问控制。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看该令牌。",
|
||||
"connect": "连接",
|
||||
"reveal": "显示访问令牌",
|
||||
"hide": "隐藏访问令牌",
|
||||
"sessionNote": "仅在当前浏览器会话中保存。新标签页或重新打开浏览器后需要重新输入。"
|
||||
},
|
||||
"provider": "提供商",
|
||||
"apiKey": "API Key",
|
||||
"learnMore": "了解更多",
|
||||
|
|
@ -65,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"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
import { LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD } from '../config/ui-constants';
|
||||
import {
|
||||
AUTH_TOKEN_STORAGE_KEY,
|
||||
LARGE_GRAPH_NODE_THRESHOLD,
|
||||
LARGE_GRAPH_EDGE_THRESHOLD,
|
||||
} from '../config/ui-constants';
|
||||
import { decideSkipGraph } from '../lib/graph-load-decision';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -92,7 +96,12 @@ export class BackendError extends Error {
|
|||
// The write-route same-host Origin guard rejected this request (HTTP 403
|
||||
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
|
||||
// 403 so the UI can show actionable "open the local UI" guidance.
|
||||
| 'origin_blocked',
|
||||
| 'origin_blocked'
|
||||
// The public edge rejected this request for a missing or wrong deploy
|
||||
// access token (HTTP 401 with `{ code: 'unauthorized' }`). Distinct from a
|
||||
// generic `client` 4xx so the UI can prompt for the token instead of
|
||||
// showing a raw error.
|
||||
| 'unauthorized',
|
||||
/**
|
||||
* Milliseconds until the caller should retry. Populated for rate-limited
|
||||
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
|
||||
|
|
@ -129,32 +138,73 @@ export interface SSEHandlers<T = unknown> {
|
|||
onMessage?: (data: T) => void;
|
||||
onComplete?: (data: T) => void;
|
||||
onError?: (error: string) => void;
|
||||
/** Fires on every successful (re)connection, once the stream is readable. */
|
||||
onOpen?: () => void;
|
||||
/**
|
||||
* Fires each time a reconnect is scheduled after a drop. Callers that want
|
||||
* "notify once per outage" dedupe on their side, resetting in `onOpen`.
|
||||
*/
|
||||
onReconnecting?: () => void;
|
||||
}
|
||||
|
||||
export interface SSEOptions {
|
||||
/** Reconnect attempts after a drop. `Infinity` for an indefinite stream. Default 3. */
|
||||
maxRetries?: number;
|
||||
/** First backoff delay; doubles per attempt. Default 1000ms. */
|
||||
baseDelayMs?: number;
|
||||
/** Upper bound on the doubling backoff. Default unbounded. */
|
||||
capDelayMs?: number;
|
||||
/**
|
||||
* Reconnect on a non-OK HTTP response as well as on a network drop. Off by
|
||||
* default: a job-progress stream that 4xx's is a real, terminal error the
|
||||
* caller has to see. A long-lived liveness stream turns it on, so a 401 from
|
||||
* the edge's token gate resolves itself once a token is entered.
|
||||
*/
|
||||
retryOnHttpError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic SSE stream consumer using fetch + ReadableStream.
|
||||
* Returns an AbortController to cancel the stream.
|
||||
* Automatically reconnects on network drops (up to 3 retries with backoff).
|
||||
* Automatically reconnects on network drops (up to `maxRetries` with backoff).
|
||||
*
|
||||
* fetch-based rather than `EventSource` because `EventSource` cannot send
|
||||
* custom headers, and every `/api/*` request needs the `Authorization` header
|
||||
* to clear the public edge's token gate.
|
||||
*/
|
||||
export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): AbortController {
|
||||
export function streamSSE<T = unknown>(
|
||||
url: string,
|
||||
handlers: SSEHandlers<T>,
|
||||
options: SSEOptions = {},
|
||||
): AbortController {
|
||||
const controller = new AbortController();
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1_000;
|
||||
const maxRetries = options.maxRetries ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 1_000;
|
||||
const capDelayMs = options.capDelayMs ?? Infinity;
|
||||
|
||||
let lastEventId = '';
|
||||
|
||||
/** Schedule the next attempt. Returns false when the budget is spent. */
|
||||
const scheduleRetry = (retryCount: number): boolean => {
|
||||
if (controller.signal.aborted || retryCount >= maxRetries) return false;
|
||||
handlers.onReconnecting?.();
|
||||
setTimeout(() => connect(retryCount + 1), Math.min(baseDelayMs * 2 ** retryCount, capDelayMs));
|
||||
return true;
|
||||
};
|
||||
|
||||
const connect = (retryCount: number) => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const headers = withAuthHeader(new Headers());
|
||||
if (lastEventId) {
|
||||
headers['Last-Event-ID'] = lastEventId;
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) {
|
||||
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
|
||||
handlers.onError?.(`Server returned ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -167,6 +217,7 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
|
|||
|
||||
// Reset retry count on successful connection
|
||||
retryCount = 0;
|
||||
handlers.onOpen?.();
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
|
@ -213,15 +264,11 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
|
|||
}
|
||||
|
||||
// Stream ended without terminal event — try to reconnect
|
||||
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
|
||||
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
|
||||
}
|
||||
scheduleRetry(retryCount);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
// Network error — attempt reconnect with backoff
|
||||
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
|
||||
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
|
||||
} else {
|
||||
if (!scheduleRetry(retryCount)) {
|
||||
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
|
||||
}
|
||||
}
|
||||
|
|
@ -288,6 +335,67 @@ export function normalizeServerUrl(input: string): string {
|
|||
return url;
|
||||
}
|
||||
|
||||
// ── Access token ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deploy access token, sent as `Authorization: Bearer <token>` on every
|
||||
* `/api/*` request. `''` when the deploy has no gate, which is a valid state;
|
||||
* `null` means "not yet read from storage". See AUTH_TOKEN_STORAGE_KEY for why
|
||||
* sessionStorage and why a header rather than a cookie.
|
||||
*/
|
||||
let _authToken: string | null = null;
|
||||
|
||||
const readStoredAuthToken = (): string => {
|
||||
try {
|
||||
if (typeof sessionStorage === 'undefined') return '';
|
||||
return sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
|
||||
} catch {
|
||||
// Storage can throw in private browsing modes — treat as no token.
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** The current access token, or `''` when the deploy is ungated. */
|
||||
export const getAuthToken = (): string => {
|
||||
if (_authToken === null) {
|
||||
_authToken = readStoredAuthToken();
|
||||
}
|
||||
return _authToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store the access token for this browser session. A whitespace-only token
|
||||
* clears it, which is how the header is disabled for an ungated local backend.
|
||||
*/
|
||||
export const setAuthToken = (token: string): void => {
|
||||
const trimmed = token.trim();
|
||||
_authToken = trimmed;
|
||||
try {
|
||||
if (typeof sessionStorage === 'undefined') return;
|
||||
if (trimmed) {
|
||||
sessionStorage.setItem(AUTH_TOKEN_STORAGE_KEY, trimmed);
|
||||
} else {
|
||||
sessionStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
// Persist failure is non-fatal: the in-memory token still authorizes this
|
||||
// tab's requests. Log the failure, never the token.
|
||||
console.warn('Failed to persist the GitNexus access token to sessionStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add `Authorization` to a header set, in place. With no token the header is
|
||||
* omitted rather than sent empty: an empty credential is malformed, not absent.
|
||||
*/
|
||||
const withAuthHeader = (headers: Headers): Headers => {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// ── Internal Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -323,6 +431,12 @@ const fetchWithTimeout = async (
|
|||
const externalSignal = init.signal;
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
|
||||
// Single chokepoint for the deploy access token — every REST call routes
|
||||
// through here. `Headers` rather than an object spread because callers pass
|
||||
// their own `headers` (e.g. `Content-Type: application/json`) and a spread
|
||||
// would drop one side or the other depending on ordering.
|
||||
const headers = withAuthHeader(new Headers(init.headers));
|
||||
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const isIdempotent = IDEMPOTENT_METHODS.has(method);
|
||||
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
|
||||
|
|
@ -348,7 +462,7 @@ const fetchWithTimeout = async (
|
|||
// single-attempt to avoid duplicate side effects.
|
||||
const response = await resilientFetch(
|
||||
url,
|
||||
{ ...init, signal },
|
||||
{ ...init, headers, signal },
|
||||
{
|
||||
breakerKey,
|
||||
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
|
||||
|
|
@ -412,13 +526,17 @@ const assertOk = async (response: Response): Promise<void> => {
|
|||
? 'not_found'
|
||||
: response.status === 429
|
||||
? 'rate_limited'
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
: // The public edge's token gate returns 401 with this discriminator;
|
||||
// surface it as a distinct code so the UI can prompt for the token.
|
||||
bodyCode === 'unauthorized'
|
||||
? 'unauthorized'
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
|
||||
// Retry-After is the standard HTTP signal for when the client may try again.
|
||||
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
|
||||
|
|
@ -460,6 +578,8 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
|||
return response.json() as Promise<ServerInfo>;
|
||||
};
|
||||
|
||||
const HEARTBEAT_MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
|
||||
* exponential backoff so transient hiccups don't reset the UI.
|
||||
|
|
@ -468,53 +588,42 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
|||
* - `onReconnecting` fires on the first retry after a drop — use it to show
|
||||
* a "reconnecting" banner while keeping the current view intact.
|
||||
*
|
||||
* Returns a cleanup function that tears down the EventSource and timers.
|
||||
* Runs on `streamSSE` rather than `EventSource`: `EventSource` cannot send
|
||||
* custom headers, so it can't clear the edge's token gate, and the heartbeat
|
||||
* would 401 forever on a gated deploy. `streamSSE` reconnects on a non-OK
|
||||
* response here (`retryOnHttpError`), so a 401 recovers on its own once the
|
||||
* user enters a token instead of needing a page reload.
|
||||
*
|
||||
* Returns a cleanup function that aborts the stream and its pending retry.
|
||||
*/
|
||||
export const connectHeartbeat = (
|
||||
onConnect: () => void,
|
||||
onReconnecting: () => void,
|
||||
): (() => void) => {
|
||||
let closed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let es: EventSource | null = null;
|
||||
let attempt = 0;
|
||||
/** Whether we've already fired onReconnecting for the current drop. */
|
||||
let notifiedReconnecting = false;
|
||||
const MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
es = new EventSource(`${_backendUrl}/api/heartbeat`);
|
||||
es.onopen = () => {
|
||||
if (!closed) {
|
||||
attempt = 0;
|
||||
const controller = streamSSE(
|
||||
`${_backendUrl}/api/heartbeat`,
|
||||
{
|
||||
onOpen: () => {
|
||||
notifiedReconnecting = false;
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
if (closed) return;
|
||||
|
||||
if (!notifiedReconnecting) {
|
||||
},
|
||||
onReconnecting: () => {
|
||||
if (notifiedReconnecting) return;
|
||||
notifiedReconnecting = true;
|
||||
onReconnecting();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
maxRetries: Infinity,
|
||||
capDelayMs: HEARTBEAT_MAX_BACKOFF_MS,
|
||||
retryOnHttpError: true,
|
||||
},
|
||||
);
|
||||
|
||||
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
es?.close();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
return () => controller.abort();
|
||||
};
|
||||
|
||||
/** Delete a repo's index and unregister it. */
|
||||
|
|
@ -528,13 +637,29 @@ export const deleteRepo = async (repoName: string): Promise<void> => {
|
|||
await assertOk(response);
|
||||
};
|
||||
|
||||
/** Probe the backend. Returns true if reachable. */
|
||||
export const probeBackend = async (): Promise<boolean> => {
|
||||
/**
|
||||
* Outcome of a backend probe. A single value rather than a pair of booleans,
|
||||
* so "reachable and gated at the same time" cannot be represented:
|
||||
* - `ok` — answered 200, reachable and authorized.
|
||||
* - `unauthorized` — the public edge rejected the probe for a missing or wrong
|
||||
* access token (401). The deploy is up; the fix is to enter a token, not to
|
||||
* start a server.
|
||||
* - `unreachable` — no usable answer: a transport failure, a timeout, or any
|
||||
* other status.
|
||||
*/
|
||||
export type BackendProbeStatus = 'ok' | 'unauthorized' | 'unreachable';
|
||||
|
||||
/**
|
||||
* Probe the backend, distinguishing "not there" from "there but gated".
|
||||
* Never throws — a probe failure is a state, not an error.
|
||||
*/
|
||||
export const probeBackendStatus = async (): Promise<BackendProbeStatus> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/repos`, {}, PROBE_TIMEOUT_MS);
|
||||
return response.status === 200;
|
||||
if (response.status === 200) return 'ok';
|
||||
return response.status === 401 ? 'unauthorized' : 'unreachable';
|
||||
} catch {
|
||||
return false;
|
||||
return 'unreachable';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal file
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* The onboarding half of the edge token gate: a 401 has to read as "enter a
|
||||
* token", not "start a server", and the token the user enters has to reach
|
||||
* sessionStorage — and only sessionStorage.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AccessTokenPrompt } from '../../src/components/AccessTokenPrompt';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
|
||||
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
describe('AccessTokenPrompt', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
it('stores an entered token in sessionStorage, never localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<AccessTokenPrompt onSubmit={onSubmit} />);
|
||||
|
||||
const input = screen.getByLabelText('Access Token');
|
||||
// Masked by default — the token is never rendered in plain text unasked.
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.type(input, TOKEN);
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reveals and re-masks the token on request', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AccessTokenPrompt />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show access token' }));
|
||||
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'text');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Hide access token' }));
|
||||
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('points at the Render environment variable that holds the token', () => {
|
||||
render(<AccessTokenPrompt />);
|
||||
expect(screen.getByText(/GITNEXUS_SERVE_AUTH_TOKEN/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal file
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Deploy access token plumbing in backend-client.
|
||||
*
|
||||
* The public edge (`docker-server.mjs`) gates every `/api/*` request behind
|
||||
* `Authorization: Bearer <token>` and answers 401 with `{ code: 'unauthorized' }`
|
||||
* otherwise. These tests pin the three things that make the browser half work:
|
||||
* the header reaches every request path, an absent token sends no header at all,
|
||||
* and the token never lands anywhere but sessionStorage.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import {
|
||||
BackendError,
|
||||
fetchRepos,
|
||||
getAuthToken,
|
||||
probeBackendStatus,
|
||||
runQuery,
|
||||
setAuthToken,
|
||||
setBackendUrl,
|
||||
streamSSE,
|
||||
} from '../../src/services/backend-client';
|
||||
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
|
||||
|
||||
const BASE = 'http://localhost:4747';
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
/** Headers of the nth fetch call, normalized to a `Headers` instance. */
|
||||
const headersOf = (fetchMock: ReturnType<typeof vi.fn>, call = 0): Headers =>
|
||||
new Headers((fetchMock.mock.calls[call]?.[1] as RequestInit | undefined)?.headers);
|
||||
|
||||
const jsonOk = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('backend-client access token', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends Authorization: Bearer <token> when a token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
await fetchRepos();
|
||||
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
it('sends no Authorization header at all when no token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchRepos();
|
||||
|
||||
// Absent, not empty — an empty credential is malformed, not missing.
|
||||
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('trims the token and treats a whitespace-only token as absent', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
setAuthToken(` ${TOKEN} `);
|
||||
await fetchRepos();
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
|
||||
setAuthToken(' ');
|
||||
await fetchRepos();
|
||||
expect(headersOf(fetchMock, 1).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves a caller's own headers alongside Authorization", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk({ result: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
// runQuery passes `Content-Type: application/json` of its own — the
|
||||
// `Headers` merge has to keep both, which an object spread would not.
|
||||
await runQuery('MATCH (n) RETURN n');
|
||||
|
||||
const headers = headersOf(fetchMock);
|
||||
expect(headers.get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('surfaces a 401 with code "unauthorized" as BackendError.code === "unauthorized"', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: 'unauthorized', code: 'unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const error = await fetchRepos().catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BackendError);
|
||||
expect((error as BackendError).code).toBe('unauthorized');
|
||||
expect((error as BackendError).status).toBe(401);
|
||||
});
|
||||
|
||||
it('keeps a 401 without the discriminator as a generic client error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 401 })),
|
||||
);
|
||||
|
||||
const error = await fetchRepos().catch((e: unknown) => e);
|
||||
expect((error as BackendError).code).toBe('client');
|
||||
});
|
||||
|
||||
it('stores the token in sessionStorage and never in localStorage', () => {
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
|
||||
setAuthToken('');
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(getAuthToken()).toBe('');
|
||||
});
|
||||
|
||||
describe('probeBackendStatus', () => {
|
||||
it('reports a 401 as unauthorized rather than plain unreachability', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 401 })),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('reports a genuinely absent backend as unreachable, not gated', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('unreachable');
|
||||
});
|
||||
|
||||
it('reports a 200 as ok', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => jsonOk([])),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamSSE', () => {
|
||||
/** A response body that emits `chunks` then closes the stream. */
|
||||
const sseResponse = (chunks: string[]) =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) c.enqueue(encoder.encode(chunk));
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
|
||||
it('sends the token, and keeps it alongside Last-Event-ID on reconnect', async () => {
|
||||
// First connection ends after one identified event, so the retry carries
|
||||
// `Last-Event-ID`. Both headers must be present on that second attempt.
|
||||
const fetchMock = vi.fn(async () => sseResponse(['id: 42\ndata: {"percent":10}\n\n']));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { baseDelayMs: 0 });
|
||||
await vi.waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
controller.abort();
|
||||
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(headersOf(fetchMock).has('Last-Event-ID')).toBe(false);
|
||||
|
||||
const retryHeaders = headersOf(fetchMock, 1);
|
||||
expect(retryHeaders.get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(retryHeaders.get('Last-Event-ID')).toBe('42');
|
||||
});
|
||||
|
||||
it('sends no Authorization header when no token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => sseResponse(['data: {"percent":10}\n\n']));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { maxRetries: 0 });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
|
||||
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a non-OK response as an error and does not retry by default', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
streamSSE(`${BASE}/api/analyze/j1/progress`, { onError }, { baseDelayMs: 0 });
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Server returned 401'));
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('retries a non-OK response when retryOnHttpError is set', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
const controller = streamSSE(
|
||||
`${BASE}/api/heartbeat`,
|
||||
{ onError },
|
||||
{ baseDelayMs: 0, maxRetries: 2, retryOnHttpError: true },
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||
controller.abort();
|
||||
|
||||
// Budget spent → the caller finally hears about it.
|
||||
expect(onError).toHaveBeenCalledWith('Server returned 401');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +1,223 @@
|
|||
/**
|
||||
* `connectHeartbeat` runs on `streamSSE` (fetch + ReadableStream), not
|
||||
* `EventSource`, because `EventSource` cannot send custom headers and every
|
||||
* `/api/*` request needs `Authorization: Bearer <token>` to clear the public
|
||||
* edge's token gate.
|
||||
*
|
||||
* These tests pin the behavior `EventSource` used to provide for free —
|
||||
* indefinite reconnect with capped backoff, one "reconnecting" notification per
|
||||
* outage, teardown on cleanup — plus the two things the migration exists for:
|
||||
* the token header, and a 401 that recovers instead of giving up.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { connectHeartbeat } from '../../src/services/backend-client';
|
||||
import { connectHeartbeat, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
// Mock EventSource to simulate SSE behavior
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
/** A live fake SSE connection, closable from the test. */
|
||||
interface FakeConnection {
|
||||
/** End the stream cleanly — the client sees a drop and reconnects. */
|
||||
drop: () => void;
|
||||
}
|
||||
|
||||
let lastEventSource: MockEventSource | null = null;
|
||||
let connections: FakeConnection[] = [];
|
||||
/** HTTP statuses to answer with, in order. Exhausted → 200. */
|
||||
let statusQueue: number[] = [];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** Let pending promises settle without advancing the clock. */
|
||||
const flush = () => vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
beforeEach(() => {
|
||||
lastEventSource = null;
|
||||
// vitest 4 enforces that mock implementations used with `new` must have a
|
||||
// [[Construct]] slot. Arrow functions don't, so we use a regular function
|
||||
// declaration here. The production code calls `new EventSource(...)`.
|
||||
vi.stubGlobal(
|
||||
'EventSource',
|
||||
vi.fn().mockImplementation(function () {
|
||||
lastEventSource = new MockEventSource();
|
||||
return lastEventSource;
|
||||
}),
|
||||
);
|
||||
connections = [];
|
||||
statusQueue = [];
|
||||
setAuthToken('');
|
||||
|
||||
fetchMock = vi.fn(async () => {
|
||||
const status = statusQueue.shift() ?? 200;
|
||||
if (status !== 200) return new Response('nope', { status });
|
||||
|
||||
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
streamController = c;
|
||||
// The server's initial ":ok" comment — proves comments are tolerated.
|
||||
c.enqueue(new TextEncoder().encode(':ok\n\n'));
|
||||
},
|
||||
});
|
||||
connections.push({ drop: () => streamController.close() });
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
describe('connectHeartbeat', () => {
|
||||
it('calls onConnect when EventSource opens', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onReconnecting on first error, then retries', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate connection drop
|
||||
lastEventSource!.onerror!();
|
||||
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
expect(lastEventSource!.closed).toBe(true);
|
||||
|
||||
// Advance past first retry delay (1s)
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
// A new EventSource should have been created
|
||||
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fires onReconnecting only once per disconnect', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// First error
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Second retry fires error again
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
|
||||
// Third retry fires error
|
||||
vi.advanceTimersByTime(2_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
});
|
||||
|
||||
it('retries indefinitely instead of giving up after 3 attempts', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate 10 consecutive failures — should never stop retrying
|
||||
for (let i = 0; i < 10; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// Advance past the max backoff (15s) to ensure the next retry fires
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// Should have created 11 EventSources (1 initial + 10 retries)
|
||||
expect(EventSource).toHaveBeenCalledTimes(11);
|
||||
});
|
||||
|
||||
it('resets reconnecting state when connection recovers', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Drop
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Retry succeeds
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
// Drop again — should fire onReconnecting again (reset after recovery)
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caps backoff at 15 seconds', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Fail many times to push backoff past the cap
|
||||
for (let i = 0; i < 6; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// The delay for attempt i is min(1000 * 2^i, 15000)
|
||||
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// All retries should have fired — 7 EventSources total
|
||||
expect(EventSource).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('stops retrying when cleanup is called', () => {
|
||||
it('calls onConnect once the stream is readable', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onerror!();
|
||||
await flush();
|
||||
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('sends the access token as an Authorization header', async () => {
|
||||
setAuthToken('deploy-token-abc123');
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
|
||||
await flush();
|
||||
|
||||
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer deploy-token-abc123');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('sends no Authorization header on an ungated deploy', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
|
||||
await flush();
|
||||
|
||||
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
|
||||
expect(headers.has('Authorization')).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('calls onReconnecting on first drop, then retries', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Advance past the first retry delay (1s)
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('fires onReconnecting only once per outage', async () => {
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(vi.fn(), onReconnecting);
|
||||
await flush();
|
||||
|
||||
// Every reconnect attempt answers 401 — the stream never reopens, so the
|
||||
// banner must not re-fire on each attempt.
|
||||
statusQueue = [401, 401, 401];
|
||||
connections[0].drop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(2);
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('retries indefinitely instead of giving up after 3 attempts', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
connections[i].drop();
|
||||
// Advance past the max backoff (15s) so the next attempt always fires
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
}
|
||||
|
||||
// 1 initial connection + 10 reconnects
|
||||
expect(fetchMock).toHaveBeenCalledTimes(11);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('reconnects after a 401 so a token entered later recovers the stream', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
// The gate starts rejecting (token cleared / never entered)…
|
||||
statusQueue = [401, 401];
|
||||
connections[0].drop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// …and once a valid token is in place the next attempt succeeds on its own.
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
expect(onConnect).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('resets reconnecting state when the connection recovers', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Retry succeeds
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onConnect).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Drop again — a fresh outage notifies again
|
||||
connections[1].drop();
|
||||
await flush();
|
||||
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('caps backoff at 15 seconds', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
// Every attempt 401s, so nothing reopens and the retry counter keeps
|
||||
// climbing — the doubling backoff would reach 16s on the 5th retry.
|
||||
statusQueue = Array.from({ length: 10 }, () => 401);
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
|
||||
// Walk the uncapped part of the schedule exactly: 1s, 2s, 4s, 8s.
|
||||
for (const delay of [1_000, 2_000, 4_000, 8_000]) {
|
||||
await vi.advanceTimersByTimeAsync(delay);
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
|
||||
// The next delay doubles to 16s, so the cap is what makes this retry fire
|
||||
// at 15s. Not a millisecond sooner, and not at 16s.
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('stops retrying when cleanup is called', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
cleanup();
|
||||
|
||||
// Advance time — no new EventSource should be created
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(EventSource).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal file
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* The settings panel's access-token field persists on Save, with every other
|
||||
* field, rather than on each keystroke. Save is the only "committed" affordance
|
||||
* the panel has, and a half-typed token would otherwise ride the next probe to
|
||||
* the backend.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsPanel } from '../../src/components/SettingsPanel';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
describe('SettingsPanel access token', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
it('holds a typed token locally until Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPanel isOpen onClose={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Access Token'), TOKEN);
|
||||
expect(getAuthToken()).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('clears a stored token when the field is emptied and saved', async () => {
|
||||
setAuthToken(TOKEN);
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPanel isOpen onClose={vi.fn()} />);
|
||||
|
||||
await user.clear(screen.getByLabelText('Access Token'));
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
// An empty token is a valid state — an ungated local backend needs no header.
|
||||
expect(getAuthToken()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"fingerprint": "69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe",
|
||||
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
246
gitnexus/bench/finalize-reexport/measure.mjs
Normal file
246
gitnexus/bench/finalize-reexport/measure.mjs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Build-free scaling bench for `buildReexportClosures`, the re-export closure
|
||||
* pass inside `finalize`.
|
||||
*
|
||||
* WHY THIS EXISTS. Until #2864 the closure sub-graph admitted only `reexport`
|
||||
* and `wildcard` drafts, so its input was TypeScript barrel files: a handful
|
||||
* of edges, shallow chains. #2864 admits `named`/`alias` drafts flagged
|
||||
* `reexportsName`, which for Python is every module-level `from m import x` —
|
||||
* measured ~20x more edges on the CPython stdlib, and cyclic SCCs where there
|
||||
* were none. The pass went from "rarely runs" to "runs over the whole named
|
||||
* import graph", and nothing measured it.
|
||||
*
|
||||
* The specific regression this guards is a QUADRATIC, and it has already
|
||||
* happened once. `populateFileClosure` copies the inherited `via` array at
|
||||
* every hop, so an unbounded chain is Theta(depth^2) in time AND retained
|
||||
* memory. `MAX_REEXPORT_DEPTH = 100` bounded it until commit `fc919ad6`
|
||||
* removed it — a correct call for shallow TS barrels, invisible for years,
|
||||
* and wrong the moment the input class changed. `MAX_VIA_LENGTH` restores the
|
||||
* bound; this bench is what notices if it goes away again. Measured at
|
||||
* depth 400: 67 ms / 145 MB uncapped vs 25 ms / 40 MB capped.
|
||||
*
|
||||
* TWO ARMS, deliberately not one, and only one of them is a timing arm:
|
||||
*
|
||||
* - `max_via_len` — EXACT and deterministic. Builds a chain far deeper than
|
||||
* the cap and asserts the longest emitted `transitiveVia` is exactly
|
||||
* `MAX_VIA_LENGTH`. Removing the cap is directly observable as a longer
|
||||
* array, so this catches it with zero flake.
|
||||
*
|
||||
* This started life as a `depth_ratio` timing arm and that was a BAD GATE.
|
||||
* Sampled five times capped it scored 2.71-3.52, and three times uncapped
|
||||
* it scored 5.87-7.65 — the ranges nearly touch, and one uncapped run came
|
||||
* in UNDER the budget. A gate that passes a third of the time on a broken
|
||||
* build is worse than no gate, because it is read as evidence. The
|
||||
* quadratic is real, but at these depths the pass's linear work dilutes it
|
||||
* enough that wall-clock cannot separate the two cleanly. The structural
|
||||
* assertion can, so it is the one that gates.
|
||||
*
|
||||
* - `width_ms` — an absolute ceiling on a wide, shallow, realistic package
|
||||
* corpus (the shape a real Python repo actually has). Structural checks
|
||||
* cannot see a constant factor: reintroducing a per-lookup linear scan of
|
||||
* a target's `localDefs` leaves every array length untouched while making
|
||||
* every real analyze slower. This arm IS timing-sensitive — re-run on an
|
||||
* idle machine before investigating. Its budget is deliberately loose; it
|
||||
* is here to catch a doubling, not to police drift.
|
||||
*
|
||||
* Both arms feed `finalize` through INDEXED hooks. The obvious mistake is to
|
||||
* reuse the unit tests' `defaultHooks`, whose `resolveImportTarget` does
|
||||
* `files.some(...)` per import — that is O(imports x files) in the FIXTURE,
|
||||
* and it swamps the pass under test so completely that removing the cap
|
||||
* measures as no change at all.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/finalize-reexport/measure.mjs # report
|
||||
* node --import tsx bench/finalize-reexport/measure.mjs --check # CI gate
|
||||
*/
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { finalize } from 'gitnexus-shared';
|
||||
|
||||
/** Must equal `MAX_VIA_LENGTH` in `gitnexus-shared`'s finalize-algorithm.ts. */
|
||||
const EXPECTED_MAX_VIA = 32;
|
||||
// Generous absolute ceiling — this arm exists to catch a restored O(n^2)
|
||||
// scan (which more than doubles it), not to police small drift.
|
||||
const WIDTH_MS_BUDGET = 1200;
|
||||
|
||||
const PROBE_DEPTH = 400;
|
||||
|
||||
const deriveSimple = (d) => {
|
||||
const q = d.qualifiedName;
|
||||
if (q === undefined || q.length === 0) return null;
|
||||
const dot = q.lastIndexOf('.');
|
||||
return dot === -1 ? q : q.slice(dot + 1);
|
||||
};
|
||||
|
||||
function hooksFor(files) {
|
||||
const byPath = new Map(files.map((f) => [f.filePath, f]));
|
||||
const byScope = new Map(files.map((f) => [f.moduleScope, f]));
|
||||
return {
|
||||
resolveImportTarget: (raw) => (raw !== null && byPath.has(raw) ? raw : null),
|
||||
expandsWildcardTo: (scope) => {
|
||||
const t = byScope.get(scope);
|
||||
return t === undefined ? [] : t.localDefs.map(deriveSimple).filter((n) => n !== null);
|
||||
},
|
||||
mergeBindings: (existing, incoming) => [...existing, ...incoming],
|
||||
};
|
||||
}
|
||||
|
||||
const mkFile = (filePath, localDefs, parsedImports) => ({
|
||||
filePath,
|
||||
moduleScope: `scope:${filePath}#1:0-9999:0:Module`,
|
||||
localDefs,
|
||||
parsedImports,
|
||||
});
|
||||
const mkDef = (qn) => ({ nodeId: `def:${qn}`, filePath: 'x', type: 'Function', qualifiedName: qn });
|
||||
const reexporting = (name, targetRaw) => ({
|
||||
kind: 'named',
|
||||
localName: name,
|
||||
importedName: name,
|
||||
targetRaw,
|
||||
reexportsName: true,
|
||||
});
|
||||
|
||||
/** A `__init__.py` chain N deep, each hop republishing the same names. */
|
||||
function chainCorpus(depth, names = 20) {
|
||||
const files = [
|
||||
mkFile(
|
||||
'leaf.py',
|
||||
Array.from({ length: names }, (_, j) => mkDef(`leaf.fn${j}`)),
|
||||
[],
|
||||
),
|
||||
];
|
||||
let prev = 'leaf.py';
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const p = `hop${d}.py`;
|
||||
files.push(
|
||||
mkFile(
|
||||
p,
|
||||
[],
|
||||
Array.from({ length: names }, (_, j) => reexporting(`fn${j}`, prev)),
|
||||
),
|
||||
);
|
||||
prev = p;
|
||||
}
|
||||
files.push(
|
||||
mkFile(
|
||||
'app.py',
|
||||
[],
|
||||
Array.from({ length: names }, (_, j) => ({
|
||||
kind: 'named',
|
||||
localName: `fn${j}`,
|
||||
importedName: `fn${j}`,
|
||||
targetRaw: prev,
|
||||
})),
|
||||
),
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Wide and shallow: the layout a real Python repo has. */
|
||||
function packageCorpus({ leaves, defsPerLeaf, pkgSize, consumers, importsPerConsumer }) {
|
||||
const files = [];
|
||||
const leafPaths = [];
|
||||
for (let i = 0; i < leaves; i++) {
|
||||
const p = `pkg${Math.floor(i / pkgSize)}/mod${i}.py`;
|
||||
leafPaths.push(p);
|
||||
files.push(
|
||||
mkFile(
|
||||
p,
|
||||
Array.from({ length: defsPerLeaf }, (_, j) => mkDef(`mod${i}.fn${j}`)),
|
||||
[],
|
||||
),
|
||||
);
|
||||
}
|
||||
const initPaths = [];
|
||||
for (let g = 0; g < Math.ceil(leaves / pkgSize); g++) {
|
||||
const p = `pkg${g}/__init__.py`;
|
||||
initPaths.push(p);
|
||||
const imports = [];
|
||||
for (let i = g * pkgSize; i < Math.min((g + 1) * pkgSize, leaves); i++) {
|
||||
for (let j = 0; j < defsPerLeaf; j++) imports.push(reexporting(`fn${j}_${i}`, leafPaths[i]));
|
||||
}
|
||||
files.push(mkFile(p, [], imports));
|
||||
}
|
||||
for (let c = 0; c < consumers; c++) {
|
||||
const imports = [];
|
||||
for (let k = 0; k < importsPerConsumer; k++) {
|
||||
const g = (c * 7 + k) % initPaths.length;
|
||||
imports.push({
|
||||
kind: 'named',
|
||||
localName: `fn0_${g * pkgSize}`,
|
||||
importedName: `fn0_${g * pkgSize}`,
|
||||
targetRaw: initPaths[g],
|
||||
});
|
||||
}
|
||||
files.push(mkFile(`app/consumer${c}.py`, [], imports));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function timeMedian(files, reps = 5) {
|
||||
const hooks = hooksFor(files);
|
||||
finalize({ files, workspaceIndex: undefined }, hooks); // warm
|
||||
const times = [];
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
finalize({ files, workspaceIndex: undefined }, hooks);
|
||||
times.push(performance.now() - t0);
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
return times[Math.floor(times.length / 2)];
|
||||
}
|
||||
|
||||
/** Longest `transitiveVia` any edge in this graph carries. */
|
||||
function maxViaLength(files) {
|
||||
const out = finalize({ files, workspaceIndex: undefined }, hooksFor(files));
|
||||
let max = 0;
|
||||
for (const edges of out.imports.values()) {
|
||||
for (const e of edges) {
|
||||
if (e.transitiveVia !== undefined) max = Math.max(max, e.transitiveVia.length);
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
const deepChain = chainCorpus(PROBE_DEPTH);
|
||||
const maxVia = maxViaLength(deepChain);
|
||||
const chainMs = timeMedian(deepChain);
|
||||
const widthMs = timeMedian(
|
||||
packageCorpus({
|
||||
leaves: 6000,
|
||||
defsPerLeaf: 8,
|
||||
pkgSize: 12,
|
||||
consumers: 3000,
|
||||
importsPerConsumer: 15,
|
||||
}),
|
||||
3,
|
||||
);
|
||||
|
||||
console.log(`chain depth ${PROBE_DEPTH} : ${chainMs.toFixed(1)} ms`);
|
||||
console.log(`max_via_len : ${maxVia} (must equal ${EXPECTED_MAX_VIA})`);
|
||||
console.log(`width_ms : ${widthMs.toFixed(1)} (budget <= ${WIDTH_MS_BUDGET})`);
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let failed = false;
|
||||
if (maxVia !== EXPECTED_MAX_VIA) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL max_via_len: ${maxVia}, expected exactly ${EXPECTED_MAX_VIA}.\n` +
|
||||
`A LARGER value means the \`via\` chain copy lost its bound — see ` +
|
||||
`MAX_VIA_LENGTH in gitnexus-shared/src/scope-resolution/finalize-algorithm.ts. ` +
|
||||
`Each hop copies the inherited path, so an unbounded chain is O(depth^2) ` +
|
||||
`in time and retained memory (measured 67 ms / 145 MB vs 25 ms / 40 MB at ` +
|
||||
`depth ${PROBE_DEPTH}).\nA SMALLER value means the cap moved; update ` +
|
||||
`EXPECTED_MAX_VIA here and the two finalize-algorithm tests that pin it.`,
|
||||
);
|
||||
}
|
||||
if (widthMs > WIDTH_MS_BUDGET) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL width_ms: ${widthMs.toFixed(1)} exceeds budget ${WIDTH_MS_BUDGET}. ` +
|
||||
`With max_via_len healthy this points at a per-lookup linear scan coming ` +
|
||||
`back (see indexExportsByName). Re-run on an idle machine first.`,
|
||||
);
|
||||
}
|
||||
if (failed) process.exit(1);
|
||||
console.log('\nOK — within budget.');
|
||||
}
|
||||
1018
gitnexus/bench/import-target/baselines.json
Normal file
1018
gitnexus/bench/import-target/baselines.json
Normal file
File diff suppressed because one or more lines are too long
2967
gitnexus/bench/import-target/measure.mjs
Normal file
2967
gitnexus/bench/import-target/measure.mjs
Normal file
File diff suppressed because it is too large
Load diff
9
gitnexus/bench/kotlin-import-target/baselines.json
Normal file
9
gitnexus/bench/kotlin-import-target/baselines.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"_comment": "Declared-package Kotlin import benchmark. The fingerprint pins external-decoy rejection, path/package disagreement, top-level declarations and overload sets, member and wildcard imports, root packages, malformed package facts, and imported-binding exclusion. Timing budgets guard one parsed-workspace index build per pass.",
|
||||
"fingerprint": "76ee74bf860c54ef6dc0850f6bec2d0f7267ba3010f552ee89b84dfdbbecd0e2",
|
||||
"cases": 24,
|
||||
"non_null": 16,
|
||||
"scaling_budget": 1.6,
|
||||
"depth_budget": 1.5,
|
||||
"small_ms_ceiling": 40
|
||||
}
|
||||
241
gitnexus/bench/kotlin-import-target/measure.mjs
Normal file
241
gitnexus/bench/kotlin-import-target/measure.mjs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* Declared-package correctness and scaling gate for Kotlin import resolution.
|
||||
*
|
||||
* The production resolver indexes the parsed workspace once per pass. This
|
||||
* benchmark pins the semantic cases path matching cannot express and verifies
|
||||
* that work remains linear as files and imports grow together.
|
||||
*
|
||||
* Run:
|
||||
* node --import tsx bench/kotlin-import-target/measure.mjs
|
||||
* node --import tsx bench/kotlin-import-target/measure.mjs --check
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const baseline = JSON.parse(fs.readFileSync(path.join(__dirname, 'baselines.json'), 'utf8'));
|
||||
const CHECK = process.argv.includes('--check');
|
||||
const SMALL = 400;
|
||||
const LARGE = 1600;
|
||||
const IMPORTS_PER_FILE = 4;
|
||||
const WARMUP = 2;
|
||||
const REPS = 7;
|
||||
|
||||
function parsedFile(filePath, packageName, exports, imported = []) {
|
||||
const moduleScope = `module:${filePath}`;
|
||||
const localDefs = exports.map((name, i) => ({
|
||||
nodeId: `Declaration:${filePath}:${i}`,
|
||||
filePath,
|
||||
type: i === 0 ? 'Class' : 'Function',
|
||||
qualifiedName: name,
|
||||
}));
|
||||
const bindings = new Map(localDefs.map((def) => [def.qualifiedName, [{ def, origin: 'local' }]]));
|
||||
for (const name of imported) {
|
||||
bindings.set(name, [
|
||||
{
|
||||
def: {
|
||||
nodeId: `Declaration:dependency.kt:${name}`,
|
||||
filePath: 'dependency.kt',
|
||||
type: 'Class',
|
||||
qualifiedName: name,
|
||||
},
|
||||
origin: 'import',
|
||||
},
|
||||
]);
|
||||
}
|
||||
return {
|
||||
filePath,
|
||||
moduleScope,
|
||||
scopes: [
|
||||
{
|
||||
id: moduleScope,
|
||||
parent: null,
|
||||
kind: 'Module',
|
||||
range: { startLine: 1, startCol: 0, endLine: 1, endCol: 1 },
|
||||
filePath,
|
||||
bindings,
|
||||
ownedDefs: localDefs,
|
||||
imports: [],
|
||||
typeBindings: new Map(),
|
||||
},
|
||||
],
|
||||
parsedImports: [],
|
||||
localDefs,
|
||||
referenceSites: [],
|
||||
captureSideChannel: {
|
||||
kind: 'kotlin',
|
||||
companionScopes: [],
|
||||
packageFact: packageName === null ? { status: 'unknown' } : { status: 'known', packageName },
|
||||
classAnnotations: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepare(parsedFiles) {
|
||||
kotlinScopeResolver.loadResolutionConfig?.('');
|
||||
for (const parsed of parsedFiles) kotlinScopeResolver.applyCaptureSideChannel?.(parsed);
|
||||
return {
|
||||
parsedFiles,
|
||||
allFilePaths: new Set(parsedFiles.map((file) => file.filePath)),
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(targetRaw, pass) {
|
||||
return kotlinScopeResolver.resolveImportTarget(
|
||||
targetRaw,
|
||||
pass.parsedFiles[0]?.filePath ?? 'app/Main.kt',
|
||||
pass.allFilePaths,
|
||||
undefined,
|
||||
{
|
||||
parsedFiles: pass.parsedFiles,
|
||||
parsedImport: { kind: 'named', localName: 'X', importedName: 'X', targetRaw },
|
||||
filesSkipped: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function render(answer) {
|
||||
if (answer === null) return 'null';
|
||||
return typeof answer === 'string' ? answer : JSON.stringify(answer);
|
||||
}
|
||||
|
||||
const correctness = [
|
||||
[
|
||||
[
|
||||
parsedFile('app/Main.kt', 'app', ['main']),
|
||||
parsedFile('src/main/kotlin/vendor/Assert.kt', 'vendor', ['Assert']),
|
||||
],
|
||||
['org.junit.Assert', 'vendor.Assert'],
|
||||
],
|
||||
[
|
||||
[
|
||||
parsedFile('flat/UserSource.kt', 'com.example.model', ['User', 'loadUser']),
|
||||
parsedFile('other/Order.kt', 'com.example.model', ['Order']),
|
||||
parsedFile('odd/ToolsFile.kt', 'com.example', ['Tools']),
|
||||
],
|
||||
[
|
||||
'com.example.model.User',
|
||||
'com.example.model.loadUser',
|
||||
'com.example.model.*',
|
||||
'com.example.Tools.format',
|
||||
'com.example.Tools.*',
|
||||
'com.example.model.Missing',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
parsedFile('one.kt', 'dup', ['parse']),
|
||||
parsedFile('two.kt', 'dup', ['parse']),
|
||||
parsedFile('Root.kt', '', ['Root']),
|
||||
parsedFile('Broken.kt', null, ['Broken']),
|
||||
parsedFile('app.kt', 'app', ['main'], ['External']),
|
||||
],
|
||||
['dup.parse', 'Root', 'broken.Broken', 'app.External'],
|
||||
],
|
||||
];
|
||||
|
||||
const records = [];
|
||||
let nonNull = 0;
|
||||
for (const [files, targets] of correctness) {
|
||||
for (const ordered of [files, [...files].reverse()]) {
|
||||
const pass = prepare(ordered);
|
||||
for (const target of targets) {
|
||||
const answer = render(resolve(target, pass));
|
||||
if (answer !== 'null') nonNull++;
|
||||
records.push(`${ordered.map((file) => file.filePath).join(',')}|${target}->${answer}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fingerprint = crypto.createHash('sha256').update(records.sort().join('\n')).digest('hex');
|
||||
|
||||
function buildCorpus(fileCount, padDepth = 0) {
|
||||
const pad = Array.from({ length: padDepth }, (_, i) => `deep${i}`).join('/');
|
||||
const files = [];
|
||||
const packages = Math.max(1, Math.floor(fileCount / 8));
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const pkg = i % packages;
|
||||
const prefix = pad === '' ? `mod${pkg}` : `mod${pkg}/${pad}`;
|
||||
files.push(
|
||||
parsedFile(
|
||||
`${prefix}/src/main/kotlin/com/example/pkg${pkg}/Source${i}.kt`,
|
||||
`com.example.pkg${pkg}`,
|
||||
[`File${i}`, `topLevel${i}`],
|
||||
),
|
||||
);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildImports(fileCount) {
|
||||
const packages = Math.max(1, Math.floor(fileCount / 8));
|
||||
return Array.from({ length: fileCount * IMPORTS_PER_FILE }, (_, i) => {
|
||||
const file = i % fileCount;
|
||||
const pkg = file % packages;
|
||||
switch (i % 4) {
|
||||
case 0:
|
||||
return `com.example.pkg${pkg}.File${file}`;
|
||||
case 1:
|
||||
return `com.example.pkg${pkg}.topLevel${file}`;
|
||||
case 2:
|
||||
return `com.example.pkg${pkg}.*`;
|
||||
default:
|
||||
return `org.external.pkg${pkg}.Missing${i}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function timeResolution(fileCount, padDepth = 0) {
|
||||
const workspaces = Array.from({ length: WARMUP + REPS }, () => buildCorpus(fileCount, padDepth));
|
||||
const imports = buildImports(fileCount);
|
||||
const samples = [];
|
||||
for (let run = 0; run < workspaces.length; run++) {
|
||||
const pass = prepare(workspaces[run]);
|
||||
const start = performance.now();
|
||||
let sink = 0;
|
||||
for (const target of imports) if (resolve(target, pass) !== null) sink++;
|
||||
const elapsed = performance.now() - start;
|
||||
if (sink === 0) throw new Error('benchmark workload resolved nothing');
|
||||
if (run >= WARMUP) samples.push(elapsed);
|
||||
}
|
||||
return Math.min(...samples);
|
||||
}
|
||||
|
||||
const smallMs = timeResolution(SMALL);
|
||||
const largeMs = timeResolution(LARGE);
|
||||
const deepMs = timeResolution(SMALL, 16);
|
||||
const report = {
|
||||
fingerprint,
|
||||
cases: records.length,
|
||||
non_null: nonNull,
|
||||
small: { files: SMALL, imports: SMALL * IMPORTS_PER_FILE, ms: Number(smallMs.toFixed(3)) },
|
||||
large: { files: LARGE, imports: LARGE * IMPORTS_PER_FILE, ms: Number(largeMs.toFixed(3)) },
|
||||
scaling_ratio: Number((largeMs / smallMs / (LARGE / SMALL)).toFixed(3)),
|
||||
depth_ratio: Number((deepMs / smallMs).toFixed(3)),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!CHECK) process.exit(0);
|
||||
|
||||
const failures = [];
|
||||
for (const key of ['fingerprint', 'cases', 'non_null']) {
|
||||
if (report[key] !== baseline[key]) failures.push(`${key}: ${report[key]} != ${baseline[key]}`);
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
failures.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
|
||||
}
|
||||
if (report.depth_ratio > baseline.depth_budget) {
|
||||
failures.push(`depth_ratio ${report.depth_ratio} > ${baseline.depth_budget}`);
|
||||
}
|
||||
if (report.small.ms > baseline.small_ms_ceiling) {
|
||||
failures.push(`small.ms ${report.small.ms} > ${baseline.small_ms_ceiling}`);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`[kotlin-import-target --check] FAIL\n - ${failures.join('\n - ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[kotlin-import-target --check] PASS');
|
||||
|
|
@ -1 +1 @@
|
|||
a0da3e7c00f603e4bdad91a376b3fc181577a73c2ca1719ab7449d3463c671e0
|
||||
2600a1f6f8a042eb4f520a7870c34d9ca292765824537c3bc861b40dac8769a8
|
||||
|
|
|
|||
|
|
@ -364,8 +364,13 @@ also resolves, so PHP nullable field types already work.
|
|||
|
||||
**C++ — the base already resolves, but `this->` field receivers do not.**
|
||||
`pointerArrowChain` and `valueDotChain` both RESOLVE, so a decorated C++ base is
|
||||
not a gap. But `this->repo.save()` and `this->repo->save()` are both
|
||||
INVISIBLE-GAP — a distinct defect, not a decoration one.
|
||||
not a gap. `this->repo.save()` and `this->repo->save()` were both INVISIBLE-GAP
|
||||
when this was written — a distinct defect, not a decoration one — and #2833
|
||||
closed it: a language that declares `this` IS the enclosing class
|
||||
(`resolveThisViaEnclosingClass`) synthesizes no `this` typeBinding anywhere, so
|
||||
a chain whose BASE is `this` could never seed its head. It was never a generics
|
||||
gap; the NON-generic control failed identically. C++'s `fieldReceiverCall` and
|
||||
`decoratedFieldType` cells moved INVISIBLE-GAP -> RESOLVES with it.
|
||||
|
||||
**Rust — the decorated receiver is NOT a gap.** `&mut self` resolves, so Go is
|
||||
the only language whose method receiver decoration defeats the lookup. Rust's
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@
|
|||
"awaitParen": "N/A",
|
||||
"explicitTypeArgs": "VISIBLE-GAP",
|
||||
"indexElement": "RESOLVES",
|
||||
"fieldReceiverCall": "INVISIBLE-GAP",
|
||||
"fieldReceiverCall": "RESOLVES",
|
||||
"decoratedReceiverBase": "N/A",
|
||||
"decoratedFieldType": "INVISIBLE-GAP"
|
||||
"decoratedFieldType": "RESOLVES"
|
||||
},
|
||||
"go": {
|
||||
"plainChain": "RESOLVES",
|
||||
|
|
@ -200,10 +200,11 @@
|
|||
},
|
||||
"countArm": {
|
||||
"callDrops": 102,
|
||||
"totalDropsAllKinds": 129,
|
||||
"totalDropsAllKinds": 148,
|
||||
"bySiteKind": {
|
||||
"call": 102,
|
||||
"read": 27
|
||||
"read": 27,
|
||||
"write": 19
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
".java": 49,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.
|
|||
|
||||
`src/core/lbug/schema.ts` generates its relation pairs from two cross products,
|
||||
and declines to add a third one **on the strength of a number** — roughly 1.04×
|
||||
at 450 declared pairs, 1.6× at 786, 2.1× at 1024. That measurement used to live
|
||||
in a scratch directory, so nobody proposing a third rule could re-run it. This
|
||||
harness is that measurement, committed — and it reproduces those figures.
|
||||
near production's pair count, 1.6× at 786, 2.1× at 1024. That measurement used
|
||||
to live in a scratch directory, so nobody proposing a third rule could re-run
|
||||
it. This harness is that measurement, committed — and it reproduces those
|
||||
figures.
|
||||
|
||||
Run it before widening a rule, and quote the new ratio in the review.
|
||||
|
||||
|
|
@ -29,8 +30,27 @@ Observed on the reference box, **four runs** (ratios vs the 332-pair list):
|
|||
| 786 | 1.52–1.75× | 1.19–1.31× |
|
||||
| 1024 | 2.03–2.34× | 1.31–1.57× |
|
||||
|
||||
Production's 450 came out _faster_ than 332 on three of the four runs, so at this
|
||||
size the pair count is inside run-to-run noise. Everything past ~640 is not.
|
||||
Production's former 450-pair surface came out _faster_ than 332 on three of the
|
||||
four runs, so at this size the pair count is inside run-to-run noise. Everything
|
||||
past ~640 is not.
|
||||
|
||||
#2801 remeasured the new 461-pair production surface on Windows six times:
|
||||
|
||||
| run | untyped ratio | typed ratio | interpretation |
|
||||
| --- | ------------- | ----------- | ----------------------------------- |
|
||||
| 1 | 1.101× | 1.157× | noise-dominated (`typed > untyped`) |
|
||||
| 2 | 1.324× | 1.122× | below the operational budget |
|
||||
| 3 | 2.705× | 1.089× | exceeds the operational budget |
|
||||
| 4 | 1.417× | 1.065× | below the operational budget |
|
||||
| 5 | 1.196× | 1.050× | below the operational budget |
|
||||
| 6 | 1.585× | 2.577× | noise-dominated (`typed > untyped`) |
|
||||
|
||||
The three comparable Windows runs below the 1.5× operational ceiling span
|
||||
**1.20–1.42× untyped / 1.05–1.12× typed**. Run 3 is published rather than
|
||||
silently discarded: no pre-registered rule excludes it, and `--check` would
|
||||
correctly reject it. These Windows measurements are not combined with the
|
||||
historical reference-box rows to infer cross-size ordering.
|
||||
|
||||
**Quote the range, not a single run** — one run is not evidence here.
|
||||
|
||||
## What it measures
|
||||
|
|
@ -52,8 +72,8 @@ data**, then times two query shapes over 40 anchors × 15 reps (median):
|
|||
control; the real cost of widening sits between it and `ratio_*`. A run where
|
||||
`typed_ratio` moves _more_ than `ratio` is noise-dominated and should be
|
||||
rerun.
|
||||
- **`ratio_<size>`** — `untyped_ms_<size> / untyped_ms_332`. `ratio_450` is the
|
||||
figure `schema.ts` quotes.
|
||||
- **`ratio_<size>`** — `untyped_ms_<size> / untyped_ms_332`. The
|
||||
production-size ratio is the figure `schema.ts` quotes.
|
||||
|
||||
### Sizes
|
||||
|
||||
|
|
@ -66,7 +86,8 @@ harness fails if the row counts ever differ across sizes.
|
|||
| size | what it is |
|
||||
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 332 | the pre-#2792 hand-written list — the reference for every ratio |
|
||||
| 450 | production today (two cross products + 72 hand-declared pairs) |
|
||||
| 450 | production before Record became linkable (#2801) |
|
||||
| 461 | production today (two cross products + 69 hand-declared pairs) |
|
||||
| 641 | the third cross product `schema.ts` defers (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, Namespace, Impl, TypeAlias, Static, Template}`), which would leave ~29 hand-declared lines |
|
||||
| 786 | the size an earlier revision of that comment attributed to the third rule — it is 641; kept as a measured waypoint |
|
||||
| 1024 | the full cross product, the ceiling |
|
||||
|
|
@ -77,11 +98,13 @@ Before timing anything, the harness round-trips the **real** `SCHEMA_QUERIES`
|
|||
through a real database and asserts that `CALL SHOW_CONNECTION('CodeRelation')`
|
||||
reports exactly the pairs `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`.
|
||||
|
||||
No magic number is baked in: the invariant is that the DDL LadybugDB _accepted_
|
||||
carries the pair set our own parser believes it declares. The absolute count is
|
||||
reported as `declared_pairs`. A pair declared twice would not reach this check at
|
||||
all — LadybugDB rejects the `CREATE REL TABLE` outright, which is why a duplicate
|
||||
kills every `analyze` rather than one repository's.
|
||||
No production-size magic number is baked in: the measured production size and
|
||||
budget key are derived from that parsed DDL count. A missing `ratio_<size>_budget`
|
||||
entry makes `--check` fail closed. The invariant is that the DDL LadybugDB
|
||||
_accepted_ carries the pair set our own parser believes it declares. The
|
||||
absolute count is reported as `declared_pairs`. A pair declared twice would not
|
||||
reach this check at all — LadybugDB rejects the `CREATE REL TABLE` outright,
|
||||
which is why a duplicate kills every `analyze` rather than one repository's.
|
||||
|
||||
## What it does NOT measure
|
||||
|
||||
|
|
@ -93,8 +116,11 @@ kills every `analyze` rather than one repository's.
|
|||
|
||||
## Regenerating the baseline
|
||||
|
||||
`baselines.json` holds one budget, `ratio_450_budget` — the ceiling on what
|
||||
`baselines.json` holds one production-size budget — the ceiling on what
|
||||
production's own pair count may cost relative to the 332-pair hand-list it
|
||||
replaced. Re-run without `--check` **several times** and copy the top of the
|
||||
observed `ratio_450` range plus headroom — the spread between runs on this box
|
||||
is wider than the effect being measured at 450, so a single run cannot set it.
|
||||
observed production-size ratio range plus headroom — the spread between runs on
|
||||
this box is wider than the effect being measured near production, so a single
|
||||
run cannot set it. Publish the raw ratios and apply only the pre-registered
|
||||
`typed_ratio > ratio` noise rule; do not silently discard another run to make a
|
||||
budget pass.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"_comment": "ratio_450_budget — ceiling on what production's 450-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. Observed 0.94x and 1.05x across two runs on the reference box (i.e. inside run-to-run noise; it came out faster than 332 once). The budget carries headroom for that spread — compare typed_ratio_450 (1.10-1.17x) for this box's floor. Raise it only with a measured range, never a single run.",
|
||||
"ratio_450_budget": 1.3
|
||||
"_comment": "ratio_461_budget — operational ceiling on what production's 461-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. #2801 measured 1.20-1.42x across three comparable Windows runs (typed floor 1.05-1.12x), so 1.5x leaves explicit host headroom. All six raw runs are published in README.md; two meet the pre-registered typed_ratio > ratio noise rule, while one additional 2.705x run is not silently discarded and would fail this gate. Raise the budget only with a published measured range, never a single run.",
|
||||
"ratio_461_budget": 1.5
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
*
|
||||
* `src/core/lbug/schema.ts` declares its relation pairs from two cross products
|
||||
* plus a small hand-written remainder, and it justifies NOT adding a third cross
|
||||
* product with a number: anchored queries cost ~1.04× at 450 declared pairs but
|
||||
* 1.6× at 786 and 2.1× at 1024. That measurement previously lived in a scratch
|
||||
* directory, so the claim could not be re-checked when someone proposed
|
||||
* widening a rule. This is it, committed.
|
||||
* product with a number: anchored queries cost ~1.04× near production's pair
|
||||
* count but 1.6× at 786 and 2.1× at 1024. That measurement previously lived in
|
||||
* a scratch directory, so the claim could not be re-checked when someone
|
||||
* proposed widening a rule. This is it, committed.
|
||||
*
|
||||
* WHAT IT MEASURES. Against a real `@ladybugdb/core` database, with byte-identical
|
||||
* DATA at every size, it times the query shape whose plan actually depends on the
|
||||
|
|
@ -34,7 +34,8 @@
|
|||
* same query at every size, and the only variable is how many UNUSED pairs the
|
||||
* table declares:
|
||||
* - 332 — the pre-#2792 hand-written list (the historical baseline);
|
||||
* - 450 — production today (two cross products + 72 hand-declared);
|
||||
* - 450 — production before Record became linkable (#2801);
|
||||
* - 461 — production today (two cross products + 69 hand-declared);
|
||||
* - 641 — the third cross product schema.ts defers
|
||||
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union,
|
||||
* Namespace, Impl, TypeAlias, Static, Template}`), which would leave
|
||||
|
|
@ -43,8 +44,8 @@
|
|||
* third rule (it is 641; 786 is kept as a measured waypoint);
|
||||
* - 1024 — the full cross product, the ceiling.
|
||||
*
|
||||
* Ratios are reported against 332, the smallest size — `ratio_450` is the
|
||||
* number schema.ts quotes.
|
||||
* Ratios are reported against 332, the smallest size — the production-size
|
||||
* ratio is the number schema.ts quotes.
|
||||
*
|
||||
* CORRECTNESS GATE. Before timing anything it round-trips the REAL
|
||||
* `SCHEMA_QUERIES` through a real database and asserts that
|
||||
|
|
@ -61,9 +62,9 @@
|
|||
* node --import tsx bench/schema-pairs/measure.mjs # print JSON lines
|
||||
* node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json
|
||||
*
|
||||
* `--check` fails if the correctness gate breaks, or if `ratio_450` exceeds its
|
||||
* budget — i.e. if production's own pair count starts costing materially more
|
||||
* than the hand-written list it replaced.
|
||||
* `--check` fails if the correctness gate breaks, or if the production-size
|
||||
* ratio exceeds its budget — i.e. if production's own pair count starts
|
||||
* costing materially more than the hand-written list it replaced.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
|
@ -85,9 +86,14 @@ const lbug = (await import('@ladybugdb/core')).default;
|
|||
|
||||
// ---- sizes + the pair enumeration every size is a prefix of ----
|
||||
|
||||
const SIZES = [332, 450, 641, 786, 1024];
|
||||
const REFERENCE_SIZE = 332; // ratios are relative to this
|
||||
const PRODUCTION_SIZE = 450; // the size schema.ts ships
|
||||
// Derive production from the same executable DDL the correctness gate
|
||||
// round-trips. A LINKABLE_LABELS widening must not require a second copied
|
||||
// count here — and cannot silently select a stale/missing budget key.
|
||||
const PRODUCTION_SIZE = parseRelationSchemaPairs(RELATION_SCHEMA).size;
|
||||
const SIZES = [...new Set([REFERENCE_SIZE, 450, PRODUCTION_SIZE, 641, 786, 1024])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
|
||||
// The four pairs the synthetic data uses. Pinned to the FRONT of the
|
||||
// enumeration so they are declared at every size — otherwise a smaller pair set
|
||||
|
|
@ -338,8 +344,13 @@ if (!CHECK) {
|
|||
process.stdout.write(JSON.stringify(summary) + '\n');
|
||||
} else {
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const budget = baselines[`ratio_${PRODUCTION_SIZE}_budget`];
|
||||
if (budget !== undefined && summary[`ratio_${PRODUCTION_SIZE}`] >= budget) {
|
||||
const budgetKey = `ratio_${PRODUCTION_SIZE}_budget`;
|
||||
const budget = baselines[budgetKey];
|
||||
if (budget === undefined) {
|
||||
failures.push(`no ${budgetKey} in baselines.json — the production gate is disarmed`);
|
||||
} else if (typeof budget !== 'number' || !Number.isFinite(budget)) {
|
||||
failures.push(`${budgetKey} must be a finite number (got ${JSON.stringify(budget)})`);
|
||||
} else if (summary[`ratio_${PRODUCTION_SIZE}`] >= budget) {
|
||||
failures.push(
|
||||
`production pair set (${PRODUCTION_SIZE}) costs ${summary[`ratio_${PRODUCTION_SIZE}`]}× vs ` +
|
||||
`${REFERENCE_SIZE} pairs, >= budget ${budget} (untyped ${reference.untyped_ms}ms -> ` +
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832",
|
||||
"fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
|
|
@ -12,11 +12,13 @@
|
|||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
|
||||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303."
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
|
||||
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5.",
|
||||
"_rebaselined_2873_undecided_satisfaction_fixtures": "#2873: added test/fixtures/lang-resolution/go-extern-qualified-signatures/ (5 Go files) and go-undecided-satisfaction/ (1 Go file) as the committed regression fixtures for out-of-repo package qualifiers in method signatures and for a satisfaction check that cannot be decided. Go fixture_count 116 -> 122. Prior e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18 -> 9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is resolution-time (signatureContextForFile recovers an identity for unresolvable imports) plus a tri-state verdict, neither of which runs during capture; go was the ONLY language whose fingerprint drifted and every other language matched its baseline on the same run."
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
|
||||
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
|
||||
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.",
|
||||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.",
|
||||
|
|
@ -33,8 +35,10 @@
|
|||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc",
|
||||
"fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
|
||||
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.",
|
||||
"_rebaselined_callable_flow": "Callable-value-flow facts for C++ function pointers/references, reference aliases, contextual arity, arguments, and member-pointer syntax. Prior 6ab657c8f9bfe988a3759098c2cffdcc0443def75ff263f1282b82c21d96e931 -> 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710; measured scaling ratio 1.069 < 1.5.",
|
||||
|
|
@ -46,22 +50,31 @@
|
|||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
|
||||
"capture_groups_small": 5021,
|
||||
"capture_groups_large": 16021,
|
||||
"capture_groups_fp": 4605,
|
||||
"fixture_count": 279
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc",
|
||||
"fingerprint": "2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.",
|
||||
"capture_groups_small": 4259,
|
||||
"capture_groups_large": 13609,
|
||||
"capture_groups_fp": 2657,
|
||||
"fixture_count": 178
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809",
|
||||
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
|
|
@ -72,7 +85,11 @@
|
|||
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.",
|
||||
"capture_groups_small": 5507,
|
||||
"capture_groups_large": 17607,
|
||||
"capture_groups_fp": 3556,
|
||||
"fixture_count": 202
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c",
|
||||
|
|
@ -107,8 +124,9 @@
|
|||
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5."
|
||||
},
|
||||
"dart": {
|
||||
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
|
||||
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator<String>`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.",
|
||||
"_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.",
|
||||
"_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.",
|
||||
|
|
@ -117,8 +135,11 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9",
|
||||
"fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.",
|
||||
"_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).",
|
||||
"_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
|
|
@ -130,18 +151,27 @@
|
|||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.",
|
||||
"capture_groups_small": 6255,
|
||||
"capture_groups_large": 20005,
|
||||
"capture_groups_fp": 3586,
|
||||
"fixture_count": 209,
|
||||
"_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633",
|
||||
"fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.",
|
||||
"_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.",
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.",
|
||||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965",
|
||||
"fingerprint": "05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
|
||||
"_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.",
|
||||
|
|
@ -152,10 +182,19 @@
|
|||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.",
|
||||
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5."
|
||||
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.",
|
||||
"_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped \u2014 so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.",
|
||||
"capture_groups_small": 4503,
|
||||
"capture_groups_large": 14403,
|
||||
"capture_groups_fp": 2465,
|
||||
"fixture_count": 167,
|
||||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.",
|
||||
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
|
||||
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
|
||||
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594",
|
||||
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
@ -166,10 +205,11 @@
|
|||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594."
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.",
|
||||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2",
|
||||
"fingerprint": "f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
|
|
@ -181,6 +221,11 @@
|
|||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.",
|
||||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2."
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
|
||||
"_rebaselined_2960_declared_package_fixture": "#2960 adds four Kotlin declared-package import-resolution fixture files. This is fixture-corpus growth only: fixture_count 137 -> 141 and capture_groups_fp 2334 -> 2367; the synthetic capture counts remain 4753/15203, no Kotlin scope-capture query or implementation changed, and package resolution runs after capture. Other language fingerprints matched their baselines in the same CI run. Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832.",
|
||||
"capture_groups_small": 4753,
|
||||
"capture_groups_large": 15203,
|
||||
"capture_groups_fp": 2367,
|
||||
"fixture_count": 141
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,10 +209,22 @@ const LANGS = [
|
|||
// Heritage-bearing: `: public Base, public Mixin` (single + multiple
|
||||
// inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added
|
||||
// (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956).
|
||||
//
|
||||
// Also GENERIC-MEMBER-bearing (#2833): `Repo<Entity_n> repo;` is a member
|
||||
// whose declared type is a bare `template_type`, and
|
||||
// `std::vector<Entity_n> items;` is the far commoner spelling where a
|
||||
// `qualified_identifier` WRAPS that template_type. Both were absent, and
|
||||
// their absence is why two successive rounds of `field_declaration`
|
||||
// type-binding rules landed with a byte-identical cpp fingerprint: the gate
|
||||
// could not see a member field it had no instance of. With them present,
|
||||
// reverting either round of rules drifts the fingerprint, which is the
|
||||
// property that makes the gate worth running.
|
||||
header:
|
||||
'#include <string>\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||||
'#include <string>\n#include <vector>\n\ntemplate <typename T>\nclass Repo {\n public:\n void save(T v) {}\n};\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` +
|
||||
` Repo<Entity${n}> repo;\n` +
|
||||
` std::vector<Entity${n}> items;\n` +
|
||||
` long getId() const { return id; }\n` +
|
||||
` void setName(std::string v) { name = v; }\n};\n\n`,
|
||||
},
|
||||
|
|
@ -255,14 +267,15 @@ const LANGS = [
|
|||
fixturePrefix: 'java',
|
||||
exts: ['.java'],
|
||||
file: 'bench.java',
|
||||
// Java was previously unbenched. Heritage-bearing: extends Base + implements
|
||||
// Marker (both forms) so the @reference.inherits synth (#1951) is driven at scale.
|
||||
// Java was previously unbenched. Class and record heritage both implement
|
||||
// Marker so the @reference.inherits synth (#1951, #2900) is driven at scale.
|
||||
header: 'package generated;\n\nclass Base {}\n\ninterface Marker {}\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} extends Base implements Marker {\n` +
|
||||
` long id = 0L;\n String name = "";\n` +
|
||||
` public long getId() { return this.id; }\n` +
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n`,
|
||||
` public void setName(String v) { this.name = v; }\n}\n\n` +
|
||||
`record RecordEntity${n}(long id) implements Marker {}\n\n`,
|
||||
},
|
||||
{
|
||||
name: 'java-local-types',
|
||||
|
|
|
|||
|
|
@ -503,7 +503,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) {
|
|||
|
||||
if (currentHead === lastCommit) return '';
|
||||
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings });
|
||||
const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true });
|
||||
return (
|
||||
`[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
|
||||
`Run \`${analyzeCmd}\` to refresh the knowledge graph.`
|
||||
|
|
|
|||
|
|
@ -523,7 +523,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'}). ` +
|
||||
|
|
|
|||
|
|
@ -5,9 +5,19 @@
|
|||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
* 3. npm 11+ without pnpm but with bunx → `bunx` (dodges the same crash)
|
||||
* 4. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 5. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 6. bun-only → `bunx`
|
||||
* 7. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The bun branches exist because a Node toolchain is no longer implied: on a
|
||||
* bun-only machine npm, npx and pnpm are all absent, so every rung above
|
||||
* resolved to `npx` and the emitted command could not run at all. `bunx` is
|
||||
* bun's install-free one-shot runner and needs no allow-build equivalent — bun
|
||||
* skips lifecycle scripts unconditionally for a `bunx` fetch, which the native
|
||||
* loader recovers from directly (see core/lbug/native-check.ts). Both bun rungs
|
||||
* gate on `bunx` actually running, not merely existing on PATH — see `hasBun`.
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
|
|
@ -42,7 +52,12 @@ const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
|||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
// under a second, so the realistic cost is far lower. The bun rungs add at most
|
||||
// one more 1s probe (`bunx --version`), reached only when pnpm is unusable and
|
||||
// npm is 11+ or unreadable, for a ~8s theoretical cap. That cap needs an absent
|
||||
// pnpm to burn its full second, which only Windows can do (`shell: true` spawns
|
||||
// cmd.exe); on POSIX an absent pnpm ENOENTs in ~1ms, so the real ceiling is
|
||||
// unmoved.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
|
|
@ -104,9 +119,17 @@ function resolveOnPath(
|
|||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// One spawn of `<command> --version` → { ran, major, minor } (versions null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
//
|
||||
// `ran` is liveness, kept separate from the version because a PATH hit proves a
|
||||
// file exists, not that it works, and the two answers differ: a banner-printing
|
||||
// or oddly-versioned tool is alive with `major: null`, while a stale shim left by
|
||||
// a partial uninstall is neither. Only the bun rung consults `ran` today (see
|
||||
// hasBun) — it is the one runner with no version to read, so a dedicated probe is
|
||||
// its only liveness signal; pnpm gets the same evidence for free from the version
|
||||
// spawn it must make anyway, and deliberately forgives an unreadable one (#1939).
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
|
|
@ -131,11 +154,13 @@ function probeVersion(command) {
|
|||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
ran: true,
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
// Spawn failure, non-zero exit, or the timeout — the command did not run.
|
||||
return { ran: false, major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +201,14 @@ function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* Resolve `gitnexus` | `pnpm` | `bun` | `npx`. `GITNEXUS_INVOCATION` forces a
|
||||
* mode (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
* inject `{ npmMajor, pnpmMajor, bunPresent, bunRuns }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx' || forced === 'bun') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
|
@ -202,12 +227,33 @@ function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
|||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// bun usability is resolved lazily: only the two branches below can select it,
|
||||
// so a machine with pnpm, or with npm < 11, never pays the PATH scan or the
|
||||
// spawn. `bunx` (not `bun`) is probed because `bunx` is what the resolved
|
||||
// command actually runs. Two gates, `&&`-ordered cheapest first: a spawn-free
|
||||
// PATH scan, then liveness — a PATH hit alone would route a present-but-broken
|
||||
// shim to a command that can only fail at execution time.
|
||||
let bunCache;
|
||||
const hasBun = () => {
|
||||
if (bunCache === undefined) {
|
||||
const present = 'bunPresent' in deps ? Boolean(deps.bunPresent) : Boolean(probe('bunx'));
|
||||
bunCache = present && ('bunRuns' in deps ? Boolean(deps.bunRuns) : probeVersion('bunx').ran);
|
||||
}
|
||||
return bunCache;
|
||||
};
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// Same crash, no pnpm to fall back on: bunx is install-free and unaffected.
|
||||
if (npmMajor !== null && npmMajor >= 11 && hasBun()) return 'bun';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
// Neither npm nor pnpm — bunx is the only install-free runner left. Without
|
||||
// this rung a bun-only machine fell through to `npx`, which is not installed
|
||||
// there, so the emitted command failed with "npx: command not found".
|
||||
if (hasBun()) return 'bun';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
|
@ -218,8 +264,25 @@ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
|||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* bun's install-free one-shot runner. Deliberately flag-free: bun has no
|
||||
* per-invocation `--allow-build` equivalent (`--trust` is a `bun add`/`bun
|
||||
* install` flag that writes trustedDependencies into a project package.json,
|
||||
* which a one-shot `bunx` has nowhere to put), so the skipped lifecycle copy is
|
||||
* recovered by the native loader instead of by the invocation.
|
||||
*/
|
||||
function formatBunxCommand(gitnexusArgs) {
|
||||
return `bunx ${NPX_REF} ${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
|
||||
|
|
@ -238,7 +301,8 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
const mightUsePnpm =
|
||||
forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx' && forced !== 'bun');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
|
|
@ -252,6 +316,7 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
if (mode === 'bun') return formatBunxCommand(`analyze${suffix}`);
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +333,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'bun') return { program: 'bunx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
|
|
@ -279,6 +345,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatBunxCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
|
|
@ -291,7 +358,7 @@ module.exports = {
|
|||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue