Compare commits
117
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c978c9b3d4 | ||
|
|
a93ecee068 | ||
|
|
b9008024ab | ||
|
|
cfe4e49c41 | ||
|
|
1e2aaeabf9 | ||
|
|
fa8033859f | ||
|
|
2cb39bc09b | ||
|
|
05e67c683f | ||
|
|
66daf27910 | ||
|
|
e12b526e07 | ||
|
|
5f0841c8bc | ||
|
|
b090f2b4e1 | ||
|
|
f86150707c | ||
|
|
4b787be835 | ||
|
|
a62a7e56cb | ||
|
|
f18ff521fc | ||
|
|
150a95bae4 | ||
|
|
5d710413d7 | ||
|
|
ed33489e38 | ||
|
|
eaf0a3052a | ||
|
|
5d1695f66a | ||
|
|
bef3da59a7 | ||
|
|
e234dac849 | ||
|
|
26894d835b | ||
|
|
2a5bbbeaae | ||
|
|
252fbabd51 | ||
|
|
85727ca625 | ||
|
|
4bc8622642 | ||
|
|
23bf594a70 | ||
|
|
2f15c1ece1 | ||
|
|
7dae4fcc41 | ||
|
|
d71fd1688b | ||
|
|
7b38b8aae2 | ||
|
|
b565c7c990 | ||
|
|
97c1f85e87 | ||
|
|
11fc43b425 | ||
|
|
50715e3894 | ||
|
|
ca95df6316 | ||
|
|
9d609cc386 | ||
|
|
128a199970 | ||
|
|
76409783aa | ||
|
|
99168be773 | ||
|
|
d9d6318b64 | ||
|
|
46eb0ebf56 | ||
|
|
eeea46466b | ||
|
|
ca3e1755c2 | ||
|
|
6acdc49f06 | ||
|
|
b1445daf04 | ||
|
|
d903152eba | ||
|
|
6c572749b0 | ||
|
|
7556a8e73a | ||
|
|
c8117d1292 | ||
|
|
681a352006 | ||
|
|
5e012c373b | ||
|
|
05d269ec28 | ||
|
|
d5b2edddc4 | ||
|
|
4870879b21 | ||
|
|
c916c88361 | ||
|
|
966ddb981e | ||
|
|
05151b1079 | ||
|
|
5458ddce77 | ||
|
|
3161938730 | ||
|
|
d4449b4ec8 | ||
|
|
a229e8e77b | ||
|
|
50c6acb108 | ||
|
|
baf57bec88 | ||
|
|
5ce448a93a | ||
|
|
1bbc876336 | ||
|
|
5e8690f992 | ||
|
|
efcab45560 | ||
|
|
73a6a5376e | ||
|
|
1c4993251c | ||
|
|
06c3fb360d | ||
|
|
2006a3e5ac | ||
|
|
66f9ec8eff | ||
|
|
ac9a2ee12f | ||
|
|
39e9b40136 | ||
|
|
a8a8a3710d | ||
|
|
eb69f667ab | ||
|
|
2b6e7ffbd9 | ||
|
|
2c066d46a1 | ||
|
|
fb94dba484 | ||
|
|
7fc797e2ce | ||
|
|
51e667808a | ||
|
|
84ac88a741 | ||
|
|
fc6007e70b | ||
|
|
87b91c821e | ||
|
|
952ada70c5 | ||
|
|
060fe75715 | ||
|
|
d15f8bef54 | ||
|
|
f72d9a99c6 | ||
|
|
64efc202f6 | ||
|
|
67cc4c6d94 | ||
|
|
a3e7dfa8a6 | ||
|
|
ccf0b8b73c | ||
|
|
9ad48c173e | ||
|
|
954b184248 | ||
|
|
be3833d9c9 | ||
|
|
dc96bb048a | ||
|
|
5a0f5e81db | ||
|
|
8c1983a8bf | ||
|
|
231ad71d40 | ||
|
|
df2ed009ce | ||
|
|
dd3527327d | ||
|
|
d3de5fa5d5 | ||
|
|
4c06d64a3b | ||
|
|
2a3d14057a | ||
|
|
a9fef2c68d | ||
|
|
8d71847791 | ||
|
|
061e123d72 | ||
|
|
a4954368ad | ||
|
|
be1071143a | ||
|
|
3d8aa7f435 | ||
|
|
4606e24f25 | ||
|
|
74653a8ffc | ||
|
|
8db51184ab | ||
|
|
1b5c6e5b6a |
@@ -0,0 +1,43 @@
|
||||
# GitNexus PR Reviewer Swarm — Claude Code adapter
|
||||
|
||||
This is the **Claude Code** entrypoint for the cross-CLI GitNexus PR reviewer swarm. The
|
||||
review logic itself is CLI-neutral and lives in **[`pr-swarm-review/`](../pr-swarm-review/README.md)**
|
||||
— that README is the canonical guide and covers every CLI (Claude Code, Gemini, Copilot,
|
||||
Cursor, Codex, and any AGENTS.md-aware agent).
|
||||
|
||||
## Invocation (Claude Code)
|
||||
|
||||
```
|
||||
/gitnexus-pr-swarm-review <PR URL or PR number>
|
||||
```
|
||||
|
||||
Runs in **Swarm mode**: the coordinator skill dispatches the seven `gitnexus-*` subagents in
|
||||
parallel (lanes 1–2 first, 3–6 in parallel, lane 7 last as a hard gate).
|
||||
|
||||
## Files in this adapter
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `.claude/skills/gitnexus-pr-swarm-review/SKILL.md` | Coordinator — runs Swarm mode per `pr-swarm-review/orchestration.md` |
|
||||
| `.claude/agents/gitnexus-*.md` | Seven thin subagent wrappers; each reads its canonical persona in `pr-swarm-review/personas/` |
|
||||
|
||||
Each subagent keeps valid Claude Code frontmatter (model, tools, etc.); the mechanical
|
||||
verifier lanes (`test-ci-verifier`, `branch-hygiene-reviewer`) run on Haiku, the analytical
|
||||
lanes on Sonnet.
|
||||
|
||||
## Key properties
|
||||
|
||||
- **Read-only.** Tools limited to Read/Grep/Glob/Bash, and every persona enforces an
|
||||
explicit permitted/prohibited Bash list. No agent edits files, commits, or posts.
|
||||
- **Evidence-grounded**; **missing visibility becomes verification work**; **manually invoked.**
|
||||
|
||||
## Editing
|
||||
|
||||
Edit review behavior in the canonical files under `pr-swarm-review/` (orchestration +
|
||||
personas), **not** in these wrappers. After adding or editing files in `.claude/agents/`,
|
||||
restart Claude Code so it reloads the agent definitions.
|
||||
|
||||
## Relationship to `/gitnexus-pr-review`
|
||||
|
||||
Coexists with the single-agent `/gitnexus-pr-review` skill (a linear checklist using GitNexus
|
||||
MCP tools). This swarm is the multi-persona deep production-readiness review.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-branch-hygiene-reviewer
|
||||
description: "GitNexus branch hygiene and mergeability reviewer. Use to classify merge state, conflicts, stale branches, merge-from-main commits, unrelated churn, mixed domains, and whether rebase or split is required."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-haiku-4-5-20251001
|
||||
maxTurns: 30
|
||||
---
|
||||
|
||||
# GitNexus Branch Hygiene & Mergeability Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/02-branch-hygiene-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-docs-dod-reviewer
|
||||
description: "GitNexus docs and Definition-of-Done reviewer. Use to translate repo guidance, linked issues, changed domains, docs requirements, release notes, and acceptance criteria into a PR-specific DoD."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 30
|
||||
---
|
||||
|
||||
# GitNexus Docs & Definition-of-Done Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/06-docs-dod-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-pr-facts-historian
|
||||
description: "GitNexus PR facts and repository-history investigator. Use to gather PR identity, visible GitHub state, changed files, commits, linked issues, related PRs, historical fixes, regressions, stale follow-ups, and missing visibility."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 40
|
||||
---
|
||||
|
||||
# GitNexus PR Facts & Repository-History Investigator
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/01-pr-facts-historian.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-risk-architect
|
||||
description: "GitNexus production-risk reviewer. Use for risk-model-first review of changed files, runtime behavior, multi-domain changes, user impact, failure modes, compatibility, and merge-blocking risk."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 40
|
||||
---
|
||||
|
||||
# GitNexus Production-Risk Architect
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/03-risk-architect.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-security-boundary-reviewer
|
||||
description: "GitNexus security and trust-boundary reviewer. Use for auth, permissions, secrets, injection, unsafe parsing, external input handling, hidden Unicode, YAML/Docker/workflow risks, and suspicious non-ASCII hygiene."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 35
|
||||
---
|
||||
|
||||
# GitNexus Security & Trust-Boundary Reviewer
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/05-security-boundary-reviewer.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-synthesis-critic
|
||||
description: "GitNexus final review synthesis critic. Use to check whether the final PR review is evidence-grounded, risk-prioritized, GitNexus-specific, non-generic, and follows required verdict rules."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-sonnet-4-6
|
||||
maxTurns: 25
|
||||
---
|
||||
|
||||
# GitNexus Final-Review Synthesis Critic
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/07-synthesis-critic.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: gitnexus-test-ci-verifier
|
||||
description: "GitNexus test and CI reviewer. Use to verify whether changed behavior is covered by targeted tests, whether CI actually runs those tests, and whether workflow changes weaken validation."
|
||||
tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Bash
|
||||
model: claude-haiku-4-5-20251001
|
||||
maxTurns: 35
|
||||
---
|
||||
|
||||
# GitNexus Test & CI Verifier
|
||||
|
||||
Your complete operating spec — role, what to inspect, classifications, and the required output sections — lives in the canonical, CLI-neutral persona file:
|
||||
|
||||
**`pr-swarm-review/personas/04-test-ci-verifier.md`**
|
||||
|
||||
Read that file now with the Read tool and follow it exactly. It is the single source of truth shared across all AI CLIs; this subagent only adapts it to Claude Code. The orchestration contract (lane order, Swarm vs Solo execution, output structure) is in `pr-swarm-review/orchestration.md`.
|
||||
|
||||
## Rules (always enforced)
|
||||
|
||||
- **Do not edit files.** You are read-only.
|
||||
- **Bash is read-only.** Permitted: `git log`, `git diff`, `git show`, `git grep`, `git ls-files`, `gh pr view`, `gh pr diff`, `gh pr checks`, `gh issue view`, and inspection tools (`grep`, `cat`, `find`, `ls`). Prohibited: any command that writes files, modifies git state (`git commit`, `git add`, `git checkout -- <path>`), posts to GitHub (`gh pr comment`, `gh pr review`, `gh issue comment`), installs packages, or runs arbitrary scripts.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: gitnexus-pr-swarm-review
|
||||
description: "Run a GitNexus production-readiness pull request review using a coordinated reviewer swarm."
|
||||
---
|
||||
|
||||
# GitNexus PR Swarm Review (Claude Code adapter)
|
||||
|
||||
Use this skill to review a GitNexus pull request and produce a production-readiness review.
|
||||
|
||||
```
|
||||
/gitnexus-pr-swarm-review <PR URL or PR number>
|
||||
```
|
||||
|
||||
You are the **swarm coordinator**. The full review contract — lanes, dependencies,
|
||||
classifications, output structure, finding format, hidden-Unicode checks, and behavior
|
||||
rules — is the canonical, CLI-neutral spec:
|
||||
|
||||
**`pr-swarm-review/orchestration.md`** — read it now and follow it.
|
||||
|
||||
This adapter only pins the Claude Code specifics:
|
||||
|
||||
- **Run in Swarm mode.** Dispatch each lane as its own subagent via the Agent tool. The
|
||||
seven subagents are the project agents named `gitnexus-*` (one per persona); each reads
|
||||
its canonical persona under `pr-swarm-review/personas/`. Run lanes 1–2 first, lanes 3–6
|
||||
in parallel after, and lane 7 last on the draft.
|
||||
- **Lane 7 is a hard gate.** Do not emit the final review while the synthesis critic's
|
||||
"Required corrections before posting" section is non-empty — revise and re-run it.
|
||||
- Stay **read-only**: investigate and report; never edit, commit, or post.
|
||||
|
||||
Do not flatten the review into a generic checklist; delegate to the subagents and
|
||||
synthesize per `orchestration.md`.
|
||||
@@ -0,0 +1,17 @@
|
||||
# GitNexus PR Swarm Review
|
||||
|
||||
You are the GitNexus PR review coordinator. Review the pull request named after this command
|
||||
(a PR URL or number for `https://github.com/abhigyanpatwari/GitNexus`). If none was given,
|
||||
ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly — it is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1–2 first, then 3–6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
@@ -0,0 +1,19 @@
|
||||
description = "GitNexus production-readiness PR swarm review (Solo mode)"
|
||||
|
||||
prompt = """
|
||||
You are the GitNexus PR review coordinator. Review this pull request: {{args}}
|
||||
(a PR URL or number for https://github.com/abhigyanpatwari/GitNexus). If no target was
|
||||
given, ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly. It is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1-2 first, then 3-6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty — revise and re-run it otherwise.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
"""
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
description: 'GitNexus production-readiness PR swarm review (Solo mode)'
|
||||
mode: 'agent'
|
||||
---
|
||||
|
||||
You are the GitNexus PR review coordinator. Review the pull request the user names (a PR URL
|
||||
or number for `https://github.com/abhigyanpatwari/GitNexus`). If none was given, ask for one.
|
||||
|
||||
Read `pr-swarm-review/orchestration.md` in this repository and follow it exactly — it is the
|
||||
canonical, CLI-neutral review contract (lanes, classifications, output structure, finding
|
||||
format, hidden-Unicode checks, behavior rules).
|
||||
|
||||
Run in **Solo mode**: you are a single agent, so perform all seven lanes yourself in
|
||||
dependency order, adopting each persona in `pr-swarm-review/personas/0N-*.md` in turn
|
||||
(lanes 1–2 first, then 3–6, then lane 7). Keep every lane's findings in context. Lane 7
|
||||
(synthesis critic) is a hard gate: do not emit the final review until its "Required
|
||||
corrections before posting" section is empty.
|
||||
|
||||
Stay strictly read-only: investigate and report; never edit files, commit, or post to GitHub.
|
||||
@@ -24,6 +24,19 @@ name: Scope Resolution Parity
|
||||
# When the set is empty (e.g. mid-Ring-3 for every language), the parity
|
||||
# matrix is skipped and the workflow reports success — no-op until a
|
||||
# language is explicitly claimed migrated.
|
||||
#
|
||||
# ── Consolidation (chore/vitest-speed-strategy) ────────────────────────
|
||||
# Previously each language was a separate GitHub Actions matrix job,
|
||||
# meaning N languages × 1 checkout+install+build per shard. The build
|
||||
# cost dwarfed the test cost (~5 min setup for ~15 sec test execution).
|
||||
#
|
||||
# Now a single job runs `scripts/run-parity.ts` which loops through all
|
||||
# migrated languages sequentially (2 vitest invocations per language:
|
||||
# legacy + registry-primary). All failures are collected and reported
|
||||
# at the end (equivalent to the old fail-fast: false behavior).
|
||||
#
|
||||
# Adding a new language to MIGRATED_LANGUAGES still requires no workflow
|
||||
# edit — the script auto-discovers the set at runtime.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -37,7 +50,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
languages: ${{ steps.read.outputs.languages }}
|
||||
has-any: ${{ steps.read.outputs.has-any }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -49,65 +61,27 @@ jobs:
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `tsx` evaluates the TS source directly (no build step), imports
|
||||
# the exported `Set`, and emits a GH-Actions-friendly JSON matrix.
|
||||
LANGS=$(npx tsx scripts/ci-list-migrated-languages.ts)
|
||||
COUNT=$(printf '%s' "$LANGS" | jq 'length')
|
||||
HAS_ANY="false"
|
||||
if [[ "$COUNT" -gt 0 ]]; then HAS_ANY="true"; fi
|
||||
echo "languages=$LANGS" >> "$GITHUB_OUTPUT"
|
||||
echo "has-any=$HAS_ANY" >> "$GITHUB_OUTPUT"
|
||||
echo "Discovered $COUNT migrated language(s): $LANGS"
|
||||
echo "Parity matrix will run: $HAS_ANY"
|
||||
echo "Parity will run: $HAS_ANY"
|
||||
|
||||
parity:
|
||||
name: ${{ matrix.lang.slug }} parity
|
||||
name: scope-resolution parity
|
||||
needs: discover
|
||||
if: needs.discover.outputs.has-any == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
# One language failing must not abort the others — we want the full
|
||||
# parity matrix result on a single CI run so a reviewer sees every
|
||||
# regression at once rather than one-at-a-time.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
lang: ${{ fromJSON(needs.discover.outputs.languages) }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Verify resolver test file exists
|
||||
- name: Run parity for all migrated languages
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TEST_FILE="test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"
|
||||
if [[ ! -f "$TEST_FILE" ]]; then
|
||||
echo "::error title=Missing resolver test::\
|
||||
Expected $TEST_FILE for '${{ matrix.lang.slug }}' (listed in \
|
||||
MIGRATED_LANGUAGES). Either fix the slug or add the test file \
|
||||
before listing this language as migrated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolver tests — legacy DAG (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=0)
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }}
|
||||
# Explicitly force the flag to `0` even though it also defaults to
|
||||
# `MIGRATED_LANGUAGES.has(lang)` — once a language is in the set,
|
||||
# the default flips to registry-primary, so an unset env var would
|
||||
# silently re-run the same path as step #2. `env FOO=0 cmd` spawns
|
||||
# `cmd` with the override scoped to just this invocation.
|
||||
run: env "$FLAG_NAME=0" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"
|
||||
|
||||
- name: Resolver tests — registry-primary (REGISTRY_PRIMARY_${{ matrix.lang.envvar }}=1)
|
||||
shell: bash
|
||||
working-directory: gitnexus
|
||||
env:
|
||||
FLAG_NAME: REGISTRY_PRIMARY_${{ matrix.lang.envvar }}
|
||||
run: env "$FLAG_NAME=1" npx vitest run "test/integration/resolvers/${{ matrix.lang.slug }}.test.ts"
|
||||
run: npx tsx scripts/run-parity.ts
|
||||
|
||||
@@ -59,19 +59,125 @@ jobs:
|
||||
gitnexus-web/web-test-results.json
|
||||
retention-days: 5
|
||||
|
||||
# Platform-sensitive subset only — the full suite runs on Ubuntu above.
|
||||
# See gitnexus/scripts/cross-platform-tests.ts for the file list and
|
||||
# rationale for each included test.
|
||||
cross-platform:
|
||||
name: ${{ matrix.os }}
|
||||
name: ${{ matrix.os }} (platform-sensitive)
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Ubuntu already covered by the coverage job above
|
||||
os: [windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
- run: npx vitest run
|
||||
- name: Run platform-sensitive tests
|
||||
run: npx tsx scripts/run-cross-platform.ts
|
||||
working-directory: gitnexus
|
||||
|
||||
# End-to-end smoke test for the #1728 packaging fix: pack the published
|
||||
# tarball, install it globally into a temp prefix, and assert no junction
|
||||
# creation (the EPERM root cause) plus working CLI plus vendor cleanliness
|
||||
# (#836). Runs on windows-latest because that is the platform the fix
|
||||
# targets; the in-repo `npm ci` job above only exercises the dev-tree path
|
||||
# and skips the tarball reify step where the historical EPERM occurred.
|
||||
packaged-install-smoke:
|
||||
name: packaged install smoke (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
# persist-credentials: false — this job runs npm pack + npm install -g
|
||||
# from a tarball and never pushes back; the token in .git/config would
|
||||
# be at risk of leaking through any future artifact-upload step
|
||||
# (zizmor artipacked audit). Disable upfront.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
with:
|
||||
build: 'true'
|
||||
|
||||
- name: Pack gitnexus tarball
|
||||
shell: bash
|
||||
run: npm pack
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Install gitnexus tarball into isolated prefix
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREFIX="$RUNNER_TEMP/gitnexus-smoke"
|
||||
mkdir -p "$PREFIX"
|
||||
TARBALL=$(find . -maxdepth 1 -name 'gitnexus-*.tgz' -print -quit)
|
||||
if [ -z "$TARBALL" ]; then
|
||||
echo "ERROR: no gitnexus-*.tgz tarball found in $(pwd)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing $TARBALL into $PREFIX"
|
||||
npm install -g --prefix "$PREFIX" "./$TARBALL" --no-audit --no-fund
|
||||
echo "PREFIX=$PREFIX" >> "$GITHUB_ENV"
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Assert no junctions or vendor build artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Locate the installed gitnexus package across npm prefix layouts
|
||||
# (lib/node_modules on POSIX, node_modules on Windows).
|
||||
for candidate in "$PREFIX/lib/node_modules/gitnexus" "$PREFIX/node_modules/gitnexus"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
INSTALLED="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "${INSTALLED:-}" ]; then
|
||||
echo "ERROR: installed gitnexus package not found under $PREFIX" >&2
|
||||
ls -la "$PREFIX" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Installed package at: $INSTALLED"
|
||||
|
||||
# #836 invariant: no node_modules/ or build/ under any vendor/*.
|
||||
BAD=$(find "$INSTALLED/vendor" \( -name node_modules -o -name build \) -print 2>/dev/null || true)
|
||||
if [ -n "$BAD" ]; then
|
||||
echo "ERROR: vendor tree contains forbidden build artifacts (#836):" >&2
|
||||
echo "$BAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# #1728 invariant: materialized grammar dirs are real directories,
|
||||
# not junctions/symlinks (which is what the EPERM regression created).
|
||||
for name in tree-sitter-dart tree-sitter-proto tree-sitter-swift; do
|
||||
entry="$INSTALLED/node_modules/$name"
|
||||
if [ ! -e "$entry" ]; then
|
||||
echo "WARN: $name not materialized (toolchain/prebuild may be unavailable on $RUNNER_OS)"
|
||||
continue
|
||||
fi
|
||||
if [ -L "$entry" ]; then
|
||||
echo "ERROR: $entry is a symlink/junction — #1728 regression" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d "$entry" ]; then
|
||||
echo "ERROR: $entry is not a directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Assert gitnexus --version works
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
"$PREFIX/gitnexus.cmd" --version
|
||||
else
|
||||
"$PREFIX/bin/gitnexus" --version
|
||||
fi
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -69,6 +69,6 @@ jobs:
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: high
|
||||
comment-summary-in-pr: on-failure
|
||||
|
||||
@@ -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@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
+6
-4
@@ -68,8 +68,8 @@ gitnexus-web/test-results/
|
||||
eval/.coverage
|
||||
eval/.hypothesis/
|
||||
|
||||
# Design docs (local only)
|
||||
docs/plans/
|
||||
# Local docs
|
||||
docs/
|
||||
|
||||
gitnexus/test/fixtures/mini-repo/*.md
|
||||
gitnexus/test/fixtures/mini-repo/.claude
|
||||
@@ -91,11 +91,13 @@ gitnexus/vendor/**/node_modules/
|
||||
|
||||
.claude-flow/
|
||||
|
||||
.claude/agents/
|
||||
.claude/agents/*
|
||||
!.claude/agents/gitnexus-*.md
|
||||
.claude/commands/
|
||||
.claude/helpers
|
||||
.claude/skills/
|
||||
.claude/skills/*
|
||||
!.claude/skills/gitnexus/
|
||||
!.claude/skills/gitnexus-pr-swarm-review/
|
||||
|
||||
.history/
|
||||
|
||||
|
||||
@@ -44,10 +44,23 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** skills in `.claude/skills/gitnexus/`; MCP rules in `gitnexus:start` block below.
|
||||
|
||||
## PR Swarm Review (cross-CLI)
|
||||
|
||||
To run a production-readiness review of a GitNexus pull request from **any** AI CLI, follow
|
||||
the canonical, CLI-neutral spec **[`pr-swarm-review/orchestration.md`](pr-swarm-review/orchestration.md)**
|
||||
(seven read-only review personas under `pr-swarm-review/personas/`). It defines two
|
||||
execution modes with the same output contract: **Swarm mode** (parallel subagents, e.g.
|
||||
Claude Code) and **Solo mode** (one agent runs all lanes sequentially — Codex, Gemini,
|
||||
Cursor, Copilot, or any agent reading this file). Per-CLI entrypoints are thin wrappers
|
||||
listed in [`pr-swarm-review/README.md`](pr-swarm-review/README.md); edit review logic only
|
||||
in the canonical files, never in the wrappers. The review is read-only — it never edits,
|
||||
commits, or posts.
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Version | Change |
|
||||
|------|---------|--------|
|
||||
| 2026-05-22 | 1.8.0 | Kotlin added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). Closes #1756 (companion-vs-instance dispatch) and #1757 (lambda scopes); refs #1746. RFC §6.4 corpus criterion waived (corpus-mode wiring is #927-scope); fixture criterion met. |
|
||||
| 2026-04-23 | 1.7.0 | TypeScript added to `MIGRATED_LANGUAGES` (registry-primary call resolution by default). |
|
||||
| 2026-04-20 | 1.6.0 | Added scope-resolution pipeline pointer (RFC #909 Ring 3); Python migrated to registry-primary. |
|
||||
| 2026-04-19 | 1.5.0 | Cross-repo impact (#794): `impact`/`query`/`context` accept `repo: "@<group>"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. |
|
||||
|
||||
@@ -35,7 +35,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
|
||||
> _Like DeepWiki, but deeper._ DeepWiki helps you _understand_ code. GitNexus lets you _analyze_ it — because a knowledge graph tracks every relationship, not just descriptions.
|
||||
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
**TL;DR:** The **Web UI** is a quick way to chat with any repo. The **CLI + MCP** is how you make your AI agent actually reliable — it gives Cursor, Claude Code, Antigravity, Codex, and friends a deep architectural view of your codebase so they stop missing dependencies, breaking call chains, and shipping blind edits. Even smaller models get full architectural clarity, making it compete with Goliath models.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
|
||||
| | **CLI + MCP** | **Web UI** |
|
||||
| ----------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
|
||||
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **For** | Daily development with Cursor, Claude Code, Antigravity, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
|
||||
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
|
||||
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
|
||||
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
|
||||
@@ -106,7 +106,7 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the native `tree-sitter-dart` and `tree-sitter-proto` builds. Dart/Proto files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip vendored grammar materialize/build (`tree-sitter-dart`, `tree-sitter-proto`, `tree-sitter-swift`). Dart/Proto/Swift files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
@@ -114,16 +114,20 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
|
||||
|
||||
### Editor Support
|
||||
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| --------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
| Editor | MCP | Skills | Hooks (auto-augment) | Support |
|
||||
| -------------------- | --- | ------ | --------------------------------------------------------------------------------------- | ------------ |
|
||||
| **Claude Code** | Yes | Yes | Yes (PreToolUse + PostToolUse) | **Full** |
|
||||
| **Cursor** | Yes | Yes | Yes (postToolUse, [manual install](gitnexus-cursor-integration/README.md#hook-install)) | **Full** |
|
||||
| **Antigravity** (Google) | Yes | Yes | Yes (AfterTool, [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/))[¹](#fn-antigravity-hooks) | **Full** |
|
||||
| **Codex** | Yes | Yes | — | MCP + Skills |
|
||||
| **Windsurf** | Yes | — | — | MCP |
|
||||
| **OpenCode** | Yes | Yes | — | MCP + Skills |
|
||||
|
||||
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that detect a stale index after commits and prompt the agent to reindex.
|
||||
|
||||
<a id="fn-antigravity-hooks"></a>
|
||||
> ¹ **Antigravity hooks** follow the [Gemini CLI hooks reference](https://geminicli.com/docs/hooks/reference/) (Antigravity 2.0 is the documented successor to Gemini CLI). Augmentation runs in `AfterTool` because `BeforeTool` has no context-injection channel in the Gemini contract — the agent sees graph context appended to the tool result via `hookSpecificOutput.additionalContext`. Stale-index hints land in the same channel after a successful `git commit/merge/rebase/cherry-pick/pull`. The schema may evolve if Antigravity-specific hook docs diverge from Gemini CLI's; the implementation will track those changes.
|
||||
|
||||
## Community Integrations
|
||||
|
||||
Built by the community — not officially maintained, but worth checking out.
|
||||
@@ -168,6 +172,21 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp
|
||||
}
|
||||
```
|
||||
|
||||
**Antigravity** (Google) — `~/.gemini/antigravity/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@latest", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `gitnexus setup` also merges an `AfterTool` entry into `~/.gemini/settings.json` (under the canonical [Gemini CLI hooks schema](https://geminicli.com/docs/hooks/reference/)) and installs skills to `~/.gemini/antigravity/skills/`. Existing user hooks are preserved. The hook adapter's path is rewritten at install time, so run `gitnexus setup` rather than hand-editing.
|
||||
|
||||
**OpenCode** (`~/.config/opencode/config.json`):
|
||||
|
||||
```json
|
||||
@@ -203,6 +222,7 @@ gitnexus analyze --skip-git # Index folders that are not Git repositories
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
|
||||
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
gitnexus analyze --workers <n> # Parse worker pool size (default: cores-1, capped at 16; 0 = sequential)
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
|
||||
@@ -237,15 +257,18 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
||||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size. `0` disables the pool (sequential fallback). Equivalent to `--workers <n>`. | Constrained containers (cgroup CPU limits), CI runners with explicit quotas, or debugging a worker-only crash via `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED` | unset | When `1`, emits `[deferred-profile]` timing/progress logs for the post-chunk deferred resolution band (imports → heritage → buildHeritageMap → legacy call resolution). Implied by `GITNEXUS_VERBOSE`. | Diagnosing analyze stalls in "Resolving calls (all chunks)" on large Java/Kotlin repos (issue #1741) without the full verbose ingestion noise. |
|
||||
| `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. |
|
||||
| `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size <kb>`. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` | `8388608` (8 MB) | Per-job byte budget the pool will send to a worker in one `postMessage`. | Very large individual files; mostly diagnostic — bumping past 8 MB risks structured-clone memory pressure. |
|
||||
| `GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT` | `3` | Max replacement spawns per worker slot before the slot is dropped from the active rotation. Bounds respawn loops on a chronically-crashing slot. | Hosts where a flaky worker should retry more (raise) or fail-fast (lower) before the slot is dropped. |
|
||||
| `GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS` | `5 × subBatchTimeoutMs` | Total retry wall-time budget per job before quarantining. Combined with `timeoutBackoffFactor`, prevents exponentially-growing retries from stalling for hours. | Slow files that legitimately need long total retry windows; lower to fail-fast on stalls. |
|
||||
| `GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD`| `max(3, poolSize)` | Per-slot consecutive deaths before the pool's circuit breaker trips. After tripping, every subsequent dispatch rejects until a fresh pool is created. | Hosts where a SIGSEGV-prone native grammar should trip the breaker sooner; CI runners that should fail loudly. |
|
||||
| `GITNEXUS_CHUNK_BYTE_BUDGET` | `2097152` (2 MB) | Chunk boundary used for cache-key composition and dispatch. Smaller = finer-grained cache hits but more dispatch overhead. | Tuning incremental-analyze cache behavior on monorepos. |
|
||||
| `GITNEXUS_NO_GITIGNORE` | unset | When set, skips `.gitignore` parsing. `.gitnexusignore` is still honored. | Indexing a repo whose `.gitignore` excludes files you actually want indexed (e.g., generated code committed for cross-repo lookup). |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips native builds for `tree-sitter-dart` / `tree-sitter-proto` at install time. | Installing on a host without a C++ toolchain; you're willing to skip Dart/Proto parsing. |
|
||||
| `GITNEXUS_SKIP_OPTIONAL_GRAMMARS` | unset | When `=1` strictly, skips vendored grammar materialize/build for `tree-sitter-dart`, `tree-sitter-proto`, and `tree-sitter-swift` at install time. | Installing on a host without a C++ toolchain or where Swift prebuilds don't match; you're willing to skip Dart/Proto/Swift parsing. |
|
||||
|
||||
#### Publishing to understand-quickly (opt-in)
|
||||
|
||||
@@ -635,7 +658,15 @@ UPSTREAM (what depends on this):
|
||||
authRouter [IMPORTS] -> src/routes/auth.ts
|
||||
```
|
||||
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`
|
||||
Options: `maxDepth`, `minConfidence`, `relationTypes` (`CALLS`, `IMPORTS`, `EXTENDS`, `IMPLEMENTS`), `includeTests`, `limit` (max symbols per depth, default 100), `offset` (pagination start per depth), `summaryOnly` (counts and risk only, omits symbol list)
|
||||
|
||||
**Disambiguation** — when several symbols share the target name, `impact` returns a ranked `ambiguous` candidate list instead of guessing. Narrow it with `target_uid` (exact, zero-ambiguity), `file_path`, or `kind` (`Function`, `Class`, `Method`, …). From the CLI these are `--uid`, `--file`, and `--kind`, matching `gitnexus context`:
|
||||
|
||||
```bash
|
||||
gitnexus impact get_embeddings # → ambiguous: lists ranked candidates
|
||||
gitnexus impact get_embeddings --file src/embed.py # → resolves to the one in that file
|
||||
gitnexus impact get_embeddings --uid "Function:src/embed.py:get_embeddings" # exact
|
||||
```
|
||||
|
||||
### Process-Grouped Search
|
||||
|
||||
|
||||
+92
-47
@@ -10,32 +10,37 @@ How we structure tests and which commands to run locally and in CI.
|
||||
| Web UI | `gitnexus-web/`| Vitest | Unit/component tests |
|
||||
| Web UI E2E | `gitnexus-web/`| Playwright | Run when changing UI flows |
|
||||
|
||||
## Commands (local)
|
||||
## Test lanes
|
||||
|
||||
From repository root, unless noted:
|
||||
### `gitnexus/` commands
|
||||
|
||||
**`gitnexus` (CLI / library)**
|
||||
From `gitnexus/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ------------------------ | ---------------------------------------------------- | ------------------------------- |
|
||||
| `npm test` | Full suite (all 3 vitest projects) | Before opening a PR |
|
||||
| `npm run test:unit` | Unit tests only (`test/unit/`) | Tight development loop |
|
||||
| `npm run test:integration` | Integration tests (`test/integration/`) | After changing pipelines, DB, workers |
|
||||
| `npm run test:coverage` | Full suite + v8 coverage with thresholds | Checking coverage impact |
|
||||
| `npm run test:parity` | Scope-resolution parity for all migrated languages | After changing resolver or scope code |
|
||||
| `npm run test:cross-platform` | Platform-sensitive subset only | Debugging a Windows/macOS issue |
|
||||
| `npm run test:watch` | Vitest in watch mode | Active development |
|
||||
|
||||
### `gitnexus-web/` commands
|
||||
|
||||
From `gitnexus-web/`:
|
||||
|
||||
| Command | What it runs | When to use |
|
||||
| ---------------------- | --------------------------------- | ------------------------------ |
|
||||
| `npm test` | Unit/component tests (vitest) | After changing web code |
|
||||
| `npm run test:coverage`| Unit tests + coverage | Checking coverage impact |
|
||||
| `npm run test:e2e` | Playwright browser tests | After changing UI flows (requires `gitnexus serve` + `npm run dev`) |
|
||||
|
||||
### Before opening a PR
|
||||
|
||||
```bash
|
||||
cd gitnexus
|
||||
npm install
|
||||
npm run build
|
||||
npm test # full suite: vitest run
|
||||
npm run test:unit # unit only: vitest run test/unit
|
||||
npm run test:integration # integration suite
|
||||
npm run test:coverage
|
||||
npx tsc --noEmit # typecheck (matches CI)
|
||||
```
|
||||
|
||||
**`gitnexus-web`**
|
||||
|
||||
```bash
|
||||
cd gitnexus-web
|
||||
npm install
|
||||
npm test # unit tests (vitest)
|
||||
npx tsc -b --noEmit # typecheck (matches CI)
|
||||
npm run test:coverage
|
||||
npm run test:e2e # Playwright (requires gitnexus serve + npm run dev)
|
||||
cd gitnexus && npx tsc --noEmit && npm test
|
||||
cd ../gitnexus-web && npx tsc -b --noEmit && npm test
|
||||
```
|
||||
|
||||
## Pre-commit hook
|
||||
@@ -50,22 +55,79 @@ Tests do **not** run in the pre-commit hook — they run in CI (`ci-tests.yml`)
|
||||
|
||||
Skip with `git commit --no-verify` (use sparingly).
|
||||
|
||||
## Vitest projects
|
||||
|
||||
`gitnexus/vitest.config.ts` defines three projects for safety isolation:
|
||||
|
||||
| Project | Files | Parallelism | Purpose |
|
||||
| ---------- | ----------------------------- | ----------- | ---------------------------------------------- |
|
||||
| `lbug-db` | Native LadybugDB integration tests (explicit list) | Sequential | Prevents file-lock conflicts from native mmap addon |
|
||||
| `cli-e2e` | `skills-e2e.test.ts` | Sequential | CLI process spawning requires serial execution |
|
||||
| `default` | Everything else | Parallel | Fast execution for pure logic and parser tests |
|
||||
|
||||
When adding a new test that uses native LadybugDB (`@ladybugdb/core`), add it to the `lbug-db` project's explicit include list and the `default` project's exclude list.
|
||||
|
||||
## Test categories
|
||||
|
||||
- **Unit** — Pure logic, parsers, graph/query helpers; fast; no network.
|
||||
- **Integration** — Real combinations (filesystem, MCP wiring, larger pipelines) as already organized under `gitnexus/test/integration`.
|
||||
- **Eval-style / golden sets** — For agent- or classification-style behavior, keep labeled inputs and expected outputs (JSON or table-driven tests) and run them in CI when relevant.
|
||||
- **Resolver / parity** — Language-specific call-resolution tests in `test/integration/resolvers/`.
|
||||
- **E2E (web)** — Critical user paths only; prefer `data-testid` attributes for stable selectors. Tests run against real backend (`gitnexus serve`) and Vite dev server.
|
||||
|
||||
## Performance metrics (targets)
|
||||
## Scope-resolution parity
|
||||
|
||||
Set targets to match team expectations, then tune to this repo’s CI reality:
|
||||
Migrated languages (listed in `MIGRATED_LANGUAGES` in `src/core/ingestion/registry-primary-flag.ts`) are tested in both legacy and registry-primary modes on every PR.
|
||||
|
||||
| Metric | Target (initial) | Notes |
|
||||
| ------------------- | ---------------- | ------------------------------------------ |
|
||||
| Unit coverage | Align with CI | CI runs Vitest with coverage in `gitnexus` |
|
||||
| Unit wall time | Fast PR feedback | Use `vitest run test/unit` for tight loop |
|
||||
| Integration duration| < few minutes | Guard heavy tests with env flags if needed |
|
||||
For each migrated language, CI runs the resolver test file twice:
|
||||
1. `REGISTRY_PRIMARY_<LANG>=0` — legacy DAG path
|
||||
2. `REGISTRY_PRIMARY_<LANG>=1` — registry-primary path
|
||||
|
||||
Both must pass. Known legacy gaps are listed in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES` in `test/integration/resolvers/helpers.ts` and are automatically skipped in legacy mode.
|
||||
|
||||
Adding a language to `MIGRATED_LANGUAGES` automatically enrolls it in parity — no workflow or config edit needed. The test file must exist at `test/integration/resolvers/<slug>.test.ts`.
|
||||
|
||||
Run parity locally: `cd gitnexus && npm run test:parity`
|
||||
|
||||
Run for a single language: `cd gitnexus && npx tsx scripts/run-parity.ts --language python`
|
||||
|
||||
## Cross-platform testing
|
||||
|
||||
Windows and macOS CI runs only the platform-sensitive test subset (~50 files out of 373). The full suite runs on Ubuntu.
|
||||
|
||||
The subset is defined in `gitnexus/scripts/cross-platform-tests.ts` and includes:
|
||||
|
||||
- **Platform-specific logic** — tests with `process.platform` guards, path.sep behavior, EPERM/EBUSY error classification
|
||||
- **Native LadybugDB** — all `lbug-*` integration tests (N-API addon with known platform-varying behavior)
|
||||
- **Process spawning / CLI** — tests using real `child_process.spawn`, shell quoting, CLI invocations
|
||||
- **Worker threads** — tests spawning real `worker_threads`
|
||||
- **Native addon loading** — tree-sitter grammar loading smoke tests
|
||||
- **Filesystem behavior** — CRLF handling, directory walking, symlinks
|
||||
|
||||
When adding a platform-sensitive test, add it to the appropriate section in `scripts/cross-platform-tests.ts`.
|
||||
|
||||
### Confirming no tests are orphaned
|
||||
|
||||
Every test file matches one of the three vitest projects. To verify:
|
||||
|
||||
```bash
|
||||
cd gitnexus
|
||||
npx vitest list 2>/dev/null | wc -l # should match total test count
|
||||
```
|
||||
|
||||
To check the cross-platform list is up to date, run `npm run test:cross-platform` — it fails fast if any listed file is missing.
|
||||
|
||||
## CI integration
|
||||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
| Workflow | Jobs | Purpose |
|
||||
| --------------------- | ------------------------------ | ------------------------------------------------ |
|
||||
| `ci-quality.yml` | format, lint, typecheck, typecheck-web, workflow-convention | Code quality gates |
|
||||
| `ci-tests.yml` | ubuntu/coverage, cross-platform (Win/Mac), packaged-install-smoke | Full suite + coverage on Ubuntu; platform-sensitive subset on Win/Mac |
|
||||
| `ci-scope-parity.yml` | discover, parity | Scope-resolution parity for all migrated languages |
|
||||
| `ci-e2e.yml` | e2e (chromium) | Playwright E2E, gated on `gitnexus-web/**` changes |
|
||||
|
||||
The `CI Gate` job in `ci.yml` is the single required check for branch protection. It requires quality, tests, e2e, and scope-parity to all pass.
|
||||
|
||||
## Regression testing
|
||||
|
||||
@@ -76,23 +138,6 @@ Re-run the full relevant suite when:
|
||||
- Graph schema, query contracts, or MCP tool shapes change
|
||||
- Dependencies with parsing or runtime impact upgrade
|
||||
|
||||
## CI integration
|
||||
|
||||
GitHub Actions (`.github/workflows/ci.yml`) orchestrate:
|
||||
|
||||
- **`ci-quality.yml`** — prettier format check, eslint lint, `tsc --noEmit` for `gitnexus/`, `tsc -b --noEmit` for `gitnexus-web/`
|
||||
- **`ci-tests.yml`** — `vitest run` with coverage (ubuntu) + cross-platform (macOS, Windows)
|
||||
- **`ci-e2e.yml`** — Playwright E2E tests, gated on `gitnexus-web/**` changes
|
||||
|
||||
Local checks before pushing:
|
||||
|
||||
```bash
|
||||
cd gitnexus && npx tsc --noEmit && npm test
|
||||
cd ../gitnexus-web && npx tsc -b --noEmit && npm test
|
||||
```
|
||||
|
||||
Or rely on the pre-commit hook which runs these automatically for staged files.
|
||||
|
||||
## User acceptance / beta (optional)
|
||||
|
||||
For staged releases or UI betas: deploy to a staging environment, collect structured feedback, watch errors and latency, then iterate before a wider release.
|
||||
|
||||
@@ -30,6 +30,12 @@ services:
|
||||
container_name: ${WEB_CONTAINER_NAME:-gitnexus-web}
|
||||
ports:
|
||||
- '${WEB_HOST_PORT:-4173}:4173'
|
||||
# Override the backend URL served to the browser. The default
|
||||
# (http://localhost:4747) works when both containers run locally.
|
||||
# Set GITNEXUS_BACKEND_URL in your .env or shell for remote/custom setups:
|
||||
# GITNEXUS_BACKEND_URL=http://<server-ip>:4747
|
||||
environment:
|
||||
- GITNEXUS_BACKEND_URL=${GITNEXUS_BACKEND_URL:-}
|
||||
depends_on:
|
||||
gitnexus-server:
|
||||
condition: service_healthy
|
||||
|
||||
+116
-61
@@ -1,12 +1,39 @@
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, isAbsolute, normalize, relative, resolve } from 'node:path';
|
||||
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = Number(process.env.PORT || '4173');
|
||||
const root = resolve(process.cwd(), 'dist');
|
||||
|
||||
function isValidUrl(value) {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonForScriptTag(obj) {
|
||||
return JSON.stringify(obj)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e')
|
||||
.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.`,
|
||||
);
|
||||
}
|
||||
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
@@ -22,22 +49,22 @@ const contentTypes = {
|
||||
|
||||
// Static asset server for the gitnexus-web Docker image.
|
||||
//
|
||||
// Path-injection containment: the request handler is intentionally a single
|
||||
// inline pipeline with no helper functions on the path-data flow. Each
|
||||
// filesystem sink (stat, createReadStream) is immediately preceded by the
|
||||
// canonical `path.relative` containment check that CodeQL's
|
||||
// `js/path-injection` query recognizes as a sanitizer barrier:
|
||||
// TOCTOU prevention: every filesystem interaction uses open() to get a
|
||||
// file handle; subsequent reads use handle.readFile()/createReadStream().
|
||||
//
|
||||
// const rel = relative(root, candidate);
|
||||
// if (rel.startsWith('..') || isAbsolute(rel)) reject;
|
||||
// // candidate is now proven inside `root`
|
||||
// CodeQL js/file-system-race: the query pairs open() calls when their
|
||||
// path arguments are data-flow aliased. This handler uses exactly two
|
||||
// open() calls whose paths are provably independent:
|
||||
// 1. open(requestedPath) — derived from the URL
|
||||
// 2. open(spaFallback) — the constant root/index.html
|
||||
// Because spaFallback has no data-flow from the request, CodeQL cannot
|
||||
// pair them as a check/use on the same path.
|
||||
//
|
||||
// Earlier iterations of this file used a helper (`resolveWithinRoot`) and a
|
||||
// `startsWith(root + sep)` check. Both were semantically correct but neither
|
||||
// was recognized by CodeQL: `startsWith(root + sep)` is not in the analyzer's
|
||||
// barrier-pattern set, and helper-based sanitization is not followed across
|
||||
// the request handler's reassignment paths in vanilla JS. The inline-at-sink
|
||||
// shape below is the documented analyzer-friendly idiom.
|
||||
// Path-injection containment: each open() is preceded by a
|
||||
// path.relative() barrier that CodeQL recognizes as a sanitizer.
|
||||
|
||||
const spaFallback = resolve(root, 'index.html');
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
@@ -56,62 +83,90 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const initialPath = resolve(root, cleanPath);
|
||||
const requestedPath = resolve(root, cleanPath);
|
||||
|
||||
// Sanitizer barrier #1 — guards the first stat() sink.
|
||||
const initialRel = relative(root, initialPath);
|
||||
if (initialRel.startsWith('..') || isAbsolute(initialRel)) {
|
||||
const rel = relative(root, requestedPath);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
let handle;
|
||||
try {
|
||||
const initialStat = await stat(initialPath).catch(() => null);
|
||||
let servePath = requestedPath;
|
||||
|
||||
// Pick the path we actually serve. Note: any branch reassigns to a
|
||||
// freshly-resolved path; the next sanitizer barrier re-validates.
|
||||
let finalPath;
|
||||
if (initialStat?.isDirectory()) {
|
||||
finalPath = resolve(initialPath, 'index.html');
|
||||
} else if (!initialStat?.isFile()) {
|
||||
finalPath = resolve(root, 'index.html');
|
||||
// Try to open the exact path the client asked for.
|
||||
handle = await open(requestedPath, 'r').catch(() => null);
|
||||
if (handle) {
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
// Directories and other non-files fall through to SPA fallback.
|
||||
await handle.close();
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If the requested path wasn't a regular file, serve the SPA entry
|
||||
// point. spaFallback is a module-level constant with no data-flow
|
||||
// from the request, so this open() is independent of the one above.
|
||||
if (!handle) {
|
||||
servePath = spaFallback;
|
||||
handle = await open(spaFallback, 'r').catch(() => null);
|
||||
if (!handle) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
const s = await handle.stat();
|
||||
if (!s.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const isHtml = extname(servePath) === '.html' || !extname(servePath);
|
||||
const cacheControl = servePath.includes(`${sep}assets${sep}`)
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache';
|
||||
const contentType = contentTypes[extname(servePath)] || 'application/octet-stream';
|
||||
|
||||
if (isHtml && configScript) {
|
||||
const raw = await handle.readFile('utf8');
|
||||
await handle.close();
|
||||
handle = null;
|
||||
if (!raw.includes('</head>')) {
|
||||
console.warn('[gitnexus-web] Could not inject config: no </head> tag found in HTML');
|
||||
}
|
||||
const html = raw.includes('</head>') ? raw.replace('</head>', `${configScript}</head>`) : raw;
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': cacheControl,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Length': buf.length,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
res.end(buf);
|
||||
} else {
|
||||
finalPath = initialPath;
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': cacheControl,
|
||||
'Content-Type': contentType,
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = handle.createReadStream();
|
||||
handle = null;
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
// Sanitizer barrier #2 — guards both the second stat() and the
|
||||
// createReadStream() sinks. No reassignment of finalPath happens
|
||||
// between this guard and either sink, so the analyzer can prove
|
||||
// containment for both.
|
||||
const finalRel = relative(root, finalPath);
|
||||
if (finalRel.startsWith('..') || isAbsolute(finalRel)) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalStat = await stat(finalPath).catch(() => null);
|
||||
if (!finalStat?.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': finalPath.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
'Content-Type': contentTypes[extname(finalPath)] || 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = createReadStream(finalPath);
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.writeHead(500);
|
||||
res.end(error instanceof Error ? error.message : 'Internal server error');
|
||||
res.end('Internal server error');
|
||||
} finally {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+142
-1
@@ -70,8 +70,20 @@ before(async () => {
|
||||
await waitForServer(serverPort);
|
||||
});
|
||||
|
||||
function killAndWait(proc) {
|
||||
return new Promise((resolve) => {
|
||||
if (!proc || proc.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
proc.once('exit', resolve);
|
||||
proc.kill();
|
||||
if (proc.exitCode !== null) resolve();
|
||||
});
|
||||
}
|
||||
|
||||
after(async () => {
|
||||
child?.kill();
|
||||
await killAndWait(child);
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -122,3 +134,132 @@ it('returns 404 when dist/index.html is missing', async () => {
|
||||
const res = await rawGet(serverPort, '/nonexistent-page');
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
// -- Config injection: server-level integration tests ---
|
||||
|
||||
function spawnServerWithEnv(cwd, port, env) {
|
||||
const proc = spawn(process.execPath, [serverScript], {
|
||||
cwd,
|
||||
env: { ...process.env, PORT: String(port), ...env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function withInjectionServer(envOverrides, fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-inject-'));
|
||||
const distDir = join(dir, 'dist');
|
||||
const assetsDir = join(distDir, 'assets');
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(distDir, 'index.html'),
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body>app</body></html>',
|
||||
);
|
||||
await writeFile(join(assetsDir, 'style.abc.css'), 'body{}');
|
||||
|
||||
const port = await getFreePort();
|
||||
const proc = spawnServerWithEnv(dir, port, envOverrides);
|
||||
try {
|
||||
await waitForServer(port);
|
||||
await fn(port);
|
||||
} finally {
|
||||
await killAndWait(proc);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('injects __GITNEXUS_CONFIG__ into / when GITNEXUS_BACKEND_URL is valid', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.body.includes('window.__GITNEXUS_CONFIG__'),
|
||||
'Expected __GITNEXUS_CONFIG__ in response body',
|
||||
);
|
||||
assert.ok(res.body.includes('http://10.0.0.1:4747'), 'Expected backend URL in response body');
|
||||
});
|
||||
});
|
||||
|
||||
it('injects __GITNEXUS_CONFIG__ into SPA fallback routes', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/some/deep/link');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
res.body.includes('window.__GITNEXUS_CONFIG__'),
|
||||
'Expected __GITNEXUS_CONFIG__ in SPA fallback response',
|
||||
);
|
||||
assert.ok(
|
||||
res.body.includes('http://10.0.0.1:4747'),
|
||||
'Expected backend URL in SPA fallback response',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL is not set', async () => {
|
||||
await withInjectionServer({}, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ when env var is unset',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL is invalid', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'not-a-url' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for invalid URL',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject when GITNEXUS_BACKEND_URL uses a non-http protocol', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'ftp://somehost:21' }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Expected no __GITNEXUS_CONFIG__ for non-http protocol',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('escapes </script> in GITNEXUS_BACKEND_URL to prevent XSS', async () => {
|
||||
const xssUrl = 'http://example.com/?x=</script><script>alert(1)</script>';
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: xssUrl }, async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const scriptMatches = res.body.match(/<script>/gi) || [];
|
||||
assert.equal(
|
||||
scriptMatches.length,
|
||||
1,
|
||||
`Expected exactly 1 <script> tag but found ${scriptMatches.length}: XSS breakout detected`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!res.body.includes('</script><script>'),
|
||||
'</script> must not appear unescaped -- would allow script breakout',
|
||||
);
|
||||
assert.ok(res.body.includes('\\u003c'), 'Angle brackets must be escaped as \\u003c');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not inject config into static assets', async () => {
|
||||
await withInjectionServer({ GITNEXUS_BACKEND_URL: 'http://10.0.0.1:4747' }, async (port) => {
|
||||
const res = await rawGet(port, '/assets/style.abc.css');
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(
|
||||
!res.body.includes('__GITNEXUS_CONFIG__'),
|
||||
'Static assets must not contain injected config',
|
||||
);
|
||||
assert.equal(res.body, 'body{}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# COBOL Code Indexing
|
||||
|
||||
GitNexus indexes COBOL codebases using a **regex-only extraction** strategy, bypassing tree-sitter entirely. This document explains why, how the pipeline works, and links to detailed sub-documents.
|
||||
|
||||
## Why Regex-Only?
|
||||
|
||||
The tree-sitter-cobol grammar (v0.0.1) has three critical limitations that make it unusable for production indexing:
|
||||
|
||||
| Issue | Impact | Severity |
|
||||
|-------|--------|----------|
|
||||
| External scanner hangs on ~5% of files | No timeout mechanism exists for the C scanner; the process blocks indefinitely | **Blocking** |
|
||||
| Only ~15% of paragraph headers detected | Most procedure-division paragraphs are invisible to the grammar | High |
|
||||
| Patch markers in cols 1-6 cause parse errors | Enterprise COBOL uses non-standard sequence area content (e.g., `mzADD`, `estero`, `#FIX`) | High |
|
||||
|
||||
Because the external scanner hang cannot be interrupted (there is no `setTimeoutMicros` equivalent for tree-sitter), using tree-sitter-cobol would hang the indexing pipeline on a non-trivial fraction of real-world files.
|
||||
|
||||
The regex-only approach provides:
|
||||
|
||||
- **Speed**: ~1ms per file average extraction time
|
||||
- **Reliability**: zero hangs, zero crashes across 13,000+ files
|
||||
- **Coverage**: captures all critical symbols -- program name, paragraphs, sections, CALL, PERFORM, COPY, data items (01-77, 88-level), file declarations, FD entries, EXEC SQL/CICS blocks, ENTRY points, and MOVE statements
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Repository Scan] --> B{File Detection}
|
||||
B -->|Extension match| C[COBOL file]
|
||||
B -->|GITNEXUS_COBOL_DIRS match| C
|
||||
B -->|No match| Z[Skip]
|
||||
|
||||
C --> D{Copybook?}
|
||||
D -->|Yes| E[Add to Copybook Map]
|
||||
D -->|No| F[Source Program]
|
||||
|
||||
E --> G[COPY Expansion Engine]
|
||||
F --> G
|
||||
|
||||
G -->|Inline copybook content| H[Expanded Source]
|
||||
H --> I[Patch Marker Cleanup]
|
||||
I --> J[Regex State Machine]
|
||||
|
||||
J --> K[Extracted Symbols]
|
||||
K --> L[Graph Model Builder]
|
||||
L --> M[Knowledge Graph]
|
||||
|
||||
subgraph "Per-Chunk Processing"
|
||||
G
|
||||
H
|
||||
I
|
||||
J
|
||||
K
|
||||
L
|
||||
end
|
||||
|
||||
subgraph "Post-Processing"
|
||||
M --> N[Community Detection]
|
||||
M --> O[Process Detection]
|
||||
M --> P[Contract Detection]
|
||||
end
|
||||
|
||||
style J fill:#e8f5e9,stroke:#2e7d32
|
||||
style G fill:#e3f2fd,stroke:#1565c0
|
||||
```
|
||||
|
||||
## COBOL vs Tree-Sitter Languages
|
||||
|
||||
| Feature | COBOL (Regex) | Tree-Sitter Languages |
|
||||
|---------|--------------|----------------------|
|
||||
| Parser | Single-pass regex state machine | tree-sitter grammar + queries |
|
||||
| Speed | ~1ms/file | ~5ms/file |
|
||||
| AST available | No | Yes |
|
||||
| COPY expansion | Yes (pre-processing step) | N/A |
|
||||
| Deep indexing | Data items, SQL, CICS, FD, ENTRY | Type annotations, generics, etc. |
|
||||
| Call extraction | PERFORM (intra-file) + CALL (cross-program) | AST-based call site detection |
|
||||
| Import extraction | COPY statements | `import`/`require`/`use`/`#include` |
|
||||
| Coverage | All critical symbols | Language-dependent query coverage |
|
||||
| Failure mode | Never hangs | External scanner can hang (COBOL only) |
|
||||
|
||||
## Sub-Documents
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [File Detection](./file-detection.md) | Extension mapping, `GITNEXUS_COBOL_DIRS`, copybook classification |
|
||||
| [COPY Expansion](./copy-expansion.md) | Copybook inlining, REPLACING transformations, cycle detection |
|
||||
| [Regex Extraction](./regex-extraction.md) | State machine, regex patterns, line processing |
|
||||
| [Deep Indexing](./deep-indexing.md) | Data items, EXEC SQL/CICS, file declarations, FD, ENTRY, MOVE |
|
||||
| [Graph Model](./graph-model.md) | COBOL-specific node types, edge types, full annotated example |
|
||||
| [Performance](./performance.md) | Benchmarks, worker pool tuning, caps, troubleshooting |
|
||||
|
||||
## Key Source Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `gitnexus/src/core/ingestion/cobol-preprocessor.ts` | Patch marker cleanup + regex extraction engine |
|
||||
| `gitnexus/src/core/ingestion/cobol-copy-expander.ts` | COPY statement expansion with REPLACING |
|
||||
| `gitnexus/src/core/ingestion/utils.ts` | `getLanguageFromPath`, `getLanguageFromFilename` |
|
||||
| `gitnexus/src/core/ingestion/pipeline.ts` | `isCobolCopybook`, `expandCobolCopies`, `detectCrossProgamContracts` |
|
||||
| `gitnexus/src/core/ingestion/workers/parse-worker.ts` | `processCobolRegexOnly` -- graph model builder |
|
||||
| `gitnexus/src/core/ingestion/workers/worker-pool.ts` | Configurable sub-batch size for COBOL |
|
||||
@@ -1,157 +0,0 @@
|
||||
# COBOL COPY Expansion
|
||||
|
||||
The COPY statement is COBOL's include mechanism -- analogous to `#include` in C or `import` in modern languages. GitNexus expands COPY statements **before** regex extraction so that symbols defined inside copybooks (data items, paragraphs, etc.) are visible in the program's extracted graph.
|
||||
|
||||
## Supported Syntax
|
||||
|
||||
### Basic COPY
|
||||
|
||||
```cobol
|
||||
COPY CPSESP.
|
||||
COPY "WORKGRID.CPY".
|
||||
```
|
||||
|
||||
Inlines the content of the named copybook, replacing the COPY line(s).
|
||||
|
||||
### COPY with REPLACING
|
||||
|
||||
```cobol
|
||||
COPY CPSESP REPLACING "ANAZI-KEY" BY "LK-KEY".
|
||||
COPY CPSESP REPLACING LEADING "ESP-" BY "LK-ESP-"
|
||||
LEADING "KPSESPL" BY "LK-KPSESPL".
|
||||
COPY LINKAGE REPLACING TRAILING "-IN" BY "-OUT".
|
||||
```
|
||||
|
||||
Three REPLACING types are supported:
|
||||
|
||||
| Type | Syntax | Behavior | Example |
|
||||
| ------------ | ------------------------------------ | --------------------------------------- | -------------------------------- |
|
||||
| **EXACT** | `REPLACING "OLD" BY "NEW"` | Replace exact identifier matches | `ANAZI-KEY` becomes `LK-KEY` |
|
||||
| **LEADING** | `REPLACING LEADING "PFX-" BY "NEW-"` | Replace prefix on all COBOL identifiers | `ESP-NAME` becomes `LK-ESP-NAME` |
|
||||
| **TRAILING** | `REPLACING TRAILING "-IN" BY "-OUT"` | Replace suffix on all COBOL identifiers | `DATA-IN` becomes `DATA-OUT` |
|
||||
|
||||
Multiple REPLACING clauses can appear in a single COPY statement. They are applied in order to each COBOL identifier in the copybook content.
|
||||
|
||||
### Multi-Line COPY
|
||||
|
||||
COPY statements can span multiple lines (standard COBOL continuation rules apply):
|
||||
|
||||
```cobol
|
||||
COPY CPSESP REPLACING
|
||||
- LEADING "ESP-" BY "LK-ESP-"
|
||||
- LEADING "KPSESPL" BY "LK-KPSESPL".
|
||||
```
|
||||
|
||||
Continuation lines (indicator `-` in column 7) are merged before COPY statement scanning.
|
||||
|
||||
## Expansion Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Pipeline
|
||||
participant Expander as COPY Expander
|
||||
participant Resolver
|
||||
participant Reader
|
||||
|
||||
Pipeline->>Pipeline: Identify all COBOL files
|
||||
Pipeline->>Pipeline: Classify copybooks vs programs
|
||||
Pipeline->>Reader: Read all copybook content upfront
|
||||
Reader-->>Pipeline: Copybook content map (name -> content)
|
||||
|
||||
loop For each source file in chunk
|
||||
Pipeline->>Expander: expandCopies(content, filePath, resolveFile, readFile)
|
||||
Expander->>Expander: Merge continuation lines
|
||||
Expander->>Expander: Detect COPY statements via regex
|
||||
|
||||
loop For each COPY statement (reverse order)
|
||||
Expander->>Resolver: resolveFile(copyTarget)
|
||||
Resolver-->>Expander: Copybook key or null
|
||||
|
||||
alt Resolved successfully
|
||||
Expander->>Reader: readFile(resolvedKey)
|
||||
Reader-->>Expander: Copybook content
|
||||
|
||||
Expander->>Expander: Apply REPLACING transformations
|
||||
Expander->>Expander: Recurse for nested COPYs (depth + 1)
|
||||
Expander->>Expander: Splice expanded content into output
|
||||
else Not resolved
|
||||
Expander->>Expander: Keep original COPY line
|
||||
end
|
||||
end
|
||||
|
||||
Expander-->>Pipeline: Expanded content + resolution metadata
|
||||
Pipeline->>Pipeline: Replace file content with expanded content
|
||||
end
|
||||
```
|
||||
|
||||
The return type `CopyExpansionResult` contains `expandedContent` and `copyResolutions`. The `expansionDepth` field has been removed from the return type (it was unused by callers).
|
||||
|
||||
COPY statement line numbers in `CopyResolution` are 1-based (consistent with the preprocessor's line numbering). The splice operation that replaces COPY lines with expanded content adjusts for 0-based array indexing internally.
|
||||
|
||||
## Cycle Detection
|
||||
|
||||
Circular COPY references (e.g., copybook A includes copybook B which includes copybook A) are detected and handled:
|
||||
|
||||
1. Each expansion chain maintains a `visited` set of resolved copybook paths
|
||||
2. If a copybook path is already in the visited set, the expansion is skipped
|
||||
3. A `warnedCircular` set (internal to `expandCopies()`, not a parameter) deduplicates warning messages within a single file expansion
|
||||
|
||||
Known circular copybooks in PROJECT-NAME: `ANAZI`, `ANDIP`, `QDIPE` (self-referential includes).
|
||||
|
||||
## Max Depth
|
||||
|
||||
Nested COPY expansion is limited to **10 levels** (`DEFAULT_MAX_DEPTH`). If a COPY chain exceeds this depth, a warning is logged and the remaining COPY statements are left unexpanded.
|
||||
|
||||
## Max Total Expansions
|
||||
|
||||
A breadth amplification guard caps the total number of COPY expansions across all branches within a single file to **500** (`MAX_TOTAL_EXPANSIONS`). This prevents exponential blowup from diamond-shaped COPY graphs where N copybooks each include N other copybooks. Once the limit is reached, further COPY statements in that file are left unexpanded and a single warning is logged.
|
||||
|
||||
## REPLACING Application Detail
|
||||
|
||||
The REPLACING engine works by scanning all COBOL identifiers (matching `\b[A-Z][A-Z0-9-]*\b`) in the copybook content and applying each replacement rule:
|
||||
|
||||
```
|
||||
Original copybook content:
|
||||
05 ESP-NAME PIC X(30).
|
||||
05 ESP-CODE PIC X(10).
|
||||
05 KPSESPL-FLAG PIC X(01).
|
||||
|
||||
After REPLACING LEADING "ESP-" BY "LK-ESP-" LEADING "KPSESPL" BY "LK-KPSESPL":
|
||||
05 LK-ESP-NAME PIC X(30).
|
||||
05 LK-ESP-CODE PIC X(10).
|
||||
05 LK-KPSESPL-FLAG PIC X(01).
|
||||
```
|
||||
|
||||
For LEADING replacements, the engine checks if each identifier starts with the `from` prefix (case-insensitive) and replaces only the prefix portion, preserving the rest of the identifier.
|
||||
|
||||
For TRAILING replacements, the same logic applies to suffixes.
|
||||
|
||||
For EXACT replacements, only identifiers that match the `from` value exactly (case-insensitive) are replaced.
|
||||
|
||||
## Copybook Resolution
|
||||
|
||||
The resolver tries multiple strategies to match a COPY target name to a copybook file:
|
||||
|
||||
1. **Exact match**: `COPY CPSESP` resolves to copybook named `CPSESP`
|
||||
2. **Strip extension**: `COPY WORKGRID.CPY` strips `.CPY` and resolves to `WORKGRID`
|
||||
3. **Add extension**: `COPY CPSESP` tries `CPSESP.CPY` and `CPSESP.COPY`
|
||||
|
||||
If no match is found, the COPY statement is left in place (unexpanded) and a resolution record with `resolvedPath: null` is created.
|
||||
|
||||
## Pipeline Integration
|
||||
|
||||
The expansion runs **per chunk**, after file content is read but before dispatch to worker threads:
|
||||
|
||||
1. All copybook files are read upfront (they are typically small, collectively under 100MB)
|
||||
2. Per chunk, the copybook map is merged with chunk content (in case a chunk contains copybooks)
|
||||
3. Only programs (not copybooks themselves) undergo expansion
|
||||
4. The expanded content replaces the original content in-place before worker dispatch
|
||||
|
||||
## Inline Comment Handling
|
||||
|
||||
The copy expander's `stripInlineComment()` helper is quote-aware: pipe characters (`|`) inside single- or double-quoted strings are preserved. This matches the same quote-aware logic used by the preprocessor.
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/cobol-copy-expander.ts` -- `expandCopies()`, `parseReplacingClause()`, `applyReplacing()`
|
||||
- `gitnexus/src/core/ingestion/pipeline.ts` -- `expandCobolCopies()`, copybook map construction, chunk integration
|
||||
@@ -1,312 +0,0 @@
|
||||
# COBOL Deep Indexing
|
||||
|
||||
Beyond basic symbol extraction (program name, paragraphs, CALL, PERFORM, COPY), GitNexus performs deep indexing of COBOL-specific constructs: data items, EXEC SQL/CICS blocks, file declarations, FD entries, ENTRY points, and MOVE statements.
|
||||
|
||||
## Data Items
|
||||
|
||||
### Level Numbers
|
||||
|
||||
| Level Range | Meaning | Graph Node Type |
|
||||
|-------------|---------|-----------------|
|
||||
| 01 | Record (group item) | `Record` |
|
||||
| 02-49 | Elementary/group items | `Property` |
|
||||
| 66 | RENAMES | `Property` |
|
||||
| 77 | Independent item | `Property` |
|
||||
| 88 | Condition name | `Const` |
|
||||
|
||||
FILLER items are skipped (no useful name for the graph).
|
||||
|
||||
### Clauses Parsed
|
||||
|
||||
The `parseDataItemClauses()` function extracts these clauses from the trailing text of a data item declaration:
|
||||
|
||||
| Clause | Pattern | Example |
|
||||
|--------|---------|---------|
|
||||
| `PIC` / `PICTURE` | `\bPIC(?:TURE)?\s+(?:IS\s+)?(\S+)` | `PIC X(30)`, `PICTURE IS 9(5)V99` |
|
||||
| `USAGE` | `\bUSAGE\s+(?:IS\s+)?(COMP\|BINARY\|...)` | `USAGE IS COMP-3`, `BINARY` |
|
||||
| `REDEFINES` | `\bREDEFINES\s+([A-Z][A-Z0-9-]+)` | `REDEFINES WK-DATE-NUM` |
|
||||
| `OCCURS` | `\bOCCURS\s+(\d+)` | `OCCURS 12 TIMES` |
|
||||
|
||||
Standalone COMP variants (without the `USAGE` keyword) are also detected: `COMP`, `COMP-1` through `COMP-6`, `COMP-X`, `BINARY`, `PACKED-DECIMAL`.
|
||||
|
||||
### Data Hierarchy
|
||||
|
||||
Data items form a hierarchical structure based on level numbers. The extractor uses a **stack algorithm**:
|
||||
|
||||
```
|
||||
Processing order:
|
||||
01 WK-RECORD -> push {01, WK-RECORD} -> parent: Module
|
||||
05 WK-NAME -> push {05, WK-NAME} -> parent: WK-RECORD (01 < 05)
|
||||
10 WK-FIRST -> push {10, WK-FIRST} -> parent: WK-NAME (05 < 10)
|
||||
10 WK-LAST -> pop WK-FIRST, push -> parent: WK-NAME (05 < 10)
|
||||
05 WK-CODE -> pop WK-LAST, WK-NAME -> parent: WK-RECORD (01 < 05)
|
||||
88 WK-ACTIVE -> (88 handled separately) -> parent: WK-CODE
|
||||
```
|
||||
|
||||
The stack maintains items where each entry's level is strictly less than the next. When a new item arrives with a level <= the top of stack, items are popped until the stack top has a smaller level. A `CONTAINS` edge is created from the stack top to the new item.
|
||||
|
||||
For 88-level condition names, the parent is the immediately preceding non-88 data item (found by scanning backwards).
|
||||
|
||||
### Annotated Example
|
||||
|
||||
```cobol
|
||||
01 WK-EMPLOYEE.
|
||||
05 WK-EMP-ID PIC 9(6).
|
||||
05 WK-EMP-NAME PIC X(30).
|
||||
05 WK-EMP-STATUS PIC X(01).
|
||||
88 WK-ACTIVE VALUE "A".
|
||||
88 WK-INACTIVE VALUE "I".
|
||||
05 WK-SALARY PIC 9(7)V99 COMP-3.
|
||||
05 WK-DEPT PIC X(04) OCCURS 3 TIMES.
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `Record` node: `WK-EMPLOYEE` (level 01, section: working-storage)
|
||||
- `Property` nodes: `WK-EMP-ID`, `WK-EMP-NAME`, `WK-EMP-STATUS`, `WK-SALARY`, `WK-DEPT`
|
||||
- `Const` nodes: `WK-ACTIVE` (values: `A`), `WK-INACTIVE` (values: `I`)
|
||||
- `CONTAINS` edges: `WK-EMPLOYEE -> WK-EMP-ID`, `WK-EMPLOYEE -> WK-EMP-NAME`, etc.
|
||||
- `CONTAINS` edges: `WK-EMP-STATUS -> WK-ACTIVE`, `WK-EMP-STATUS -> WK-INACTIVE`
|
||||
|
||||
### Data Item Cap
|
||||
|
||||
A maximum of **500 data items per file** (`MAX_DATA_ITEMS_PER_FILE`) are processed. Some COBOL programs (especially after COPY expansion) can have 10,000+ data items, which would cause graph bloat and push the V8 relationship Map past its 16.7M entry limit across thousands of files.
|
||||
|
||||
The cap applies after extraction: the first 500 items in source order are kept. Since 01-level records appear first, critical top-level structure is preserved.
|
||||
|
||||
## EXEC SQL
|
||||
|
||||
EXEC SQL blocks are accumulated across lines between `EXEC SQL` and `END-EXEC`, then parsed as a unit.
|
||||
|
||||
### Operation Classification
|
||||
|
||||
The first SQL keyword determines the operation:
|
||||
|
||||
| First Keyword | Operation |
|
||||
|---------------|-----------|
|
||||
| `SELECT` | SELECT |
|
||||
| `INSERT` | INSERT |
|
||||
| `UPDATE` | UPDATE |
|
||||
| `DELETE` | DELETE |
|
||||
| `DECLARE` | DECLARE |
|
||||
| `OPEN` | OPEN |
|
||||
| `CLOSE` | CLOSE |
|
||||
| `FETCH` | FETCH |
|
||||
| *(anything else)* | OTHER |
|
||||
|
||||
### Table Extraction
|
||||
|
||||
Tables are extracted from SQL clauses:
|
||||
|
||||
| Clause Pattern | Example |
|
||||
|----------------|---------|
|
||||
| `FROM <table>` | `SELECT * FROM EMPLOYEES` |
|
||||
| `INSERT INTO <table>` | `INSERT INTO EMPLOYEES` |
|
||||
| `UPDATE <table>` | `UPDATE EMPLOYEES SET ...` |
|
||||
| `JOIN <table>` | `LEFT JOIN DEPARTMENTS ON ...` |
|
||||
|
||||
Note: The `INTO` pattern is restricted to `INSERT INTO` to avoid false positives from `FETCH ... INTO :host-var` and `SELECT ... INTO :host-var` statements, where `INTO` introduces host variables rather than table names.
|
||||
|
||||
### Cursor Detection
|
||||
|
||||
```cobol
|
||||
EXEC SQL
|
||||
DECLARE C-EMPLOYEES CURSOR FOR
|
||||
SELECT EMP-ID, EMP-NAME FROM EMPLOYEES
|
||||
WHERE DEPT = :WK-DEPT
|
||||
END-EXEC
|
||||
```
|
||||
|
||||
Extracts: cursor `C-EMPLOYEES`, table `EMPLOYEES`, host variable `WK-DEPT`.
|
||||
|
||||
### Host Variables
|
||||
|
||||
Host variables are COBOL variables referenced in SQL with a `:` prefix. The colon is stripped:
|
||||
|
||||
```sql
|
||||
WHERE EMP-ID = :WK-EMP-ID AND DEPT = :WK-DEPT
|
||||
```
|
||||
|
||||
Extracts: `WK-EMP-ID`, `WK-DEPT`.
|
||||
|
||||
### Graph Output
|
||||
|
||||
- `CodeElement` node per table, with description `sql-table op:{OP}`
|
||||
- `CodeElement` node per cursor, with description `sql-cursor`
|
||||
- `ACCESSES` edge from Module to each CodeElement
|
||||
- Deduplication: if the same table appears in multiple SQL blocks, only one node is created
|
||||
|
||||
## EXEC CICS
|
||||
|
||||
EXEC CICS blocks are accumulated and parsed similarly to SQL blocks.
|
||||
|
||||
### Command Detection
|
||||
|
||||
Two-word commands are detected first (matched against the block start):
|
||||
|
||||
```
|
||||
SEND MAP, RECEIVE MAP, SEND TEXT, SEND CONTROL, READ NEXT, READ PREV
|
||||
```
|
||||
|
||||
If no two-word command matches, the first word is used (e.g., `LINK`, `XCTL`, `RETURN`, `READ`, `WRITE`).
|
||||
|
||||
### Extraction
|
||||
|
||||
| Element | Pattern | Example |
|
||||
|---------|---------|---------|
|
||||
| MAP name | `MAP('name')` or `MAP("name")` | `EXEC CICS SEND MAP('EMPMENU')` |
|
||||
| PROGRAM name | `PROGRAM('name')` or `PROGRAM("name")` | `EXEC CICS LINK PROGRAM('BGTABUP')` |
|
||||
| TRANSID | `TRANSID('name')` or `TRANSID("name")` | `EXEC CICS START TRANSID('EMP1')` |
|
||||
|
||||
### Graph Output
|
||||
|
||||
- MAP: `CodeElement` node with description `cics-map cmd:{CMD}` + `ACCESSES` edge from Module
|
||||
- PROGRAM: `CALLS` edge (cross-program call via CICS LINK/XCTL)
|
||||
- TRANSID: `CodeElement` node with description `cics-transid cmd:{CMD}` + `ACCESSES` edge from Module
|
||||
|
||||
### Annotated Example
|
||||
|
||||
```cobol
|
||||
EXEC CICS
|
||||
SEND MAP('EMPMENU')
|
||||
MAPSET('EMPSET')
|
||||
FROM(WK-MAP-DATA)
|
||||
ERASE
|
||||
END-EXEC
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `CodeElement` node: `EMPMENU` (description: `cics-map cmd:SEND MAP`)
|
||||
- `ACCESSES` edge: Module -> `EMPMENU`
|
||||
|
||||
## File Declarations
|
||||
|
||||
SELECT statements in the INPUT-OUTPUT SECTION are accumulated across multiple lines (until a period terminator) and parsed for:
|
||||
|
||||
| Clause | Pattern | Example |
|
||||
|--------|---------|---------|
|
||||
| SELECT | `SELECT <name>` | `SELECT MASTER-FILE` |
|
||||
| ASSIGN | `ASSIGN TO <file>` | `ASSIGN TO "MASTER.DAT"` |
|
||||
| ORGANIZATION | `ORGANIZATION IS <type>` | `ORGANIZATION IS INDEXED` |
|
||||
| ACCESS | `ACCESS MODE IS <mode>` | `ACCESS MODE IS DYNAMIC` |
|
||||
| RECORD KEY | `RECORD KEY IS <field>` | `RECORD KEY IS WK-EMP-ID` |
|
||||
| FILE STATUS | `FILE STATUS IS <field>` | `FILE STATUS IS WK-FILE-STATUS` |
|
||||
|
||||
### Graph Output
|
||||
|
||||
- `CodeElement` node with description containing all parsed clauses (e.g., `select org:INDEXED access:DYNAMIC key:WK-EMP-ID status:WK-FILE-STATUS assign:MASTER.DAT`)
|
||||
- `RECORD_KEY_OF` edge: from Property node to CodeElement (confidence 0.8)
|
||||
- `FILE_STATUS_OF` edge: from Property node to CodeElement (confidence 0.8)
|
||||
|
||||
## FD Entries
|
||||
|
||||
FD (File Description) entries associate a file name with its record layout:
|
||||
|
||||
```cobol
|
||||
FD MASTER-FILE.
|
||||
01 MASTER-RECORD.
|
||||
05 MR-EMP-ID PIC 9(6).
|
||||
05 MR-EMP-NAME PIC X(30).
|
||||
```
|
||||
|
||||
The extractor tracks `pendingFdName` state: when an `FD` line is seen, the next 01-level data item becomes its record.
|
||||
|
||||
### Graph Output
|
||||
|
||||
- `CodeElement` node with description `fd record:{recordName}`
|
||||
- `CONTAINS` edge: FD CodeElement -> Record node
|
||||
- `CONTAINS` edge: SELECT CodeElement -> FD CodeElement (linking file declaration to file description)
|
||||
|
||||
## ENTRY Points
|
||||
|
||||
The `ENTRY` statement defines additional entry points into a COBOL program (in addition to the main program entry):
|
||||
|
||||
```cobol
|
||||
ENTRY "SUBPROG" USING WK-PARAM-1 WK-PARAM-2.
|
||||
```
|
||||
|
||||
### Graph Output
|
||||
|
||||
- `Constructor` node with description `entry params:{param1},{param2}` (or just `entry` if no parameters)
|
||||
- `CONTAINS` edge: Module -> Constructor
|
||||
- Symbol table entry (so the entry point is discoverable by name)
|
||||
|
||||
## PROCEDURE DIVISION USING
|
||||
|
||||
```cobol
|
||||
PROCEDURE DIVISION USING WK-INPUT-REC WK-OUTPUT-REC.
|
||||
```
|
||||
|
||||
The USING clause identifies parameters received by the program from its caller.
|
||||
|
||||
### Graph Output
|
||||
|
||||
- `RECEIVES` edge: Module -> Property (for each parameter name, confidence 0.8)
|
||||
|
||||
## MOVE Statements
|
||||
|
||||
MOVE statements produce `ACCESSES` edges in the graph:
|
||||
|
||||
```cobol
|
||||
MOVE WK-NAME TO OUT-NAME.
|
||||
MOVE CORRESPONDING WK-INPUT TO WK-OUTPUT.
|
||||
MOVE CORR WK-IN TO WK-OUT.
|
||||
```
|
||||
|
||||
### Extraction Details
|
||||
|
||||
- Source and target identifiers are captured
|
||||
- `CORRESPONDING` and its abbreviation `CORR` are both recognized (bulk field-by-field move)
|
||||
- Figurative constants (SPACES, ZEROS, LOW-VALUES, HIGH-VALUES, QUOTES, ALL) are skipped
|
||||
- The enclosing paragraph (`caller`) is tracked for context
|
||||
|
||||
### MOVE CORRESPONDING / CORR Edge Reasons
|
||||
|
||||
MOVE CORRESPONDING (and CORR) produces distinct edge reasons to differentiate from simple MOVE:
|
||||
|
||||
| Edge | Reason (simple MOVE) | Reason (CORRESPONDING/CORR) |
|
||||
|------|---------------------|-----------------------------|
|
||||
| Read (source) | `cobol-move-read` | `cobol-move-corresponding-read` |
|
||||
| Write (target) | `cobol-move-write` | `cobol-move-corresponding-write` |
|
||||
|
||||
This distinction allows queries to find bulk field-by-field moves separately from simple variable assignments.
|
||||
|
||||
## GO TO DEPENDING ON
|
||||
|
||||
The `GO TO` statement with multiple targets and a `DEPENDING ON` clause is a computed branch:
|
||||
|
||||
```cobol
|
||||
GO TO PARA-1 PARA-2 PARA-3
|
||||
DEPENDING ON WK-SELECTOR.
|
||||
```
|
||||
|
||||
All target paragraph names are extracted and emitted as separate `gotos` entries. Each target produces a `CALLS` edge in the graph (same semantics as PERFORM). The `DEPENDING ON` variable is not currently tracked as a data-flow dependency.
|
||||
|
||||
## SORT INPUT/OUTPUT PROCEDURE
|
||||
|
||||
SORT and MERGE statements can specify procedural entry points instead of file-based I/O:
|
||||
|
||||
```cobol
|
||||
SORT SORT-FILE ON ASCENDING KEY SORT-KEY
|
||||
INPUT PROCEDURE IS PREPARE-INPUT
|
||||
OUTPUT PROCEDURE IS FORMAT-OUTPUT.
|
||||
```
|
||||
|
||||
`INPUT PROCEDURE IS` and `OUTPUT PROCEDURE IS` targets are extracted as control-flow targets (same as PERFORM). They produce `performs` entries and corresponding `CALLS` edges in the graph.
|
||||
|
||||
## Fixed-Format Literal Continuation
|
||||
|
||||
In fixed-format COBOL, string literals can span multiple lines using the continuation indicator (`-` in column 7). When a continuation line starts with a quote character, the extractor joins it with the predecessor by removing the trailing quote from the previous line and the opening quote from the continuation:
|
||||
|
||||
```
|
||||
Line N: MOVE "THIS IS A LONG STRI
|
||||
Line N+1 (cont): - "NG VALUE" TO WK-FIELD.
|
||||
Merged: MOVE "THIS IS A LONG STRING VALUE" TO WK-FIELD.
|
||||
```
|
||||
|
||||
The trailing `"` on line N and the opening `"` on line N+1 are both removed, producing a seamless literal. If no matching quote is found on the predecessor line, the continuation is appended as-is.
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/cobol-preprocessor.ts` -- All extraction logic, clause parsers, EXEC block parsers
|
||||
- `gitnexus/src/core/ingestion/workers/parse-worker.ts` -- `processCobolRegexOnly()`, graph node/edge emission
|
||||
- `gitnexus/src/core/ingestion/parsing-processor.ts` -- Sequential fallback with same `MAX_DATA_ITEMS_PER_FILE` cap
|
||||
@@ -1,126 +0,0 @@
|
||||
# COBOL File Detection
|
||||
|
||||
GitNexus detects COBOL files through two mechanisms: extension-based mapping and directory-based override for extensionless files. This document covers both, plus the copybook/program classification logic.
|
||||
|
||||
## Extension Mapping
|
||||
|
||||
### Program Extensions
|
||||
|
||||
| Extension | Type |
|
||||
|-----------|------|
|
||||
| `.cbl` | COBOL program |
|
||||
| `.cob` | COBOL program |
|
||||
| `.cobol` | COBOL program |
|
||||
|
||||
### Copybook Extensions
|
||||
|
||||
| Extension | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `.cpy` | Copybook | Standard |
|
||||
| `.copy` | Copybook | Standard |
|
||||
| `.gnm` / `.GNM` | Copybook | Enterprise (GnuCOBOL naming) |
|
||||
| `.fd` / `.FD` | Copybook | File Description fragment |
|
||||
| `.wrk` / `.WRK` | Copybook | Working-Storage fragment |
|
||||
| `.sel` / `.SEL` | Copybook | SELECT clause fragment |
|
||||
| `.open` / `.OPEN` | Copybook | File OPEN fragment |
|
||||
| `.close` / `.CLOSE` | Copybook | File CLOSE fragment |
|
||||
| `.ini` / `.INI` | Copybook | Initialization fragment |
|
||||
| `.def` / `.DEF` | Copybook | Definition fragment |
|
||||
|
||||
All extension matching is case-sensitive in `getLanguageFromFilename` (the extensions above are matched as written, including uppercase variants like `.GNM`).
|
||||
|
||||
## Extensionless File Detection: `GITNEXUS_COBOL_DIRS`
|
||||
|
||||
Many enterprise COBOL repositories use extensionless files -- the filename alone identifies the program (e.g., `s/BGTABFL` is the source for program `BGTABFL`). GitNexus handles this via the `GITNEXUS_COBOL_DIRS` environment variable.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `GITNEXUS_COBOL_DIRS` to a comma-separated list of directory names:
|
||||
|
||||
```bash
|
||||
# Files in s/, c/, and wfproc/ directories (at any depth) are treated as COBOL
|
||||
export GITNEXUS_COBOL_DIRS=s,c,wfproc
|
||||
```
|
||||
|
||||
The matching is **case-insensitive** and checks all path segments:
|
||||
|
||||
- `/repo/s/BGTABFL` -- matches segment `s` -- COBOL
|
||||
- `/repo/src/c/CPSESP` -- matches segment `c` -- COBOL
|
||||
- `/repo/wfproc/WF001` -- matches segment `wfproc` -- COBOL
|
||||
- `/repo/docs/README` -- no matching segment -- skipped
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[getLanguageFromPath] --> B[getLanguageFromFilename]
|
||||
B --> C{Known extension?}
|
||||
C -->|Yes .cbl/.cob/.cobol/.cpy/...| D[Return COBOL]
|
||||
C -->|Yes .ts/.py/.java/...| E[Return other language]
|
||||
C -->|No match| F{Has extension?}
|
||||
|
||||
F -->|"Has dot in basename"| G[Return null]
|
||||
F -->|"No dot = extensionless"| H{GITNEXUS_COBOL_DIRS set?}
|
||||
|
||||
H -->|No| G
|
||||
H -->|Yes| I{Any path segment<br/>matches a configured dir?}
|
||||
|
||||
I -->|Yes| D
|
||||
I -->|No| G
|
||||
|
||||
style D fill:#e8f5e9,stroke:#2e7d32
|
||||
style G fill:#ffebee,stroke:#c62828
|
||||
```
|
||||
|
||||
### Implementation Detail
|
||||
|
||||
The `GITNEXUS_COBOL_DIRS` value is parsed once (on first call) and cached in a `Set<string>`:
|
||||
|
||||
```typescript
|
||||
// From gitnexus/src/core/ingestion/utils.ts
|
||||
const getCobolDirs = (): Set<string> => {
|
||||
if (_cobolDirs) return _cobolDirs;
|
||||
const raw = process.env.GITNEXUS_COBOL_DIRS;
|
||||
_cobolDirs = raw
|
||||
? new Set(raw.split(',').map(d => d.trim().toLowerCase()))
|
||||
: new Set();
|
||||
return _cobolDirs;
|
||||
};
|
||||
```
|
||||
|
||||
The path segment check splits the full path on `/` and tests each segment against the cached set.
|
||||
|
||||
## Copybook vs Program Classification
|
||||
|
||||
After a file is identified as COBOL, it must be classified as either a **program** (to be parsed for symbols) or a **copybook** (to be loaded into the copybook map for COPY expansion).
|
||||
|
||||
### Classification Rules
|
||||
|
||||
A COBOL file is classified as a **copybook** if ANY of these conditions is true:
|
||||
|
||||
1. It has a recognized copybook extension (`.cpy`, `.copy`, `.gnm`, `.fd`, `.wrk`, `.sel`, `.open`, `.close`, `.ini`, `.def`)
|
||||
2. It is an extensionless file whose path contains a directory segment matching one of: `c`, `copy`, `copybooks`, `copylib`, `cpy`
|
||||
|
||||
A file is classified as a **program** if:
|
||||
|
||||
1. It has a program extension (`.cbl`, `.cob`, `.cobol`), OR
|
||||
2. It is extensionless and does NOT match any copybook directory pattern
|
||||
|
||||
### Copybook Name Resolution
|
||||
|
||||
Copybook names are derived from the filename:
|
||||
|
||||
- Strip the extension (if any)
|
||||
- Convert to uppercase
|
||||
|
||||
Examples:
|
||||
- `c/CPSESP` -- name: `CPSESP`
|
||||
- `copy/workgrid.cpy` -- name: `WORKGRID`
|
||||
- `c/ANAZI.GNM` -- name: `ANAZI`
|
||||
|
||||
This name is used to resolve `COPY CPSESP.` statements during expansion.
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/utils.ts` -- `getLanguageFromPath()`, `getLanguageFromFilename()`, `getCobolDirs()`
|
||||
- `gitnexus/src/core/ingestion/pipeline.ts` -- `isCobolCopybook()`, `getCopybookName()`, `COPYBOOK_EXTENSIONS`, `COBOL_PROGRAM_EXTENSIONS`
|
||||
@@ -1,193 +0,0 @@
|
||||
# COBOL Graph Model
|
||||
|
||||
This document describes the graph nodes and edges that GitNexus creates for COBOL codebases. The COBOL graph model is richer than most tree-sitter languages because it captures domain-specific constructs: file declarations, FD entries, data hierarchies, SQL tables, CICS maps, and cross-program contracts.
|
||||
|
||||
## Entity-Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
File ||--o{ Module : DEFINES
|
||||
File ||--o{ Function : DEFINES
|
||||
File ||--o{ Namespace : DEFINES
|
||||
File ||--o{ Record : DEFINES
|
||||
File ||--o{ Property : DEFINES
|
||||
File ||--o{ Const : DEFINES
|
||||
File ||--o{ CodeElement : DEFINES
|
||||
File ||--o{ Constructor : DEFINES
|
||||
File }o--o{ File : IMPORTS
|
||||
|
||||
Module ||--o{ Record : CONTAINS
|
||||
Module ||--o{ Constructor : CONTAINS
|
||||
Module }o--o{ CodeElement : ACCESSES
|
||||
Module }o--o{ Module : CALLS
|
||||
Module }o--o{ Module : CONTRACTS
|
||||
Module }o--o{ Property : RECEIVES
|
||||
|
||||
Record ||--o{ Property : CONTAINS
|
||||
Record ||--o{ Const : CONTAINS
|
||||
Record }o--o{ Record : REDEFINES
|
||||
|
||||
Property ||--o{ Property : CONTAINS
|
||||
Property ||--o{ Const : CONTAINS
|
||||
Property }o--o{ Property : REDEFINES
|
||||
Property }o--o{ CodeElement : RECORD_KEY_OF
|
||||
Property }o--o{ CodeElement : FILE_STATUS_OF
|
||||
|
||||
CodeElement ||--o{ CodeElement : CONTAINS
|
||||
CodeElement ||--o{ Record : CONTAINS
|
||||
|
||||
Function }o--o{ Function : CALLS
|
||||
```
|
||||
|
||||
## Node Types
|
||||
|
||||
| Node Type | COBOL Concept | Created From | Example |
|
||||
|-----------|--------------|--------------|---------|
|
||||
| `Module` | PROGRAM-ID | `PROGRAM-ID. BGTABFL` | Name: `BGTABFL`, description may include author and date |
|
||||
| `Function` | Paragraph | `PROCESS-RECORD.` at column 8 | Name: `PROCESS-RECORD` |
|
||||
| `Namespace` | Procedure section | `MAIN-LOGIC SECTION.` at column 8 | Name: `MAIN-LOGIC` |
|
||||
| `Record` | 01-level data item | `01 WK-EMPLOYEE.` | Description: `level:01 section:working-storage` |
|
||||
| `Property` | 02-49/66/77 data item | `05 WK-NAME PIC X(30).` | Description: `level:05 pic:X(30) section:working-storage` |
|
||||
| `Const` | 88-level condition | `88 WK-ACTIVE VALUE "A".` | Description: `level:88 values:A` |
|
||||
| `CodeElement` | SELECT, FD, SQL table, CICS map, cursor, transid | Various | Description varies by subtype |
|
||||
| `Constructor` | ENTRY point | `ENTRY "SUBPROG" USING WK-DATA` | Description: `entry params:WK-DATA` |
|
||||
|
||||
### CodeElement Subtypes
|
||||
|
||||
CodeElement is used for multiple COBOL constructs, distinguished by their description prefix:
|
||||
|
||||
| Subtype | ID Pattern | Description Format | Example |
|
||||
|---------|-----------|-------------------|---------|
|
||||
| File SELECT | `CodeElement:{path}:SELECT:{name}` | `select org:INDEXED access:DYNAMIC ...` | `SELECT MASTER-FILE` |
|
||||
| FD entry | `CodeElement:{path}:FD:{name}` | `fd record:{recordName}` | `FD MASTER-FILE` |
|
||||
| SQL table | `CodeElement:{path}:sql-table:{name}` | `sql-table op:SELECT` | Table `EMPLOYEES` |
|
||||
| SQL cursor | `CodeElement:{path}:sql-cursor:{name}` | `sql-cursor` | Cursor `C-EMPLOYEES` |
|
||||
| CICS map | `CodeElement:{path}:cics-map:{name}` | `cics-map cmd:SEND MAP` | Map `EMPMENU` |
|
||||
| CICS transid | `CodeElement:{path}:cics-transid:{name}` | `cics-transid cmd:START` | Transid `EMP1` |
|
||||
|
||||
## Edge Types
|
||||
|
||||
| Edge Type | Source | Target | Created By | Confidence | Example |
|
||||
|-----------|--------|--------|-----------|------------|---------|
|
||||
| `DEFINES` | File | any node | File defines its symbols | 1.0 | File -> Module `BGTABFL` |
|
||||
| `CALLS` | Function | Function | `PERFORM X [THRU Y]` | (via call-processor) | `PROCESS-RECORD` -> `CALC-TAX` |
|
||||
| `CALLS` | Module | Module | `CALL "BGTABUP"` | (via call-processor) | `BGTABFL` -> `BGTABUP` |
|
||||
| `CALLS` | Module | Module | `EXEC CICS LINK PROGRAM('X')` | (via call-processor) | `BGTABFL` -> `BGTABUP` |
|
||||
| `IMPORTS` | File | File | `COPY copybook` | (via import-processor) | Source file -> Copybook file |
|
||||
| `CONTAINS` | Module | Record | Data hierarchy root | 1.0 | `BGTABFL` -> `WK-EMPLOYEE` |
|
||||
| `CONTAINS` | Record | Property | Data hierarchy | 1.0 | `WK-EMPLOYEE` -> `WK-NAME` |
|
||||
| `CONTAINS` | Property | Property | Nested data items | 1.0 | `WK-ADDRESS` -> `WK-CITY` |
|
||||
| `CONTAINS` | Record/Property | Const | 88-level parent | 1.0 | `WK-STATUS` -> `WK-ACTIVE` |
|
||||
| `CONTAINS` | CodeElement (FD) | Record | FD record link | 1.0 | `FD:MASTER-FILE` -> `MASTER-RECORD` |
|
||||
| `CONTAINS` | CodeElement (SELECT) | CodeElement (FD) | SELECT-FD link | 0.9 | `SELECT:MASTER-FILE` -> `FD:MASTER-FILE` |
|
||||
| `CONTAINS` | Module | Constructor | ENTRY in module | 1.0 | `BGTABFL` -> `SUBPROG` |
|
||||
| `REDEFINES` | Record | Record | `01 X REDEFINES Y` | 1.0 | `WK-DATE-NUM` -> `WK-DATE-ALPHA` |
|
||||
| `REDEFINES` | Property | Property | `05 X REDEFINES Y` | 1.0 | `WK-CODE-NUM` -> `WK-CODE-ALPHA` |
|
||||
| `RECORD_KEY_OF` | Property | CodeElement (SELECT) | `RECORD KEY IS field` | 0.8 | `WK-EMP-ID` -> `SELECT:MASTER-FILE` |
|
||||
| `FILE_STATUS_OF` | Property | CodeElement (SELECT) | `FILE STATUS IS field` | 0.8 | `WK-FS` -> `SELECT:MASTER-FILE` |
|
||||
| `ACCESSES` | Module | CodeElement | EXEC SQL/CICS | 0.9 | `BGTABFL` -> `sql-table:EMPLOYEES` |
|
||||
| `RECEIVES` | Module | Property | `PROCEDURE USING` | 0.8 | `BGTABFL` -> `WK-INPUT-REC` |
|
||||
| `CONTRACTS` | Module | Module | Shared copybook detection | 0.9 | `BGTABFL` -> `BGTABUP` (via `CPSESP`) |
|
||||
|
||||
## Full Annotated Example
|
||||
|
||||
Given this COBOL program:
|
||||
|
||||
```cobol
|
||||
IDENTIFICATION DIVISION.
|
||||
PROGRAM-ID. EMPMAINT.
|
||||
AUTHOR. Development Team.
|
||||
|
||||
ENVIRONMENT DIVISION.
|
||||
INPUT-OUTPUT SECTION.
|
||||
FILE-CONTROL.
|
||||
SELECT EMP-FILE
|
||||
ASSIGN TO "EMPLOYEE.DAT"
|
||||
ORGANIZATION IS INDEXED
|
||||
ACCESS MODE IS DYNAMIC
|
||||
RECORD KEY IS EMP-ID
|
||||
FILE STATUS IS WS-FILE-STATUS.
|
||||
|
||||
DATA DIVISION.
|
||||
FILE SECTION.
|
||||
FD EMP-FILE.
|
||||
01 EMP-RECORD.
|
||||
05 EMP-ID PIC 9(6).
|
||||
05 EMP-NAME PIC X(30).
|
||||
|
||||
WORKING-STORAGE SECTION.
|
||||
01 WS-FLAGS.
|
||||
05 WS-FILE-STATUS PIC X(02).
|
||||
05 WS-EOF-FLAG PIC X(01).
|
||||
88 WS-EOF VALUE "Y".
|
||||
|
||||
LINKAGE SECTION.
|
||||
01 LK-SEARCH-KEY PIC 9(6).
|
||||
|
||||
PROCEDURE DIVISION USING LK-SEARCH-KEY.
|
||||
MAIN-LOGIC SECTION.
|
||||
MAIN-START.
|
||||
PERFORM OPEN-FILE
|
||||
PERFORM PROCESS-RECORDS
|
||||
PERFORM CLOSE-FILE
|
||||
STOP RUN.
|
||||
|
||||
OPEN-FILE.
|
||||
OPEN I-O EMP-FILE.
|
||||
|
||||
PROCESS-RECORDS.
|
||||
MOVE LK-SEARCH-KEY TO EMP-ID
|
||||
EXEC SQL
|
||||
SELECT EMP_SALARY INTO :WS-SALARY
|
||||
FROM EMPLOYEES
|
||||
WHERE EMP_ID = :EMP-ID
|
||||
END-EXEC
|
||||
CALL "EMPREPORT".
|
||||
|
||||
CLOSE-FILE.
|
||||
CLOSE EMP-FILE.
|
||||
```
|
||||
|
||||
The graph produced contains:
|
||||
|
||||
**Nodes:**
|
||||
- `Module`: EMPMAINT (description: `author:Development Team`)
|
||||
- `Namespace`: MAIN-LOGIC
|
||||
- `Function`: MAIN-START, OPEN-FILE, PROCESS-RECORDS, CLOSE-FILE
|
||||
- `Record`: EMP-RECORD, WS-FLAGS, LK-SEARCH-KEY
|
||||
- `Property`: EMP-ID, EMP-NAME, WS-FILE-STATUS, WS-EOF-FLAG
|
||||
- `Const`: WS-EOF (values: Y)
|
||||
- `CodeElement`: SELECT:EMP-FILE, FD:EMP-FILE, sql-table:EMPLOYEES
|
||||
- (COPY imports, if any, would produce File IMPORTS edges)
|
||||
|
||||
**Edges:**
|
||||
- `DEFINES`: File -> all nodes
|
||||
- `CONTAINS`: EMPMAINT -> EMP-RECORD, EMPMAINT -> WS-FLAGS, EMPMAINT -> LK-SEARCH-KEY
|
||||
- `CONTAINS`: EMP-RECORD -> EMP-ID, EMP-RECORD -> EMP-NAME
|
||||
- `CONTAINS`: WS-FLAGS -> WS-FILE-STATUS, WS-FLAGS -> WS-EOF-FLAG
|
||||
- `CONTAINS`: WS-EOF-FLAG -> WS-EOF
|
||||
- `CONTAINS`: FD:EMP-FILE -> EMP-RECORD
|
||||
- `CONTAINS`: SELECT:EMP-FILE -> FD:EMP-FILE
|
||||
- `CALLS`: MAIN-START -> OPEN-FILE, MAIN-START -> PROCESS-RECORDS, MAIN-START -> CLOSE-FILE
|
||||
- `CALLS`: EMPMAINT -> EMPREPORT (external CALL)
|
||||
- `ACCESSES`: EMPMAINT -> sql-table:EMPLOYEES
|
||||
- `RECEIVES`: EMPMAINT -> LK-SEARCH-KEY (PROCEDURE USING)
|
||||
- `RECORD_KEY_OF`: EMP-ID -> SELECT:EMP-FILE
|
||||
- `FILE_STATUS_OF`: WS-FILE-STATUS -> SELECT:EMP-FILE
|
||||
|
||||
## How COBOL Differs from Tree-Sitter Languages
|
||||
|
||||
| Aspect | COBOL | Tree-Sitter Languages |
|
||||
|--------|-------|----------------------|
|
||||
| Node variety | 8 types (Module, Function, Namespace, Record, Property, Const, CodeElement, Constructor) | Typically 4-6 (Function, Class, Method, Interface, Module, Const) |
|
||||
| Domain edges | RECORD_KEY_OF, FILE_STATUS_OF, ACCESSES, RECEIVES, CONTRACTS, REDEFINES | Primarily CALLS, IMPORTS, EXTENDS, IMPLEMENTS |
|
||||
| Data hierarchy | Deep CONTAINS chains (01 -> 05 -> 10 -> 88) | Flat class members |
|
||||
| Cross-program calls | CALL "name" + CICS LINK PROGRAM | Import-based resolution |
|
||||
| Contract detection | Shared COPY copybook between caller/callee | Not applicable |
|
||||
| Metadata | AUTHOR, DATE-WRITTEN on Module | JSDoc/docstring (not indexed) |
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/workers/parse-worker.ts` -- `processCobolRegexOnly()`, node/edge emission logic
|
||||
- `gitnexus/src/core/ingestion/pipeline.ts` -- `detectCrossProgamContracts()` for CONTRACTS edges
|
||||
- `gitnexus/src/core/ingestion/cobol-preprocessor.ts` -- `CobolRegexResults` interface (all extracted data)
|
||||
@@ -1,261 +0,0 @@
|
||||
# COBOL Performance and Tuning
|
||||
|
||||
This document covers real-world benchmarks, worker pool configuration, memory management, known limitations, and troubleshooting for COBOL indexing.
|
||||
|
||||
## PROJECT-NAME Benchmark
|
||||
|
||||
The PROJECT-NAME project is a large Italian payroll system written in COBOL. It serves as the primary benchmark for COBOL indexing performance.
|
||||
|
||||
### Input
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ---------------------------------------------------------------------------- |
|
||||
| Paths scanned | 14,217 |
|
||||
| Parseable files | 13,129 |
|
||||
| Total source size | 224 MB |
|
||||
| Chunks | 12 (at 20 MB budget) |
|
||||
| Copybooks loaded | 2,976 |
|
||||
| Copybooks used in expansion | 2,955 |
|
||||
| Key directories | `s/` (7773 programs), `c/` (3036 copybooks), `wfproc/` (1973 workflow files) |
|
||||
|
||||
### Output
|
||||
|
||||
| Metric | Value |
|
||||
| ---------------------- | ------ |
|
||||
| Graph nodes | 2.79M |
|
||||
| Graph edges | 5.67M |
|
||||
| Clusters (communities) | 16,679 |
|
||||
| Execution flows | 300 |
|
||||
|
||||
### Timing
|
||||
|
||||
| Phase | Duration |
|
||||
| ------------------------------- | ----------------- |
|
||||
| Total | ~251s |
|
||||
| KuzuDB write | 132s |
|
||||
| Full-text search indexing | 6.7s |
|
||||
| Regex extraction (avg per file) | ~1ms |
|
||||
| COPY expansion + deep indexing | Remainder (~112s) |
|
||||
|
||||
### Indexing Command
|
||||
|
||||
```bash
|
||||
cd /path/to/PROJECT-NAME
|
||||
GITNEXUS_COBOL_DIRS=s,c,wfproc GITNEXUS_VERBOSE=1 node --max-old-space-size=8192 \
|
||||
/path/to/gitnexus/dist/cli/index.js analyze --force
|
||||
```
|
||||
|
||||
## Open-Source Benchmarks
|
||||
|
||||
### CardDemo (AWS)
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ----- |
|
||||
| Graph nodes | 12,323 |
|
||||
| Graph edges | 8,893 |
|
||||
| Total time | 7.4s |
|
||||
|
||||
### ACAS
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ----- |
|
||||
| Graph nodes | 14,016 |
|
||||
| Graph edges | 15,452 |
|
||||
| Total time | 9.3s |
|
||||
|
||||
### Micro-Benchmark (Single-File Extraction)
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ----- |
|
||||
| Per-iteration | 0.65ms |
|
||||
| Throughput | ~382K lines/sec |
|
||||
|
||||
## Worker Pool Tuning
|
||||
|
||||
### Sub-Batch Size
|
||||
|
||||
The worker pool splits each worker's chunk into sub-batches to bound peak memory per `postMessage` serialization. COBOL repos use a smaller sub-batch size than the default:
|
||||
|
||||
| Parameter | Default | COBOL Mode |
|
||||
| --------------------- | ----------- | ------------------- |
|
||||
| Sub-batch size | 1,500 files | 200 files |
|
||||
| Per sub-batch timeout | 120s | 120s (configurable) |
|
||||
|
||||
**Why 200?** COBOL regex extraction + preprocessing takes ~1ms per file on average, but with COPY expansion and deep indexing the effective time is ~150ms per file. At sub-batch size 1500, that would be ~225s per sub-batch, exceeding the 120s timeout.
|
||||
|
||||
COBOL mode is activated automatically when `GITNEXUS_COBOL_DIRS` is set:
|
||||
|
||||
```typescript
|
||||
// From pipeline.ts
|
||||
const cobolSubBatch = process.env.GITNEXUS_COBOL_DIRS ? 200 : undefined;
|
||||
workerPool = createWorkerPool(workerUrl, undefined, cobolSubBatch);
|
||||
```
|
||||
|
||||
### Worker Count
|
||||
|
||||
Workers default to `min(8, cpus - 1)`. For COBOL repos, this is usually sufficient since regex extraction is CPU-bound but fast. The bottleneck is typically KuzuDB write, not extraction.
|
||||
|
||||
### Timeout Configuration
|
||||
|
||||
| Environment Variable | Default | Purpose |
|
||||
| ------------------------------------ | --------------- | --------------------------------------------------- |
|
||||
| `GITNEXUS_WORKER_TIMEOUT_MS` | 120,000 (2 min) | Per sub-batch processing timeout |
|
||||
| `GITNEXUS_WORKER_STARTUP_TIMEOUT_MS` | 60,000 (1 min) | Worker initialization timeout (tree-sitter loading) |
|
||||
|
||||
For COBOL-only repos, worker startup is faster because tree-sitter native modules are loaded lazily (skipped entirely if only COBOL files are present).
|
||||
|
||||
## Data Item Cap
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
const MAX_DATA_ITEMS_PER_FILE = 500;
|
||||
```
|
||||
|
||||
This constant appears in both `parse-worker.ts` (worker path) and `parsing-processor.ts` (sequential fallback).
|
||||
|
||||
### Rationale
|
||||
|
||||
Some COBOL programs, especially after COPY expansion, can have 10,000+ data items. At that scale:
|
||||
|
||||
- The in-memory relationship Map (for CONTAINS, REDEFINES, etc.) approaches the V8 16.7M entry limit across thousands of files
|
||||
- KuzuDB write time increases linearly with edge count
|
||||
- Most deep-nested items (level 20+) are rarely queried individually
|
||||
|
||||
### Impact
|
||||
|
||||
The cap truncates data items beyond the 500th in source order. Since 01-level Records appear first in COBOL source, the cap preserves:
|
||||
|
||||
- All 01-level record definitions
|
||||
- The most important 02-49 level items (those closest to the record root)
|
||||
- 88-level conditions associated with early items
|
||||
|
||||
To increase the cap for specific needs, modify the `MAX_DATA_ITEMS_PER_FILE` constant in both files.
|
||||
|
||||
## Memory Management
|
||||
|
||||
### COPY Expansion Breadth Guard
|
||||
|
||||
A per-file `MAX_TOTAL_EXPANSIONS = 500` limit prevents exponential blowup from diamond-shaped COPY graphs (e.g., N copybooks each containing N COPY statements). Once the limit is reached, further COPY statements in that file are left unexpanded. See [copy-expansion.md](copy-expansion.md) for details.
|
||||
|
||||
### COPY Expansion Memory
|
||||
|
||||
All copybook content is loaded upfront into a Map before chunk processing begins. For PROJECT-NAME:
|
||||
|
||||
- 2,976 copybooks, typically under 100MB total
|
||||
- The Map is shared (read-only) across chunk iterations
|
||||
- Per-chunk, the copybook map is merged with chunk file content (in case a chunk contains copybooks not in the pre-loaded set)
|
||||
- After all chunks are processed, the copybook map is freed (`cobolCopybookContents = undefined`)
|
||||
|
||||
### Chunk Budget
|
||||
|
||||
Source files are grouped into chunks of max 20MB (`CHUNK_BYTE_BUDGET`). Each chunk's lifecycle:
|
||||
|
||||
1. Read file content into memory
|
||||
2. Expand COPY statements (mutates content in-place)
|
||||
3. Dispatch to workers for extraction
|
||||
4. Workers return serialized results
|
||||
5. Merge results into graph
|
||||
6. Chunk content goes out of scope (GC reclaims)
|
||||
|
||||
This ensures only ~20MB of source + ~200-400MB of working memory (ASTs, extracted records, serialization) is active at any time.
|
||||
|
||||
### Shared Warning Deduplication
|
||||
|
||||
The `warnedCircular` set (used by the COPY expansion engine) is shared across all files in a chunk. This prevents the same circular copybook warning (e.g., `ANAZI includes itself`) from being logged thousands of times.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
| Limitation | Impact | Workaround |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| tree-sitter-cobol hangs on ~5% of files | Cannot use tree-sitter for COBOL | Regex-only extraction (current approach) |
|
||||
| Data item cap (500/file) | May miss deeply nested items in large programs | Increase `MAX_DATA_ITEMS_PER_FILE` in source |
|
||||
| Circular copybooks (ANAZI, ANDIP, QDIPE) | Self-referential includes cannot be expanded | Detected and skipped with warning |
|
||||
| wfproc/ files may not be pure COBOL | Workflow files may produce extraction noise | Exclude `wfproc` from `GITNEXUS_COBOL_DIRS` if problematic |
|
||||
| No MOVE DATA_FLOW edges yet | Data flow between variables not in graph | Reserved for future release |
|
||||
| Continuation line handling | Some complex multi-line continuations (especially in string literals spanning 3+ lines) may not merge correctly | Known edge case; affects <0.1% of lines |
|
||||
| Single-line EXEC blocks | `EXEC SQL SELECT ... END-EXEC` on one line is handled, but pathological nesting is not | Extremely rare in practice |
|
||||
| Extension case sensitivity | `.GNM` and `.gnm` are matched differently | Use the exact case from the codebase |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "COPY expansion failed"
|
||||
|
||||
```
|
||||
[pipeline] COPY expansion failed for s/BGTABFL: Cannot read properties of null
|
||||
```
|
||||
|
||||
**Cause:** A copybook referenced by a COPY statement cannot be found.
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Verify `GITNEXUS_COBOL_DIRS` includes the directory containing copybooks (typically `c`)
|
||||
2. Check that copybook filenames match the COPY target (case-insensitive, after stripping extensions)
|
||||
3. Ensure copybook files are not in `.gitignore`
|
||||
|
||||
### Worker sub-batch timeout
|
||||
|
||||
```
|
||||
Worker 3 sub-batch timed out after 120s (chunk: 200 items)
|
||||
```
|
||||
|
||||
**Cause:** A sub-batch took longer than the timeout. Typically happens when one file is extremely large (50,000+ lines after COPY expansion).
|
||||
|
||||
**Fix:** Increase the timeout:
|
||||
|
||||
```bash
|
||||
GITNEXUS_WORKER_TIMEOUT_MS=300000 gitnexus analyze
|
||||
```
|
||||
|
||||
### Memory errors (heap out of memory)
|
||||
|
||||
```
|
||||
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
|
||||
```
|
||||
|
||||
**Fix:** Increase Node.js heap size:
|
||||
|
||||
```bash
|
||||
node --max-old-space-size=16384 /path/to/gitnexus/dist/cli/index.js analyze
|
||||
```
|
||||
|
||||
For very large repos (>500MB source), consider `--max-old-space-size=32768`.
|
||||
|
||||
### Concurrent analyze corruption
|
||||
|
||||
**Rule:** Only ONE `gitnexus analyze` process should run at a time per repository. Concurrent writes to KuzuDB corrupt the database.
|
||||
|
||||
If corruption occurs:
|
||||
|
||||
```bash
|
||||
# Remove the KuzuDB directory and re-index
|
||||
rm -rf .gitnexus/kuzu
|
||||
gitnexus analyze --force
|
||||
```
|
||||
|
||||
### Slow KuzuDB write phase
|
||||
|
||||
The KuzuDB write phase (132s for PROJECT-NAME) is the bottleneck for large COBOL repos. This is proportional to the number of nodes and edges being written. Reducing `MAX_DATA_ITEMS_PER_FILE` or excluding non-essential directories from `GITNEXUS_COBOL_DIRS` can help.
|
||||
|
||||
### Verbose output
|
||||
|
||||
Enable verbose logging to see per-phase timing and statistics:
|
||||
|
||||
```bash
|
||||
GITNEXUS_VERBOSE=1 gitnexus analyze
|
||||
```
|
||||
|
||||
This outputs:
|
||||
|
||||
- Scan statistics (paths, parseable files, chunk count)
|
||||
- Worker pool configuration (worker count, sub-batch size)
|
||||
- COPY expansion statistics (copybooks loaded, files expanded)
|
||||
- Community and process detection results
|
||||
- Contract detection results
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/workers/worker-pool.ts` -- `DEFAULT_SUB_BATCH_SIZE`, `SUB_BATCH_TIMEOUT_MS`, `WORKER_STARTUP_TIMEOUT_MS`
|
||||
- `gitnexus/src/core/ingestion/pipeline.ts` -- `CHUNK_BYTE_BUDGET`, COBOL sub-batch configuration, chunk lifecycle
|
||||
- `gitnexus/src/core/ingestion/workers/parse-worker.ts` -- `MAX_DATA_ITEMS_PER_FILE`, `processCobolRegexOnly()`
|
||||
- `gitnexus/src/core/ingestion/parsing-processor.ts` -- Sequential fallback `MAX_DATA_ITEMS_PER_FILE`
|
||||
@@ -1,206 +0,0 @@
|
||||
# COBOL Regex Extraction
|
||||
|
||||
The `extractCobolSymbolsWithRegex()` function in `cobol-preprocessor.ts` performs single-pass, state-machine-driven extraction of all COBOL symbols. This document describes the state machine, line processing flow, and every regex pattern used.
|
||||
|
||||
## State Machine: Division Tracking
|
||||
|
||||
The extractor tracks which COBOL division is currently being processed. Division transitions are detected by the `RE_DIVISION` pattern.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> null : Start of file
|
||||
null --> identification : IDENTIFICATION DIVISION
|
||||
identification --> environment : ENVIRONMENT DIVISION
|
||||
environment --> data : DATA DIVISION
|
||||
data --> procedure : PROCEDURE DIVISION
|
||||
|
||||
note right of identification
|
||||
Extracts: PROGRAM-ID, AUTHOR, DATE-WRITTEN
|
||||
end note
|
||||
note right of environment
|
||||
Extracts: SELECT ... ASSIGN ... (file declarations)
|
||||
end note
|
||||
note right of data
|
||||
Extracts: FD entries, data items (01-77, 88), COPY
|
||||
end note
|
||||
note right of procedure
|
||||
Extracts: paragraphs, sections, PERFORM, CALL,
|
||||
ENTRY, MOVE, EXEC SQL/CICS
|
||||
end note
|
||||
```
|
||||
|
||||
## State Machine: Data Section Tracking
|
||||
|
||||
Within the DATA DIVISION, a secondary state machine tracks the current section to tag data items with their origin.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> unknown : DATA DIVISION entered
|
||||
unknown --> working_storage : WORKING-STORAGE SECTION
|
||||
unknown --> linkage : LINKAGE SECTION
|
||||
unknown --> file : FILE SECTION
|
||||
unknown --> local_storage : LOCAL-STORAGE SECTION
|
||||
working_storage --> linkage : LINKAGE SECTION
|
||||
working_storage --> file : FILE SECTION
|
||||
linkage --> working_storage : WORKING-STORAGE SECTION
|
||||
file --> working_storage : WORKING-STORAGE SECTION
|
||||
file --> linkage : LINKAGE SECTION
|
||||
local_storage --> working_storage : WORKING-STORAGE SECTION
|
||||
```
|
||||
|
||||
Within the ENVIRONMENT DIVISION, the `currentEnvSection` tracks whether we are in `INPUT-OUTPUT` or `CONFIGURATION` section. SELECT statement accumulation only occurs in `INPUT-OUTPUT`.
|
||||
|
||||
## Line Processing Flow
|
||||
|
||||
Each raw source line goes through this pipeline:
|
||||
|
||||
```
|
||||
Raw line
|
||||
|
|
||||
v
|
||||
Length < 7? ---------> Skip (flush pending if any)
|
||||
|
|
||||
v
|
||||
Indicator col 7
|
||||
|
|
||||
+-- '*' or '/' -----> Comment: skip entirely
|
||||
|
|
||||
+-- '-' ------------> Continuation: append to pending line
|
||||
|
|
||||
+-- other ----------> Normal: flush pending, strip inline comments (|),
|
||||
buffer as new pending logical line
|
||||
```
|
||||
|
||||
After all lines are processed, the final pending line is flushed, along with any accumulated SELECT statement, SORT/MERGE accumulator, and any open EXEC block (truncated file without `END-EXEC`).
|
||||
|
||||
### Inline Comment Stripping
|
||||
|
||||
Enterprise COBOL (particularly Italian dialect) uses the pipe character `|` as an inline comment marker. The `stripInlineComment()` helper is **quote-aware**: it tracks whether the scan position is inside a single- or double-quoted string and only treats `|` as a comment marker when outside quotes. Pipe characters inside string literals are preserved.
|
||||
|
||||
Free-format `*>` inline comment stripping uses the same quote-aware approach: the scanner walks character by character, toggling quote state, and only recognizes `*>` as a comment marker when not inside a quoted string.
|
||||
|
||||
### Patch Marker Handling
|
||||
|
||||
The `preprocessCobolSource()` function (run before extraction in the worker) replaces non-standard content in columns 1-6. Standard COBOL expects spaces or digit sequence numbers in this area. If any letter or `#` character is found, the entire sequence area is replaced with 6 spaces:
|
||||
|
||||
```
|
||||
Before: mzADD MOVE WK-AMT TO WK-TOTAL
|
||||
After: MOVE WK-AMT TO WK-TOTAL
|
||||
```
|
||||
|
||||
This preserves exact line count for position mapping.
|
||||
|
||||
## Regex Pattern Reference
|
||||
|
||||
All patterns are compiled once as module-level constants and reused across calls.
|
||||
|
||||
### Division and Section Detection
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_DIVISION` | `\b(IDENTIFICATION\|ENVIRONMENT\|DATA\|PROCEDURE)\s+DIVISION\b` | Division boundary | `PROCEDURE DIVISION` |
|
||||
| `RE_SECTION` | `\b(WORKING-STORAGE\|LINKAGE\|FILE\|LOCAL-STORAGE\|INPUT-OUTPUT\|CONFIGURATION)\s+SECTION\b` | Section boundary | `WORKING-STORAGE SECTION` |
|
||||
|
||||
### IDENTIFICATION DIVISION
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_PROGRAM_ID` | `\bPROGRAM-ID\.\s*([A-Z][A-Z0-9-]*)` | Program name | `PROGRAM-ID. BGTABFL` |
|
||||
| `RE_AUTHOR` | `^\s+AUTHOR\.\s*(.+)` | Author metadata | `AUTHOR. D. Smith` |
|
||||
| `RE_DATE_WRITTEN` | `^\s+DATE-WRITTEN\.\s*(.+)` | Date metadata | `DATE-WRITTEN. 2024-01-15` |
|
||||
|
||||
### ENVIRONMENT DIVISION
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_SELECT_START` | `\bSELECT\s+(?:OPTIONAL\s+)?([A-Z][A-Z0-9-]+)` | File SELECT start (with optional `SELECT OPTIONAL` support) | `SELECT MASTER-FILE`, `SELECT OPTIONAL TRANS-FILE` |
|
||||
|
||||
SELECT statements are accumulated across multiple lines until a period terminator is found, then parsed for ASSIGN, ORGANIZATION, ACCESS, RECORD KEY, and FILE STATUS clauses.
|
||||
|
||||
### DATA DIVISION
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_FD` | `^\s+FD\s+([A-Z][A-Z0-9-]+)` | File description | `FD MASTER-FILE` |
|
||||
| `RE_DATA_ITEM` | `^\s+(\d{1,2})\s+([A-Z][A-Z0-9-]+)\s*(.*)` | Data item (01-77) | `05 WK-NAME PIC X(30)` |
|
||||
| `RE_ANONYMOUS_REDEFINES` | `^\s+(\d{1,2})\s+REDEFINES\s+([A-Z][A-Z0-9-]+)` | Anonymous REDEFINES | `01 REDEFINES WK-REC` |
|
||||
| `RE_88_LEVEL` | `^\s+88\s+([A-Z][A-Z0-9-]+)\s+VALUES?\s+(?:ARE\s+)?(.+)` | Condition name | `88 WK-ACTIVE VALUE "Y"` |
|
||||
|
||||
The trailing clauses of `RE_DATA_ITEM` are parsed by `parseDataItemClauses()` for PIC, USAGE, OCCURS, and REDEFINES.
|
||||
|
||||
### PROCEDURE DIVISION
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_PROC_SECTION` | `^ ([A-Z][A-Z0-9-]+)\s+SECTION\.\s*$` | Procedure section header | ` MAIN-LOGIC SECTION.` |
|
||||
| `RE_PROC_PARAGRAPH` | `^ ([A-Z][A-Z0-9-]+)\.\s*$` | Paragraph header | ` PROCESS-RECORD.` |
|
||||
| `RE_PERFORM` | `\bPERFORM\s+([A-Z][A-Z0-9-]+)(?:\s+THRU\s+([A-Z][A-Z0-9-]+))?` | PERFORM call | `PERFORM CALC-TAX THRU CALC-TAX-EXIT` |
|
||||
| `RE_PROC_USING` | `\bPROCEDURE\s+DIVISION\s+USING\s+([\s\S]*?)(?:\.\|$)` | USING parameters | `PROCEDURE DIVISION USING WK-PARAM` |
|
||||
| `RE_ENTRY` | `\bENTRY\s+"([^"]+)"(?:\s+USING\s+([\s\S]*?))?(?:\.\|$)` | ENTRY point | `ENTRY "SUBPROG" USING WK-DATA` |
|
||||
| `RE_MOVE` | `\bMOVE\s+((?:CORRESPONDING\|CORR)\s+)?([A-Z][A-Z0-9-]+)\s+TO\s+(.+)` | MOVE statement (supports CORR abbreviation and multi-target) | `MOVE WK-NAME TO OUT-NAME`, `MOVE CORR WK-IN TO WK-OUT` |
|
||||
|
||||
The USING parameter list (`RE_PROC_USING`) is split on `\bRETURNING\b` before tokenization -- any RETURNING clause and everything after it is excluded from the parameter list (`.split(/\bRETURNING\b/i)[0]`).
|
||||
|
||||
Note: `RE_PROC_SECTION` and `RE_PROC_PARAGRAPH` require exactly 7 spaces of leading indentation (COBOL area A starting at column 8). This is the standard COBOL paragraph indentation.
|
||||
|
||||
### All-Division Patterns
|
||||
|
||||
These patterns are checked regardless of current division:
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_CALL` | `\bCALL\s+"([^"]+)"` | External program call | `CALL "BGTABUP"` |
|
||||
| `RE_COPY_UNQUOTED` | `\bCOPY\s+([A-Z][A-Z0-9-]+)(?:\s\|\.)` | COPY (unquoted) | `COPY CPSESP.` |
|
||||
| `RE_COPY_QUOTED` | `\bCOPY\s+"([^"]+)"(?:\s\|\.)` | COPY (quoted) | `COPY "WORKGRID.CPY".` |
|
||||
|
||||
### SORT/MERGE Support
|
||||
|
||||
| Constant | Purpose |
|
||||
|----------|---------|
|
||||
| `SORT_CLAUSE_NOISE` | Set of SORT/MERGE clause keywords filtered from USING/GIVING file lists: `ON`, `ASCENDING`, `DESCENDING`, `KEY`, `WITH`, `DUPLICATES`, `IN`, `ORDER`, `COLLATING`, `SEQUENCE`, `IS`, `THROUGH`, `THRU`, `INPUT`, `OUTPUT`, `PROCEDURE` |
|
||||
|
||||
SORT and MERGE statements are accumulated across multiple lines (like SELECT) until a period terminator is found, then parsed for USING/GIVING file lists and INPUT/OUTPUT PROCEDURE targets. The `flushSort()` helper encapsulates the flush-and-parse logic, mirroring the existing `flushSelect()` pattern. Both helpers are called at EOF to handle truncated files.
|
||||
|
||||
### GO TO Multi-Target
|
||||
|
||||
`RE_GOTO` captures all paragraph names in a `GO TO` statement, including the multi-target form `GO TO p1 p2 p3 DEPENDING ON x`. The captured group contains all target names (space-separated), which are split into individual targets. Each target produces a separate `gotos` entry.
|
||||
|
||||
### PROGRAM-ID Detection
|
||||
|
||||
PROGRAM-ID is detected regardless of the current division state. This handles sibling programs that appear after `END PROGRAM` and omit the `IDENTIFICATION DIVISION` header -- the extractor will still capture the PROGRAM-ID and push a new program boundary.
|
||||
|
||||
### EXEC Block Patterns
|
||||
|
||||
| Constant | Pattern | Purpose | Example Match |
|
||||
|----------|---------|---------|---------------|
|
||||
| `RE_EXEC_SQL_START` | `\bEXEC\s+SQL\b` | Start of EXEC SQL block | `EXEC SQL` |
|
||||
| `RE_EXEC_CICS_START` | `\bEXEC\s+CICS\b` | Start of EXEC CICS block | `EXEC CICS` |
|
||||
| `RE_END_EXEC` | `\bEND-EXEC\b` | End of EXEC block | `END-EXEC` |
|
||||
|
||||
EXEC blocks accumulate all lines between `EXEC SQL/CICS` and `END-EXEC`, then delegate to `parseExecSqlBlock()` or `parseExecCicsBlock()` for detailed extraction.
|
||||
|
||||
## Excluded Paragraph Names
|
||||
|
||||
The following names are excluded from paragraph detection to avoid false positives from division/section headers:
|
||||
|
||||
```
|
||||
DECLARATIVES, END, PROCEDURE, IDENTIFICATION,
|
||||
ENVIRONMENT, DATA, WORKING-STORAGE, LINKAGE,
|
||||
FILE, LOCAL-STORAGE, COMMUNICATION, REPORT,
|
||||
SCREEN, INPUT-OUTPUT, CONFIGURATION
|
||||
```
|
||||
|
||||
Additionally, paragraph candidates containing `DIVISION` or `SECTION` as substrings are excluded.
|
||||
|
||||
## MOVE Skip List (Figurative Constants)
|
||||
|
||||
MOVE statements where the source is a figurative constant are skipped:
|
||||
|
||||
```
|
||||
SPACES, ZEROS, ZEROES, LOW-VALUES, LOW-VALUE,
|
||||
HIGH-VALUES, HIGH-VALUE, QUOTES, QUOTE, ALL
|
||||
```
|
||||
|
||||
## Source Files
|
||||
|
||||
- `gitnexus/src/core/ingestion/cobol-preprocessor.ts` -- `preprocessCobolSource()`, `extractCobolSymbolsWithRegex()`, all regex constants
|
||||
@@ -1,300 +0,0 @@
|
||||
# Using GitNexus across gRPC microservices
|
||||
|
||||
## When to use this guide
|
||||
|
||||
This guide is for teams whose product lives in **several separate Git repositories** — one per service — and whose services talk to each other over **gRPC** (possibly alongside HTTP and message topics). GitNexus indexes each repo independently, then a _group_ stitches the per-repo indexes into a single cross-repo view that the `impact`, `query`, and `context` tools can traverse. If your services live in one monorepo, much of this still applies — set each service as a member of a group and use the `service` prefix to scope queries — but the walkthrough assumes the harder multi-repo case.
|
||||
|
||||
## Mental model
|
||||
|
||||
- Each repository has its own `.gitnexus/` index (a LadybugDB graph of symbols, relationships, processes). `gitnexus analyze` in each repo produces that index completely independently.
|
||||
- A **group** is a higher-level construct stored at `~/.gitnexus/groups/<group>/` that references the per-repo indexes by their registry name.
|
||||
- Sync-time extractors walk each member repo and emit **contracts** — provider or consumer records keyed by a canonical `contractId` (`grpc::auth.AuthService/Login`, `http::GET::/orders`, etc.).
|
||||
- The sync step matches providers and consumers that share a `contractId` and writes **cross-links** to `<groupDir>/contracts.json`. Those cross-links are what lets `impact({repo: "@<group>", target: "X"})` hop from one repo into another.
|
||||
- Contracts come from three places: automatic contract extractors (`grpc-extractor`, `http-route-extractor`, `topic-extractor`), a manifest escape hatch (`config.links` in `group.yaml`), and — for same-name symbol matches where no contract is declared — the exact-match matching cascade in [`matching.ts`](../../gitnexus/src/core/group/matching.ts).
|
||||
- Each repo stays editable and re-indexable on its own. Re-run `gitnexus analyze` in a repo when it changes, then `gitnexus group sync <group>` to refresh `contracts.json`. `gitnexus group status` reports which members are stale.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GitNexus installed and runnable as `gitnexus` or `npx gitnexus` (see the root [README.md](../../README.md)).
|
||||
- Each service repository checked out locally. No requirement that they share a parent directory — the group references them by registry name.
|
||||
- Write access to `~/.gitnexus/` (the default gitnexus home; see `getDefaultGitnexusDir` in [`storage.ts`](../../gitnexus/src/core/group/storage.ts)).
|
||||
|
||||
## Step-by-step walkthrough
|
||||
|
||||
The example uses three services — a TypeScript API gateway, a Go orders service, and a Python inventory service — with gRPC between them. The gateway is an `orders` consumer; the orders service is both an `orders` provider and an `inventory` consumer; the inventory service is an `inventory` provider.
|
||||
|
||||
### 1. Index each repository
|
||||
|
||||
Run `analyze` from inside each service repo (or pass the path). The CLI surface lives in [`gitnexus/src/cli/analyze.ts`](../../gitnexus/src/cli/analyze.ts) and is wired in [`gitnexus/src/cli/index.ts`](../../gitnexus/src/cli/index.ts).
|
||||
|
||||
```bash
|
||||
cd ~/code/gateway && npx gitnexus analyze
|
||||
cd ~/code/orders && npx gitnexus analyze
|
||||
cd ~/code/inventory && npx gitnexus analyze
|
||||
```
|
||||
|
||||
Useful flags:
|
||||
|
||||
- `--force` — reindex even if up to date.
|
||||
- `--embeddings` — generate embedding vectors (needed only if you want semantic search; the exact-match cross-repo cascade does **not** need them).
|
||||
- `--name <alias>` — register the repo under a specific alias when two repos share a basename (e.g. two `api/` folders).
|
||||
- `--skip-git` — index a checkout that isn't a git repo.
|
||||
|
||||
Each run writes a `.gitnexus/` folder in the repo and registers the repo in `~/.gitnexus/registry.json`. Confirm with `npx gitnexus list`.
|
||||
|
||||
### 2. Author `group.yaml`
|
||||
|
||||
Create the group directory and edit the config. Either use the CLI scaffolder or write the file directly — both produce the same shape consumed by [`config-parser.ts`](../../gitnexus/src/core/group/config-parser.ts).
|
||||
|
||||
```bash
|
||||
npx gitnexus group create payments-platform
|
||||
# or manually:
|
||||
mkdir -p ~/.gitnexus/groups/payments-platform
|
||||
$EDITOR ~/.gitnexus/groups/payments-platform/group.yaml
|
||||
```
|
||||
|
||||
Minimal working `group.yaml`:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: payments-platform
|
||||
description: Gateway + orders + inventory (gRPC)
|
||||
|
||||
repos:
|
||||
gateway: gateway
|
||||
orders: orders
|
||||
inventory: inventory
|
||||
|
||||
# Only add explicit links when the automatic extractors miss something —
|
||||
# see "When automatic extraction isn't enough" below.
|
||||
links: []
|
||||
|
||||
packages: {}
|
||||
|
||||
detect:
|
||||
http: true
|
||||
grpc: true
|
||||
topics: true
|
||||
shared_libs: true
|
||||
embedding_fallback: false
|
||||
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
# Exclude noisy paths from cross-link matching (contracts are still extracted)
|
||||
exclude_links_paths: [/ping, /health, /healthcheck]
|
||||
exclude_links_param_only_paths: true
|
||||
```
|
||||
|
||||
Field notes (schema in [`types.ts`](../../gitnexus/src/core/group/types.ts)):
|
||||
|
||||
- `version` — must be `1`. The parser rejects anything else.
|
||||
- `name` — required; used for the group directory name and all CLI / MCP calls.
|
||||
- `repos` — a mapping from **group path** (a logical name you choose; can be a hierarchy like `backend/orders`) to **registry name** (the name shown by `npx gitnexus list`). Both sides appear throughout the tooling: contract rows use the group path; `@<group>/<groupPath>` routes tools to a single member.
|
||||
- `links` — optional manifest escape hatch, one entry per explicit cross-repo contract. Validated by the parser: `from` and `to` must be known repo paths, `type` must be one of `http | grpc | topic | lib | custom`, and `role` must be `provider | consumer`.
|
||||
- `detect` — toggles per extractor family. Defaults (set in `config-parser.ts`) turn `http`, `grpc`, `topics`, and `shared_libs` on; disable the ones you don't use to speed up sync.
|
||||
- `matching` — thresholds for the matching cascade. The exact match is always run; other strategies depend on indexer state. Two optional fields reduce false-positive cross-links in large groups:
|
||||
- `exclude_links_paths` — list of HTTP paths to exclude from cross-link matching (default `[]`). Contracts at these paths are still extracted and visible in the registry, but they don't produce cross-repo links. Useful for health-check endpoints (`/ping`, `/health`) that every service exposes. Trailing slashes are normalized.
|
||||
- `exclude_links_param_only_paths` — when `true`, exclude routes where every segment is `{param}` (e.g. `/{param}`, `/{param}/{param}`) from cross-link matching (default `false`). Mixed routes like `/users/{param}` are not affected.
|
||||
|
||||
### 3. Sync the group
|
||||
|
||||
```bash
|
||||
npx gitnexus group sync payments-platform --verbose
|
||||
```
|
||||
|
||||
What this does (see [`sync.ts`](../../gitnexus/src/core/group/sync.ts)):
|
||||
|
||||
1. Opens each member's per-repo LadybugDB.
|
||||
2. Runs the HTTP, gRPC, and topic extractors against the source files.
|
||||
3. Applies manifest `links` through [`manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts).
|
||||
4. Runs the exact-match cascade, joining providers and consumers that share a normalized `contractId`.
|
||||
5. Writes `contracts.json` in the group directory.
|
||||
|
||||
Flags:
|
||||
|
||||
- `--exact-only` — stop after the exact cascade; skip BM25 and embedding fallback.
|
||||
- `--skip-embeddings` — run exact plus BM25 but not embedding-based matching.
|
||||
- `--allow-stale` — don't warn if a member's index is stale.
|
||||
- `--json` — machine-readable output.
|
||||
|
||||
The same operation is available over MCP as `group_sync({ name: "payments-platform" })` — see [`tools.ts`](../../gitnexus/src/mcp/tools.ts).
|
||||
|
||||
### 4. Inspect the registry
|
||||
|
||||
Use `gitnexus group contracts` for the CLI view or read the `gitnexus://group/<name>/contracts` MCP resource for the same data.
|
||||
|
||||
```bash
|
||||
npx gitnexus group contracts payments-platform --type grpc --json
|
||||
```
|
||||
|
||||
A shortened response:
|
||||
|
||||
```json
|
||||
{
|
||||
"contracts": [
|
||||
{
|
||||
"contractId": "grpc::orders.OrderService/PlaceOrder",
|
||||
"type": "grpc",
|
||||
"role": "provider",
|
||||
"repo": "orders",
|
||||
"symbolRef": { "filePath": "internal/grpc/order_server.go", "name": "RegisterOrderServiceServer" },
|
||||
"confidence": 0.8,
|
||||
"meta": { "service": "OrderService", "method": "PlaceOrder", "source": "go_register" }
|
||||
},
|
||||
{
|
||||
"contractId": "grpc::orders.OrderService/PlaceOrder",
|
||||
"type": "grpc",
|
||||
"role": "consumer",
|
||||
"repo": "gateway",
|
||||
"symbolRef": { "filePath": "src/clients/orders.ts", "name": "OrderServiceClient" },
|
||||
"confidence": 0.75,
|
||||
"meta": { "service": "OrderService", "source": "ts_generated_client" }
|
||||
}
|
||||
],
|
||||
"crossLinks": [
|
||||
{
|
||||
"from": { "repo": "gateway", "symbolUid": "…", "symbolRef": { "filePath": "src/clients/orders.ts", "name": "OrderServiceClient" } },
|
||||
"to": { "repo": "orders", "symbolUid": "…", "symbolRef": { "filePath": "internal/grpc/order_server.go", "name": "RegisterOrderServiceServer" } },
|
||||
"type": "grpc",
|
||||
"contractId": "grpc::orders.OrderService/PlaceOrder",
|
||||
"matchType": "exact",
|
||||
"confidence": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Staleness of the underlying indexes shows up in `npx gitnexus group status payments-platform` or the `gitnexus://group/<name>/status` resource.
|
||||
|
||||
### 5. Run cross-repo impact with `@<group>` routing
|
||||
|
||||
From any shell (you do **not** have to `cd` into a member repo), the normal `impact` / `query` / `context` tools accept `repo: "@<group>"` to fan out across all members, or `repo: "@<group>/<memberPath>"` to target one member. Routing is implemented in [`resolve-at-member.ts`](../../gitnexus/src/core/group/resolve-at-member.ts) and described in [`tools.ts`](../../gitnexus/src/mcp/tools.ts).
|
||||
|
||||
Example MCP calls:
|
||||
|
||||
```json
|
||||
{"tool": "impact", "arguments": {
|
||||
"repo": "@payments-platform/orders",
|
||||
"target": "PlaceOrder",
|
||||
"direction": "upstream",
|
||||
"crossDepth": 2
|
||||
}}
|
||||
```
|
||||
|
||||
```json
|
||||
{"tool": "query", "arguments": {
|
||||
"repo": "@payments-platform",
|
||||
"query": "retry logic around PlaceOrder"
|
||||
}}
|
||||
```
|
||||
|
||||
The CLI equivalents still exist for scripting:
|
||||
|
||||
```bash
|
||||
npx gitnexus group impact payments-platform \
|
||||
--repo orders --target PlaceOrder --direction upstream --cross-depth 2
|
||||
```
|
||||
|
||||
Phase 1 walks within the anchor member; Phase 2 hops across the Contract Bridge wherever a cross-link endpoint matches an impacted symbol. See [`cross-impact.ts`](../../gitnexus/src/core/group/cross-impact.ts) for the bridge query.
|
||||
|
||||
## How gRPC extraction works
|
||||
|
||||
`GrpcExtractor` ([`grpc-extractor.ts`](../../gitnexus/src/core/group/extractors/grpc-extractor.ts)) runs two passes per member repo:
|
||||
|
||||
1. **Proto map.** Every `**/*.proto` file is parsed to enumerate `service Foo { rpc Bar(...) }` blocks and (transitively) resolve the package name. Each RPC method becomes a provider contract with `contractId = grpc::<package>.<Service>/<Method>` and `confidence = 0.85`. Parsing uses the vendored `tree-sitter-proto` grammar when available and falls back to a length-preserving manual parser (`extractServiceBlocks`) otherwise, so `.proto` extraction works on platforms where the grammar fails to build.
|
||||
2. **Source scan.** Every source file whose extension matches [`GRPC_SCAN_GLOB`](../../gitnexus/src/core/group/extractors/grpc-patterns/index.ts) is parsed by its language plugin:
|
||||
|
||||
| Language | Provider signal | Consumer signal |
|
||||
|----------|-----------------|-----------------|
|
||||
| Go ([`go.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/go.ts)) | `pb.RegisterXxxServer(...)`, `pb.UnimplementedXxxServer` embedded in struct | `pb.NewXxxClient(conn)` |
|
||||
| Java ([`java.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/java.ts)) | `extends XxxServiceGrpc.XxxServiceImplBase` (with or without `@GrpcService`) | `XxxServiceGrpc.newBlockingStub(...)`, `newStub(...)` |
|
||||
| Python ([`python.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/python.ts)) | `add_XxxServicer_to_server(...)` (bare or `_pb2_grpc.` attribute form) | `XxxStub(channel)` (ignores `Mock`/`Test`/`Fake`/`Stub`) |
|
||||
| Node / TS ([`node.ts`](../../gitnexus/src/core/group/extractors/grpc-patterns/node.ts)) | NestJS `@GrpcMethod('Service','Method')` | `@GrpcClient` field typed `XxxServiceClient`, `client.getService<X>('Service')`, `new XxxServiceClient(...)`, `new foo.bar.XxxService(...)` in files that call `loadPackageDefinition` |
|
||||
|
||||
For each source-scan detection the extractor looks up the short service name in the proto map and picks:
|
||||
|
||||
- `grpc::<package>.<Service>/<Method>` when a method is named and the service resolves against the proto map,
|
||||
- `grpc::<package>.<Service>/*` (wildcard) when only the service is known, or
|
||||
- `grpc::<ServiceName>/*` when no `.proto` is available at all.
|
||||
|
||||
Provider detections land at confidence 0.8 (with proto) or 0.65 (without); consumers at 0.75 or 0.55. NestJS `@GrpcMethod` is fixed at 0.8 because the decorator is self-describing.
|
||||
|
||||
### Matching
|
||||
|
||||
`matching.ts` lowercases the package/service segment before comparing contract ids, so bindings that capitalize names differently (`auth.AuthService` vs `auth.authservice`) still match. Method names are compared case-sensitively because gRPC's wire path is case-sensitive. Service-only wildcards (`grpc::pkg.Svc/*`) match any method on the same service during cross-linking.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- **Ambiguous proto resolution.** If a short service name exists in more than one `.proto` file and the source-scan hit can't be narrowed down by shared directory segments (`resolveProtoConflict` refuses to guess), the extractor skips contract emission and logs a warning.
|
||||
- **Proto packages must be resolvable locally.** Transitive imports that point outside the repo produce an empty package segment, which means the contract id collapses to `grpc::<Service>/<Method>`. Cross-repo matches still work as long as both sides agree on the empty package.
|
||||
- **Rewrite rules are not implemented.** If the provider repo writes `grpc::orders.OrderService/PlaceOrder` and the consumer repo writes `grpc::orderspb.OrderService/PlaceOrder`, they won't cross-link automatically. Use `config.links` to declare the correspondence (see below).
|
||||
- **One sync = one snapshot.** Contracts are extracted against the indexed snapshot of each repo. Re-index first, then re-sync; the `status` command and resource surface staleness.
|
||||
|
||||
## When automatic extraction isn't enough
|
||||
|
||||
The escape hatch is the `links` list in `group.yaml`, handled by [`ManifestExtractor`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts). Each entry is a **one-directional** provider/consumer declaration:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: payments-platform
|
||||
repos:
|
||||
gateway: gateway
|
||||
orders: orders
|
||||
inventory: inventory
|
||||
|
||||
links:
|
||||
# Explicit gRPC method: use when naming mismatches stop the
|
||||
# automatic matcher from cross-linking.
|
||||
- from: gateway
|
||||
to: orders
|
||||
type: grpc
|
||||
contract: OrderService/PlaceOrder
|
||||
role: consumer
|
||||
|
||||
# Service-level link when you don't want to enumerate methods.
|
||||
- from: orders
|
||||
to: inventory
|
||||
type: grpc
|
||||
contract: InventoryService
|
||||
role: consumer
|
||||
|
||||
# Works for HTTP too — use `METHOD::/path` form for the exact
|
||||
# handler, or just `/path` for a method-agnostic wildcard.
|
||||
- from: gateway
|
||||
to: orders
|
||||
type: http
|
||||
contract: POST::/orders
|
||||
role: consumer
|
||||
```
|
||||
|
||||
What the manifest extractor does (see [`manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts)):
|
||||
|
||||
1. Builds a canonical `contractId` with `buildContractId` — the same canonicalization used by the automatic extractors, so manifest links cross-match automatic contracts on the other side.
|
||||
2. Tries to resolve each side to a real graph symbol (the `Route` node for HTTP, a `Function|Method` / `Class|Interface` for gRPC, a `Package|Module` for `lib`).
|
||||
3. If resolution fails, falls back to a deterministic synthetic uid (`manifest::<repo>::<contractId>`) so both sides still line up in cross-impact — name-only links still work when the symbol isn't in the graph.
|
||||
4. Emits both a provider and a consumer `StoredContract` (confidence `1.0`, `source: "manifest"`) and a `CrossLink` with `matchType: "manifest"`.
|
||||
|
||||
Use `links` for exactly the cases the extractor can't infer: different package names across repos (see #701), hand-rolled transports, cases where the provider repo isn't checked out locally but you still want a record, or any contract whose provider and consumer simply don't share a surface the extractors know how to pattern-match.
|
||||
|
||||
History: the manifest extractor used to be silently skipped by the sync pipeline; that was fixed in [#827](https://github.com/abhigyanpatwari/GitNexus/pull/827) (tracking issue #826). If you ever see `config.links` with zero cross-links in `contracts.json`, make sure you're on a build that includes that fix, then re-run `group sync`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **`contracts.json` is empty after a sync.** Either no member repo contained a recognizable gRPC pattern, or the extractors are disabled in `detect`. Confirm `detect.grpc: true` and re-run with `--verbose`.
|
||||
2. **A known provider/consumer pair doesn't cross-link.** Most common cause: the package segment differs. Check the raw contract ids with `gitnexus group contracts <name> --unmatched` — if you see two same-method contracts with different package prefixes, add a manifest `links:` entry to bridge them (no automatic rewrite rules yet).
|
||||
3. **`matchType: "manifest"` is missing entirely.** The extractor needs `config.links` to be non-empty and the sync pipeline to actually call it — verify you're on a post-#827 build. Empty contract rows for manifest links usually mean `resolveSymbol` couldn't find a graph match; the synthetic uid still lets cross-impact work, it just won't carry a file path.
|
||||
4. **Ambiguous proto warnings.** Look for `[grpc-extractor] Ambiguous proto resolution` in the sync logs; that means a service name exists in multiple `.proto` files under the same repo and the path-distance heuristic couldn't pick a winner. Resolve by renaming the service or declaring the intended pairing in `config.links`.
|
||||
5. **Cross-impact says "stale".** Both sides need a fresh per-repo index _and_ a fresh group sync. Order matters: `gitnexus analyze` in each changed repo, then `gitnexus group sync <name>`. Use `gitnexus group status <name>` to see which side is behind.
|
||||
|
||||
## Related docs and references
|
||||
|
||||
- [AGENTS.md](../../AGENTS.md) — authoritative list of MCP tools and resources, including group-mode routing and the `gitnexus://group/…` resources.
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — overall data flow and the call-resolution DAG that the per-repo indexer uses.
|
||||
- [`gitnexus/src/core/group/`](../../gitnexus/src/core/group/) — `service.ts`, `sync.ts`, `config-parser.ts`, `matching.ts`.
|
||||
- [`gitnexus/src/core/group/extractors/grpc-extractor.ts`](../../gitnexus/src/core/group/extractors/grpc-extractor.ts) and [`grpc-patterns/`](../../gitnexus/src/core/group/extractors/grpc-patterns/) — gRPC detection.
|
||||
- [`gitnexus/src/core/group/extractors/manifest-extractor.ts`](../../gitnexus/src/core/group/extractors/manifest-extractor.ts) — the `config.links` escape hatch.
|
||||
- [`gitnexus/src/mcp/tools.ts`](../../gitnexus/src/mcp/tools.ts) — MCP tool schemas (`group_list`, `group_sync`, plus `@<group>` routing on `impact` / `query` / `context`).
|
||||
- [`gitnexus/src/cli/group.ts`](../../gitnexus/src/cli/group.ts) — CLI command definitions and flags.
|
||||
- Upstream issues: [#701](https://github.com/abhigyanpatwari/GitNexus/issues/701), [#826](https://github.com/abhigyanpatwari/GitNexus/issues/826), [#906](https://github.com/abhigyanpatwari/GitNexus/issues/906).
|
||||
@@ -1,185 +0,0 @@
|
||||
# Using GitNexus across Apache Thrift microservices
|
||||
|
||||
## When to use this guide
|
||||
|
||||
Use this guide when several repositories communicate through Apache Thrift and you want GitNexus to trace impact across provider and consumer boundaries. The walkthrough assumes each service is indexed on its own, then joined through a GitNexus group.
|
||||
|
||||
This is not a framework integration guide. GitNexus reads portable Thrift IDL and common Java generated-code shapes. Framework-specific wiring, service discovery, deployment metadata, and private annotations belong outside the open-source core.
|
||||
|
||||
## Mental model
|
||||
|
||||
- `.thrift` files define the canonical service contract. A method in an IDL service becomes a stable contract id in the form `thrift::<namespace>.<Service>/<Method>`.
|
||||
- Service wildcard ids in the form `thrift::<namespace>.<Service>/*` are supported as manifest and matching fallback forms when a service-level link is needed.
|
||||
- Java generated-code usage points GitNexus toward implementation and call sites. Providers commonly implement generated `Service.Iface`; consumers commonly hold or construct generated service interfaces or clients.
|
||||
- Group sync matches provider and consumer contracts with the same id, then cross-repo impact can hop through those links.
|
||||
- Framework-specific wiring should be modeled by extractor plugins, manifest links, or downstream integrations rather than hard-coded into core Thrift support.
|
||||
|
||||
## Fictional IDL
|
||||
|
||||
```thrift
|
||||
namespace java billing.v1
|
||||
|
||||
struct PlaceOrderRequest {
|
||||
1: string orderId
|
||||
2: double amount
|
||||
}
|
||||
|
||||
struct PlaceOrderResponse {
|
||||
1: bool accepted
|
||||
}
|
||||
|
||||
struct GetOrderRequest {
|
||||
1: string orderId
|
||||
}
|
||||
|
||||
struct GetOrderResponse {
|
||||
1: string orderId
|
||||
2: string status
|
||||
}
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
GetOrderResponse GetOrder(1: GetOrderRequest request)
|
||||
}
|
||||
```
|
||||
|
||||
The service methods above produce canonical ids:
|
||||
|
||||
- `thrift::billing.v1.OrderService/PlaceOrder`
|
||||
- `thrift::billing.v1.OrderService/GetOrder`
|
||||
- `thrift::billing.v1.OrderService/*` as a service-level manifest or matching fallback form
|
||||
|
||||
## Java provider example
|
||||
|
||||
Generated Java code usually exposes an `Iface` interface for the service. A provider implementation can be detected when it implements that generated interface.
|
||||
|
||||
```java
|
||||
package example.billing;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.GetOrderResponse;
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
import billing.v1.PlaceOrderResponse;
|
||||
|
||||
public final class OrderServiceHandler implements OrderService.Iface {
|
||||
@Override
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetOrderResponse GetOrder(GetOrderRequest request) {
|
||||
return new GetOrderResponse(request.getOrderId(), "CREATED");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the IDL available, GitNexus can connect the implementation to `thrift::billing.v1.OrderService/PlaceOrder` and `thrift::billing.v1.OrderService/GetOrder`.
|
||||
|
||||
## Java consumer examples
|
||||
|
||||
Consumers are strongest when Java usage can be tied back to the IDL namespace and service.
|
||||
|
||||
```java
|
||||
package example.checkout;
|
||||
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
|
||||
public final class CheckoutWorkflow {
|
||||
private final OrderService.Iface orders;
|
||||
|
||||
public CheckoutWorkflow(OrderService.Iface orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
public void submit(String orderId) throws Exception {
|
||||
orders.PlaceOrder(new PlaceOrderRequest(orderId, 42.0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Some generated-code styles use the generated service type directly while keeping enough IDL context through imports and method calls.
|
||||
|
||||
```java
|
||||
package example.reporting;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.OrderService;
|
||||
|
||||
public final class OrderLookup {
|
||||
private final OrderService.Client client;
|
||||
|
||||
public OrderLookup(OrderService.Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public String status(String orderId) throws Exception {
|
||||
return client.GetOrder(new GetOrderRequest(orderId)).getStatus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When IDL context is missing, GitNexus may still emit a weaker consumer signal for generated `Iface` or `Client` shapes, but confidence is lower.
|
||||
|
||||
## Group configuration
|
||||
|
||||
New group configs enable Thrift contract detection by default. Keep `detect.thrift: true`
|
||||
when a group should scan for Thrift contracts, or set it to `false` to skip Thrift
|
||||
extraction for that group.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: billing-platform
|
||||
description: Fictional services connected by Apache Thrift
|
||||
|
||||
repos:
|
||||
checkout: checkout-service
|
||||
billing: billing-service
|
||||
|
||||
links: []
|
||||
|
||||
detect:
|
||||
http: true
|
||||
grpc: false
|
||||
thrift: true
|
||||
topics: false
|
||||
shared_libs: true
|
||||
```
|
||||
|
||||
To disable Thrift extraction explicitly:
|
||||
|
||||
```yaml
|
||||
detect:
|
||||
thrift: false
|
||||
```
|
||||
|
||||
After indexing each member repository, run group sync to extract contracts and write cross-repo links:
|
||||
|
||||
```bash
|
||||
npx gitnexus group sync billing-platform
|
||||
```
|
||||
|
||||
## Manifest escape hatch
|
||||
|
||||
Use manifest links when automatic extraction cannot see a provider or consumer, or when generated code is wrapped behind an abstraction. Write the contract without the `thrift::` prefix; GitNexus canonicalizes it to the full Thrift contract id.
|
||||
|
||||
```yaml
|
||||
links:
|
||||
- from: checkout
|
||||
to: billing
|
||||
type: thrift
|
||||
contract: billing.v1.OrderService/PlaceOrder
|
||||
role: consumer
|
||||
```
|
||||
|
||||
GitNexus canonicalizes that manifest entry to `thrift::billing.v1.OrderService/PlaceOrder` and uses it to connect the two repositories.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Java detection currently targets v1 generated-code patterns.
|
||||
- Maven and POM dependency coordinates are not used for inference.
|
||||
- Framework-specific annotations and service discovery metadata are ignored by open-source Thrift extraction.
|
||||
- Ambiguous same-name services are skipped instead of guessed.
|
||||
- Java consumers without IDL context are lower confidence and limited to generated `Iface` and `Client` shapes.
|
||||
@@ -1,326 +0,0 @@
|
||||
---
|
||||
title: "feat: Complete COBOL language feature coverage for maximum knowledge graph value"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-03-26
|
||||
origin: Feature audit from v3-integration-architect agent (session 8642401e)
|
||||
---
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-26
|
||||
**Research agents used:** COBOL expert (Phase 1+2), graph value analyst, codebase explorer
|
||||
**Sections enhanced:** Phase 1 (5 features), Phase 2 (4 features), graph value ranking
|
||||
|
||||
### Key Improvements from Research
|
||||
1. **CALL USING** is the #1 highest-value edge type (9.2/10) — fixes ~40% of missing caller references
|
||||
2. **EXEC DLI** requires dual-interface support (EXEC DLI + CBLTDLI CALL) for full IMS coverage
|
||||
3. **DECLARATIVES** is lowest-risk Phase 2 item — existing section/paragraph detection already captures structure
|
||||
4. **SET TO TRUE** accounts for 80-90% of all SET statements — prioritize this form
|
||||
5. **INSPECT** needs multi-line accumulator (like SORT) — can span 5+ continuation lines
|
||||
6. **Graph value ranking**: cobol-call-using (9.2) > cobol-error-handler (9.0) > dli-gu (8.2) > cobol-string (6.2)
|
||||
|
||||
### New Edge Cases Discovered
|
||||
- CALL USING supports mixed modes: `USING BY REFERENCE WS-A BY CONTENT WS-B BY VALUE WS-C`
|
||||
- CALL USING `ADDRESS OF` and `OMITTED` must be filtered from parameter lists
|
||||
- EXEC DLI can have multiple SEGMENT levels in hierarchical retrieval (use matchAll)
|
||||
- DECLARATIVES can have multiple USE sections (one per file + catch-all for INPUT/OUTPUT/I-O/EXTEND)
|
||||
- INSPECT TALLYING can have multiple counters in a single statement
|
||||
- STRING/UNSTRING can span multiple lines (need accumulator pattern)
|
||||
|
||||
---
|
||||
|
||||
# Complete COBOL Language Feature Coverage
|
||||
|
||||
## Overview
|
||||
|
||||
Implement the remaining 25 unhandled COBOL language features and fix 10 partial features to achieve ~95% coverage (up from 71.9%). The goal is to build the richest possible knowledge graph from COBOL codebases, enabling a future `modernize` MCP command (out of scope for this plan) that would use the graph to assist with COBOL-to-modern-language migration.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The COBOL processor currently handles 54 of 89 applicable language features (71.9%). The 25 unhandled features represent real data loss in the knowledge graph:
|
||||
- **Cross-program data flow** is invisible (CALL ... USING parameters not extracted)
|
||||
- **IMS/DB programs** produce empty graphs (EXEC DLI not recognized)
|
||||
- **String transformation logic** is invisible (STRING/UNSTRING/INSPECT not tracked)
|
||||
- **SQL copybook dependencies** are missing (EXEC SQL INCLUDE not mapped)
|
||||
- **Error handling flows** are lost (DECLARATIVES/USE AFTER not captured)
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Implement features in 4 phases, ordered by graph value density (edges created per LOC of implementation). Each phase is independently shippable and testable.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: High-Value Data Flow Edges (~150 LOC, ~8 new edge types)
|
||||
|
||||
The highest-ROI features: they create new ACCESSES and IMPORTS edges that directly improve impact analysis.
|
||||
|
||||
**Critical research finding**: Multi-line statement accumulation is the dominant challenge. CALL USING, STRING/UNSTRING, and multi-line data item clauses all span multiple lines in production COBOL. The free-format path processes each line independently — these features need statement accumulators (like SORT/SELECT) or the free-format path needs multi-line awareness. Estimated LOC increased from 110 to 150 to account for accumulator infrastructure.
|
||||
|
||||
#### 1.1 EXEC SQL INCLUDE -> IMPORTS edges
|
||||
- **File:** `cobol-preprocessor.ts` (parseExecSqlBlock)
|
||||
- **What:** Detect `INCLUDE` as the operation, extract member name, emit as a `copies[]` entry
|
||||
- **Graph:** IMPORTS edge from File to included copybook/SQLCA with reason `sql-include`
|
||||
- **Tests:** Unit test for `EXEC SQL INCLUDE SQLCA END-EXEC` and `EXEC SQL INCLUDE CUSTCOPY END-EXEC`
|
||||
|
||||
**Research insights (EXEC SQL INCLUDE):**
|
||||
- DB2 member names can contain underscores: `EXEC SQL INCLUDE CUST_TBL_DCL END-EXEC` — regex must use `[A-Z][A-Z0-9_-]+`
|
||||
- Quoted literal form: `EXEC SQL INCLUDE 'DBRMLIB.MEMBER' END-EXEC` (z/OS PDS qualified name)
|
||||
- SQLCA/SQLDA are DB2 builtins — won't resolve to repo files. Emit unresolved IMPORTS edge (still valuable)
|
||||
- No REPLACING support on EXEC SQL INCLUDE (unlike COPY)
|
||||
- Add `INCLUDE` to `OP_MAP` in `parseExecSqlBlock`; extract member via `RE_SQL_INCLUDE = /^INCLUDE\s+(?:'([^']+)'|"([^"]+)"|([A-Z][A-Z0-9_-]+))/i`
|
||||
|
||||
#### 1.2 CALL ... USING parameter extraction -> ACCESSES edges (Graph value: 9.2/10)
|
||||
- **File:** `cobol-preprocessor.ts` (processLogicalLine CALL section)
|
||||
- **What:** After capturing CALL target, scan for USING clause. Extract parameter names (reuse USING_KEYWORDS filter). Store as `calls[].parameters: string[]`
|
||||
- **Interface:** Add `parameters?: string[]` to calls array type in CobolRegexResults
|
||||
- **File:** `cobol-processor.ts` (CALL edge block)
|
||||
- **Graph:** For each USING parameter, create ACCESSES edge from caller to data item Property node with reason `cobol-call-using`
|
||||
- **Tests:** `CALL 'AUDITLOG' USING CUST-ID WS-AMOUNT` -> 2 ACCESSES edges
|
||||
|
||||
**Research insights (CALL USING forms):**
|
||||
- Mixed modes: `CALL 'PGM' USING BY REFERENCE WS-A BY CONTENT WS-B BY VALUE WS-C`
|
||||
- Pointer passing: `CALL 'PGM' USING ADDRESS OF WS-A`
|
||||
- Placeholder: `CALL 'PGM' USING OMITTED WS-B`
|
||||
- Filter keywords: add `ADDRESS`, `OMITTED`, `LENGTH` to USING_KEYWORDS (already has BY/VALUE/REFERENCE/CONTENT)
|
||||
- **Impact tool enhancement:** CALL-USING edges enable BFS traversal through parameter data flow — single most impactful edge type for COBOL impact analysis
|
||||
|
||||
#### 1.3 STRING/UNSTRING data flow -> ACCESSES edges
|
||||
- **File:** `cobol-preprocessor.ts` (new section in extractProcedure)
|
||||
- **What:** Accumulate multi-line STRING/UNSTRING until period or END-STRING/END-UNSTRING. Extract sources and INTO targets.
|
||||
- **Interface:** Add `strings: Array<{ sources: string[]; target: string; type: 'string' | 'unstring'; line: number; caller: string | null }>` to CobolRegexResults
|
||||
- **Graph:** read-ACCESSES on sources, write-ACCESSES on INTO target with reason `cobol-string-read` / `cobol-string-write`
|
||||
- **Tests:** 2 unit tests + integration test assertions
|
||||
|
||||
**Research insights (STRING/UNSTRING):**
|
||||
- **Needs statement accumulator** — STRING/UNSTRING always span multiple lines in production
|
||||
- Terminate accumulation at: period, END-STRING/END-UNSTRING, or start of next COBOL verb
|
||||
- STRING sources: identifiers before each `DELIMITED BY`. Filter: STRING, DELIMITED, BY, SIZE, ALL, INTO, WITH, POINTER, ON, OVERFLOW, NOT, END-STRING
|
||||
- UNSTRING: source is first identifier after UNSTRING; INTO targets are identifiers after INTO. Filter: DELIMITER, IN, COUNT, TALLYING, OR
|
||||
- WITH POINTER field is both read AND written (starting position updated)
|
||||
- TALLYING IN / COUNT IN fields are write targets
|
||||
- Literal sources (`'text'`) must be filtered — quote-aware tokenization needed
|
||||
- **Edge case**: STRING terminated by next verb, not period — existing fixture has `STRING ... DISPLAY` without period between them
|
||||
|
||||
#### 1.4 OCCURS DEPENDING ON -> ACCESSES edge
|
||||
- **File:** `cobol-preprocessor.ts` (parseDataItemClauses)
|
||||
- **What:** Extend OCCURS regex to capture DEPENDING ON field, KEY fields, and INDEXED BY names
|
||||
- **Interface:** Add `dependingOn?: string`, `occursMax?: number`, `occursKeys?: Array<{direction: string; fields: string[]}>`, `indexedBy?: string[]` to data items
|
||||
- **Graph:** ACCESSES edge from table item to controlling field with reason `cobol-depends-on`
|
||||
- **Tests:** `05 WS-TABLE OCCURS 100 DEPENDING ON WS-COUNT` -> edge
|
||||
|
||||
**Research insights (OCCURS):**
|
||||
- IBM allows `OCCURS 0 TO n DEPENDING ON` (zero minimum) and `OCCURS UNBOUNDED DEPENDING ON` (V6.4)
|
||||
- Subscripted controlling fields: `DEPENDING ON WS-COUNT(WS-IDX)` — strip subscripts before storing
|
||||
- **Pre-existing gap**: Multi-line data item clauses without continuation indicator are NOT captured. `05 WS-TABLE\n OCCURS 100\n DEPENDING ON WS-COUNT.` — the current RE_DATA_ITEM only gets the first line, `rest` is empty. Fixing properly requires a data item accumulator (like SELECT). **Defer full fix to Phase 3; implement same-line capture now.**
|
||||
- KEY IS fields: `ASCENDING KEY IS WS-KEY-1 WS-KEY-2` — capture for SEARCH ALL resolution
|
||||
- INDEXED BY: `INDEXED BY IDX-1 IDX-2` — capture for SET/SEARCH context
|
||||
|
||||
#### 1.5 VALUE clause for standard data items
|
||||
- **File:** `cobol-preprocessor.ts` (parseDataItemClauses)
|
||||
- **What:** Extract VALUE using a pragmatic function that handles quoted strings, numerics, figurative constants, hex/national literals
|
||||
- **Interface:** Already exists as `values?: string[]` on data items (currently only populated for 88-level)
|
||||
- **Graph:** Stored in Property node description (no new edges)
|
||||
- **Tests:** `01 WS-STATUS PIC X VALUE 'A'` -> values: ['A']
|
||||
|
||||
**Research insights (VALUE forms):**
|
||||
- Hex literals: `VALUE X'F1F2F3F4'`, National: `VALUE N'text'`, DBCS: `VALUE G'text'`
|
||||
- Figurative constants: SPACES, ZEROS, ZEROES, LOW-VALUES, HIGH-VALUES, QUOTES, NULL, NULLS
|
||||
- ALL literal: `VALUE ALL '*'`
|
||||
- Numeric with sign/decimal: `VALUE -123.45`, `VALUE +1`
|
||||
- `VALUE IS` optional — both `VALUE 'A'` and `VALUE IS 'A'` valid
|
||||
- **Decimal vs period ambiguity**: `VALUE 100.` — is `.` decimal or terminator? `parseDataItemClauses` already strips trailing period, so this is handled
|
||||
- IBM V6.4: floating-point `VALUE 1.0E5` — extend numeric regex if needed
|
||||
- Implementation: use a pragmatic `extractValue(rest)` function, not a single complex regex
|
||||
|
||||
### Phase 2: EXEC DLI + DECLARATIVES (~90 LOC, ~4 new edge types)
|
||||
|
||||
IMS/DB support and error handling flows.
|
||||
|
||||
#### 2.1 EXEC DLI (IMS/DB) -> ACCESSES edges (Graph value: 8.2/10)
|
||||
- **File:** `cobol-preprocessor.ts` (processLogicalLine — add RE_EXEC_DLI_START check alongside SQL/CICS)
|
||||
- **What:** Accumulate EXEC DLI blocks like EXEC SQL. Parse DLI verbs (GU, GN, GNP, GHU, GHN, GHNP, ISRT, DLET, REPL, CHKP, SCHD, TERM). Extract segment name, PCB number, INTO/FROM areas, WHERE fields, PSB name.
|
||||
- **Interface:** Add `execDliBlocks: Array<{ line: number; verb: string; pcbNumber?: number; segmentName?: string; intoField?: string; fromField?: string; whereField?: string; psbName?: string }>` to CobolRegexResults
|
||||
- **Graph:** CodeElement node + ACCESSES edge to `<ims>:<segmentName>` Record node with reason `dli-{verb}`; ACCESSES edges to INTO/FROM data areas; PSB ACCESSES for SCHD
|
||||
- **Tests:** `EXEC DLI GU USING PCB(1) SEGMENT(CUSTOMER) INTO(WS-CUST) END-EXEC`
|
||||
|
||||
**Research insights (dual IMS interface):**
|
||||
- **EXEC DLI**: Embedded command interface for CICS-DL/I programs only
|
||||
- **CBLTDLI CALL**: Batch interface via `CALL 'CBLTDLI' USING function-code PCB io-area SSA1..SSA15`
|
||||
- CBLTDLI is already captured as a CALL to 'CBLTDLI' — enrich with USING parameter semantics later
|
||||
- Multiple SEGMENT levels in hierarchical retrieval — use `matchAll` on segment regex
|
||||
- DLI verbs: GU (most common), GN, GNP, GHU, GHN, GHNP, ISRT, REPL, DLET, CHKP, SCHD, TERM, ROLL, ROLB
|
||||
- **Edge case**: DLET/REPL have no SEGMENT clause (operate on current position)
|
||||
- **Recommended order**: Implement AFTER DECLARATIVES and SET (lower risk, higher frequency)
|
||||
|
||||
#### 2.2 DECLARATIVES / USE AFTER STANDARD EXCEPTION (Graph value: 9.0/10)
|
||||
- **File:** `cobol-preprocessor.ts` (processLogicalLine — detect DECLARATIVES keyword, track USE AFTER blocks)
|
||||
- **What:** When `DECLARATIVES.` is encountered, switch to declaratives mode. Extract USE statements binding sections to files/modes.
|
||||
- **Interface:** Add `declaratives: Array<{ sectionName: string; useType: 'error' | 'debug' | 'label' | 'reporting'; target: string; line: number }>` to CobolRegexResults
|
||||
- **Graph:** ACCESSES edge from declarative Namespace to file Record with reason `cobol-declarative-error-handler`
|
||||
- **Tests:** Unit test with DECLARATIVES section, integration test for error flow
|
||||
|
||||
**Research insights (DECLARATIVES syntax):**
|
||||
- `USE AFTER STANDARD {EXCEPTION|ERROR} ON {file-name|INPUT|OUTPUT|I-O|EXTEND}`
|
||||
- EXCEPTION and ERROR are synonymous; STANDARD is optional in IBM dialects
|
||||
- Multiple USE sections allowed (one per file + catch-all for I/O modes)
|
||||
- `END DECLARATIVES.` must NOT reset PROCEDURE DIVISION state
|
||||
- `DECLARATIVES` is already in EXCLUDED_PARA_NAMES — no false paragraph risk
|
||||
- Existing section/paragraph detection already captures structural elements — just need USE binding
|
||||
- **Lowest risk Phase 2 item** — implement first
|
||||
|
||||
#### 2.3 SET statement -> ACCESSES edges
|
||||
- **File:** `cobol-preprocessor.ts` (extractProcedure — new RE_SET regex)
|
||||
- **Interface:** Add `sets: Array<{ targets: string[]; form: 'to-true'|'to-value'|'up-by'|'down-by'|'address-of'|'to-null'|'to-entry'; value?: string; entryTarget?: string; entryIsLiteral?: boolean; line: number; caller: string | null }>` to CobolRegexResults
|
||||
- **Graph:** ACCESSES write edge with reason `cobol-set-condition` (TO TRUE), `cobol-set-index` (TO/UP/DOWN), `cobol-set-address` (ADDRESS OF). SET ENTRY with literal -> CALLS edge.
|
||||
- **Tests:** `SET WS-EOF TO TRUE`, `SET IDX-1 TO 5`, `SET IDX-1 UP BY 1`
|
||||
|
||||
**Research insights (SET forms by frequency):**
|
||||
- `SET condition TO TRUE` — 80-90% of all SET usage. Multiple targets: `SET COND-A COND-B TO TRUE`
|
||||
- `SET index TO/UP BY/DOWN BY` — ~8%. Multiple indices: `SET IDX-1 IDX-2 UP BY 1`
|
||||
- `SET pointer TO ADDRESS OF data-item` / `SET ADDRESS OF data-item TO pointer` — ~2%
|
||||
- `SET proc-ptr TO ENTRY "PROGNAME"` — rare but creates CALLS edge (like dynamic CALL)
|
||||
- Filter OF/IN qualifiers: `SET COND-A OF WS-RECORD TO TRUE` (strip OF WS-RECORD)
|
||||
- **Prioritize**: SET TO TRUE alone covers 80-90% — implement this form first
|
||||
|
||||
#### 2.4 INSPECT -> ACCESSES edges
|
||||
- **File:** `cobol-preprocessor.ts` (extractProcedure — new `inspectAccum` accumulator like SORT)
|
||||
- **What:** Accumulate multi-line INSPECT until period. Extract inspected field + tally counters.
|
||||
- **Interface:** Add `inspects: Array<{ inspectedField: string; counters: string[]; form: 'tallying'|'replacing'|'converting'|'tallying-replacing'; line: number; caller: string | null }>` to CobolRegexResults
|
||||
- **Graph:** ACCESSES read on inspected field always; write if REPLACING/CONVERTING. Write edges for tally counters. Reason: `cobol-inspect-read`/`cobol-inspect-write`/`cobol-inspect-tally`
|
||||
- **Tests:** `INSPECT WS-FIELD TALLYING WS-COUNT FOR ALL 'A'` -> read on WS-FIELD, write on WS-COUNT
|
||||
|
||||
**Research insights (INSPECT forms by frequency):**
|
||||
- REPLACING (~60%): `INSPECT WS-STR REPLACING ALL 'A' BY 'B'`
|
||||
- TALLYING (~25%): `INSPECT WS-STR TALLYING WS-CNT FOR ALL 'A'` — multiple counters possible
|
||||
- CONVERTING (~10%): `INSPECT WS-STR CONVERTING 'abc' TO 'ABC'`
|
||||
- Combined (~5%): TALLYING + REPLACING in single statement
|
||||
- **Needs multi-line accumulator** — INSPECT frequently spans 3-5 lines in production
|
||||
- Extract tally counters with `([A-Z][A-Z0-9-]+)\s+FOR\b` matchAll pattern
|
||||
- Filter figurative constants (SPACES, ZEROS) using existing MOVE_SKIP set
|
||||
|
||||
### Phase 3: Completeness Fixes (~60 LOC)
|
||||
|
||||
Fix the 10 partial features and small gaps.
|
||||
|
||||
#### 3.1 CALL ... RETURNING extraction
|
||||
- Extend RE_CALL processing to capture RETURNING target after the USING clause
|
||||
- Store as `calls[].returning?: string`
|
||||
- Graph: ACCESSES write edge with reason `cobol-call-returning`
|
||||
|
||||
#### 3.2 SELECT OPTIONAL flag preservation
|
||||
- Store `isOptional: boolean` in FileDeclaration interface
|
||||
- Include in Record node description
|
||||
|
||||
#### 3.3 ALTERNATE RECORD KEY extraction
|
||||
- Add regex in parseSelectStatement: `/\bALTERNATE\s+RECORD\s+KEY\s+(?:IS\s+)?([A-Z][A-Z0-9-]+)/i`
|
||||
- Store as `alternateKeys?: string[]`
|
||||
|
||||
#### 3.4 COMMON attribute on nested programs
|
||||
- Extend RE_PROGRAM_ID: `/\bPROGRAM-ID\.\s*([A-Z][A-Z0-9-]+)(?:\s+IS\s+COMMON)?/i`
|
||||
- Store `isCommon: boolean` on Module node
|
||||
- Affects cross-program CALL resolution scope
|
||||
|
||||
#### 3.5 IS EXTERNAL / IS GLOBAL as first-class properties
|
||||
- Change from usage string hack to proper boolean fields on data items
|
||||
- Add `isExternal?: boolean`, `isGlobal?: boolean` to data item interface
|
||||
|
||||
#### 3.6 AUTHOR / DATE-WRITTEN mapped to Module node
|
||||
- Already extracted as programMetadata — map to Module node properties
|
||||
- `graph.addNode({ ..., properties: { ..., author, dateWritten } })`
|
||||
|
||||
#### 3.7 REPLACE statement
|
||||
- Track REPLACE / REPLACE OFF state in preprocessor
|
||||
- Apply text substitutions during preprocessing (before regex extraction)
|
||||
- Complex: requires careful scoping rules
|
||||
|
||||
### Phase 4: Niche Features (~30 LOC)
|
||||
|
||||
Low-priority but nice for completeness.
|
||||
|
||||
#### 4.1 INITIALIZE statement -> write ACCESSES
|
||||
- `/\bINITIALIZE\s+([A-Z][A-Z0-9-]+)/i`
|
||||
- ACCESSES write edge with reason `cobol-initialize`
|
||||
|
||||
#### 4.2 Remaining IDENTIFICATION DIVISION paragraphs
|
||||
- DATE-COMPILED, INSTALLATION, SECURITY, REMARKS
|
||||
- Map to Module node description properties
|
||||
|
||||
#### 4.3 EXEC SQL INCLUDE -> IMPORTS edge (expansion)
|
||||
- For EXEC SQL INCLUDE inside EXEC blocks that reference copybooks containing SQL
|
||||
- Create IMPORTS edge similar to COPY
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Phase 1: All 5 features implemented with unit + integration tests
|
||||
- [ ] Phase 2: All 4 features implemented with unit + integration tests
|
||||
- [ ] Phase 3: All 7 partial features fixed
|
||||
- [ ] Phase 4: At least 2 of 3 niche features implemented
|
||||
- [ ] All existing 145 tests continue to pass
|
||||
- [ ] TypeScript compiles cleanly
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] No performance regression: CardDemo benchmark stays under 8s
|
||||
- [ ] No file exceeds 1500 LOC (preprocessor currently 1326)
|
||||
- [ ] ACAS benchmark shows increased node/edge counts (more data extracted)
|
||||
- [ ] CardDemo benchmark shows increased edge counts (CALL USING, STRING, etc.)
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Each phase has its own commit
|
||||
- [ ] Integration test assertions updated with exact counts per phase
|
||||
- [ ] Benchmark run after each phase to track graph growth
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
### Dependencies
|
||||
- None. All changes are additive to existing COBOL processor code.
|
||||
- No LanguageProvider changes needed.
|
||||
- No graph schema changes needed (all new constructs map to existing node labels + edge types).
|
||||
|
||||
### Risks
|
||||
- **preprocessor.ts size**: Currently 1326 LOC. Phase 1+2 adds ~200 LOC -> 1526 LOC. May need to extract helpers into a separate `cobol-data-flow.ts` module if it exceeds 1500.
|
||||
- **REPLACE statement** (Phase 3.7) is the most complex feature — requires tracking text substitution state across logical lines. Consider deferring to a separate PR if it takes >100 LOC.
|
||||
- **EXEC DLI** (Phase 2.1) is only testable against IMS codebases. Need fixture data or synthetic test cases.
|
||||
|
||||
## Graph Value Ranking by MCP Tool Impact
|
||||
|
||||
Research agent analyzed all 5 MCP tools (query, context, impact, detect_changes, rename) against planned edge types:
|
||||
|
||||
| Edge Type | QUERY | CONTEXT | IMPACT | DETECT | RENAME | **Overall** |
|
||||
|-----------|-------|---------|--------|--------|--------|-------------|
|
||||
| `cobol-call-using` | 4/5 | 5/5 | 5/5 | 4/5 | 4/5 | **9.2/10** |
|
||||
| `cobol-error-handler` | 5/5 | 4/5 | 5/5 | 5/5 | 2/5 | **9.0/10** |
|
||||
| `dli-*` (IMS verbs) | 4/5 | 4/5 | 5/5 | 4/5 | 2/5 | **8.2/10** |
|
||||
| `cobol-string-*` | 4/5 | 3/5 | 3/5 | 3/5 | 2/5 | **6.2/10** |
|
||||
|
||||
**Key finding**: `cobol-call-using` alone would fix ~40% of missing caller references in COBOL graphs.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
This plan provides the graph data foundation for a future `modernize` MCP command (out of scope) that would:
|
||||
- Use CALL USING edges to map data contracts between programs
|
||||
- Use STRING/UNSTRING edges to identify data transformation logic
|
||||
- Use EXEC SQL/DLI edges to map database access patterns
|
||||
- Use DECLARATIVES to understand error handling architecture
|
||||
- Use the complete knowledge graph to generate migration plans
|
||||
|
||||
**MCP tool enhancements needed** (after this plan ships):
|
||||
- Add `cobol-call-using`, `cobol-error-handler`, `dli-*` to IMPACT tool's default `relationTypes` for COBOL repos
|
||||
- Add confidence floors for new edge types in `IMPACT_RELATION_CONFIDENCE`
|
||||
- Register new edge types in `VALID_RELATION_TYPES` set (`local-backend.ts:52`)
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Internal References
|
||||
- Feature audit: session 8642401e (COBOL expert agent, 123 features audited)
|
||||
- Prior plans: `docs/plans/2026-03-25-feat-cobol-100-percent-feature-coverage-plan.md`
|
||||
- Architecture: `docs/code-indexing/cobol/` (7 documentation files)
|
||||
|
||||
### External References
|
||||
- COBOL features reference: mainframestechhelp.com/tutorials/cobol/features.htm
|
||||
- COBOL-85 standard: ISO/IEC 1989:1985
|
||||
- IBM Enterprise COBOL reference
|
||||
@@ -1,725 +0,0 @@
|
||||
# PR #626 HIGH-Priority Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
|
||||
|
||||
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
|
||||
|
||||
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Path Traversal — Validate Group Name
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
|
||||
- Test: `gitnexus/test/unit/group/storage.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for validateGroupName**
|
||||
|
||||
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
|
||||
|
||||
```typescript
|
||||
describe('validateGroupName', () => {
|
||||
it('test_validateGroupName_traversal_path_throws', () => {
|
||||
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_slash_in_name_throws', () => {
|
||||
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_empty_string_throws', () => {
|
||||
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_dash_throws', () => {
|
||||
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_underscore_throws', () => {
|
||||
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_dots_throws', () => {
|
||||
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_valid_alphanumeric_passes', () => {
|
||||
expect(() => validateGroupName('my-group_01')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_single_char_passes', () => {
|
||||
expect(() => validateGroupName('A')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_all_digits_passes', () => {
|
||||
expect(() => validateGroupName('123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupDir rejects invalid names', () => {
|
||||
it('test_getGroupDir_traversal_throws', () => {
|
||||
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_getGroupDir_valid_name_returns_path', () => {
|
||||
const dir = getGroupDir(tmpDir, 'company');
|
||||
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroupDir rejects invalid names', () => {
|
||||
it('test_createGroupDir_traversal_throws', async () => {
|
||||
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
|
||||
|
||||
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
|
||||
|
||||
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
|
||||
|
||||
```typescript
|
||||
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export function validateGroupName(name: string): void {
|
||||
if (!GROUP_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
||||
validateGroupName(groupName);
|
||||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
```
|
||||
|
||||
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
|
||||
git commit -m "fix(group): validate group name to prevent path traversal
|
||||
|
||||
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
|
||||
Called in getGroupDir (defense in depth) which covers all CLI entry
|
||||
points: create, add, remove, status, sync.
|
||||
|
||||
Addresses PR #626 review item 1 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
|
||||
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for excluded directories**
|
||||
|
||||
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_detect_skips_vendor_directory', async () => {
|
||||
writeFile('services/auth/package.json', '{}');
|
||||
writeFile('services/auth/src/index.ts', '');
|
||||
// vendor should be skipped — its contents should not create a boundary
|
||||
writeFile('vendor/some-dep/package.json', '{}');
|
||||
writeFile('vendor/some-dep/src/lib.go', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/auth');
|
||||
expect(paths).not.toContain('vendor/some-dep');
|
||||
});
|
||||
|
||||
it('test_detect_skips_target_directory', async () => {
|
||||
writeFile('services/api/go.mod', 'module api');
|
||||
writeFile('services/api/main.go', '');
|
||||
writeFile('target/classes/Main.java', '');
|
||||
writeFile('target/pom.xml', '<project/>');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('target');
|
||||
});
|
||||
|
||||
it('test_detect_skips_pycache_directory', async () => {
|
||||
writeFile('services/ml/pyproject.toml', '[project]');
|
||||
writeFile('services/ml/model.py', '');
|
||||
// __pycache__ with a marker + source files — would be detected as
|
||||
// a boundary if not excluded, since it has package.json + .py file
|
||||
writeFile('__pycache__/package.json', '{}');
|
||||
writeFile('__pycache__/cached.py', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/ml');
|
||||
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
|
||||
});
|
||||
|
||||
it('test_detect_skips_dotfile_directories_regression', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
writeFile('.hidden/package.json', '{}');
|
||||
writeFile('.hidden/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('.hidden');
|
||||
});
|
||||
|
||||
it('test_detect_does_not_skip_regular_source_directories', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
expect(boundaries).toHaveLength(1);
|
||||
expect(boundaries[0].serviceName).toBe('api');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
|
||||
|
||||
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
|
||||
|
||||
In `gitnexus/src/core/group/service-boundary-detector.ts`:
|
||||
|
||||
After `SOURCE_EXTENSIONS` (after line 51), add:
|
||||
|
||||
```typescript
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'target',
|
||||
'build',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.tox',
|
||||
'.mypy_cache',
|
||||
'.gradle',
|
||||
'.mvn',
|
||||
'out',
|
||||
'bin',
|
||||
]);
|
||||
```
|
||||
|
||||
In `walkForBoundaries`, replace line 78:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
```
|
||||
|
||||
In `hasSourceFilesInSubdirs`, replace line 130:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
|
||||
git commit -m "fix(group): add directory exclusions to service boundary detector
|
||||
|
||||
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
|
||||
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
|
||||
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
|
||||
Replaces inline node_modules check.
|
||||
|
||||
Addresses PR #626 review item 3 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
|
||||
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
|
||||
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
|
||||
|
||||
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
|
||||
|
||||
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
|
||||
|
||||
```typescript
|
||||
it('test_syncGroup_closes_only_opened_pools', async () => {
|
||||
const config = makeConfig({
|
||||
'app/backend': 'backend-repo',
|
||||
'app/frontend': 'frontend-repo',
|
||||
});
|
||||
|
||||
const closedIds: string[] = [];
|
||||
|
||||
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
|
||||
const { vi } = await import('vitest');
|
||||
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
||||
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
|
||||
if (id) closedIds.push(id);
|
||||
});
|
||||
|
||||
try {
|
||||
await syncGroup(config, {
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: groupPath.replace(/\//g, '-'),
|
||||
path: groupPath,
|
||||
repoPath: '/tmp/' + groupPath,
|
||||
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
||||
}),
|
||||
skipWrite: true,
|
||||
}).catch(() => {});
|
||||
// Regardless of extraction errors, closeLbug should be called per id
|
||||
// closeLbug should only receive specific pool ids, never undefined/empty
|
||||
for (const id of closedIds) {
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe('string');
|
||||
}
|
||||
// No blanket close (no-arg call)
|
||||
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
|
||||
expect(blanketCalls).toHaveLength(0);
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
closeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
|
||||
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
|
||||
|
||||
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
|
||||
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
|
||||
const source = fs.readFileSync(cliGroupPath, 'utf-8');
|
||||
|
||||
// closeLbug() without arguments (blanket close) must not appear.
|
||||
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
|
||||
// Match closeLbug() but not closeLbug(someArg)
|
||||
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
|
||||
expect(source).not.toMatch(blanketClosePattern);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
|
||||
|
||||
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
|
||||
|
||||
In `gitnexus/src/cli/group.ts`:
|
||||
|
||||
Remove the `closeLbug` import at line 160:
|
||||
```typescript
|
||||
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
|
||||
```
|
||||
|
||||
Replace the try/finally wrapper (lines 162-189):
|
||||
```typescript
|
||||
try {
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
|
||||
```typescript
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
|
||||
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
|
||||
|
||||
sync.ts already closes pools per-id in its finally block.
|
||||
The blanket closeLbug() in cli/group.ts tears down ALL active pools
|
||||
including unrelated ones in MCP server context.
|
||||
|
||||
Addresses PR #626 review item 4 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: gRPC Proto Regex — Brace-Depth Counter
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
|
||||
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for nested braces in proto services**
|
||||
|
||||
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
|
||||
writeFile(
|
||||
'api/gateway.proto',
|
||||
`syntax = "proto3";
|
||||
package gateway.v1;
|
||||
|
||||
import "google/api/annotations.proto";
|
||||
|
||||
service GatewayService {
|
||||
rpc GetUser (GetUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/v1/users/{user_id}"
|
||||
};
|
||||
}
|
||||
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/users"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(2);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::gateway.v1.GatewayService/CreateUser',
|
||||
'grpc::gateway.v1.GatewayService/GetUser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_multiple_services', async () => {
|
||||
writeFile(
|
||||
'api/multi.proto',
|
||||
`syntax = "proto3";
|
||||
package multi;
|
||||
|
||||
service ServiceA {
|
||||
rpc MethodA (Req) returns (Res);
|
||||
}
|
||||
|
||||
service ServiceB {
|
||||
rpc MethodB1 (Req) returns (Res);
|
||||
rpc MethodB2 (Req) returns (Res);
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::multi.ServiceA/MethodA',
|
||||
'grpc::multi.ServiceB/MethodB1',
|
||||
'grpc::multi.ServiceB/MethodB2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
|
||||
writeFile(
|
||||
'api/nested.proto',
|
||||
`syntax = "proto3";
|
||||
package nested;
|
||||
|
||||
service DeepService {
|
||||
rpc DeepMethod (Req) returns (Res) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/deep"
|
||||
body: "*"
|
||||
additional_bindings {
|
||||
get: "/v1/deep/{id}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
|
||||
});
|
||||
|
||||
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
|
||||
writeFile(
|
||||
'api/broken.proto',
|
||||
`syntax = "proto3";
|
||||
package broken;
|
||||
|
||||
service IncompleteService {
|
||||
rpc SomeMethod (Req) returns (Res);
|
||||
// Missing closing brace — EOF before depth returns to 0
|
||||
`,
|
||||
);
|
||||
|
||||
// Should not throw; incomplete service is silently skipped
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
|
||||
);
|
||||
|
||||
// The old regex would find partial match; the new parser should skip it
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the nested brace test fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
|
||||
|
||||
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
|
||||
|
||||
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
|
||||
|
||||
```typescript
|
||||
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
|
||||
const pkg = pkgMatch ? pkgMatch[1] : '';
|
||||
|
||||
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
|
||||
const rpcRe = /rpc\s+(\w+)\s*\(/g;
|
||||
let rpcMatch: RegExpExecArray | null;
|
||||
while ((rpcMatch = rpcRe.exec(body)) !== null) {
|
||||
const methodName = rpcMatch[1];
|
||||
const cid = contractId(pkg, serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
|
||||
package: pkg,
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
source: 'proto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
|
||||
|
||||
```typescript
|
||||
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
const headerRe = /service\s+(\w+)\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(content)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < content.length && depth > 0) {
|
||||
const ch = content[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
// If EOF before depth returns to 0, skip incomplete service
|
||||
if (depth !== 0) continue;
|
||||
|
||||
// body is between opening { (consumed by regex) and closing } (pos is one past it)
|
||||
const body = content.slice(bodyStart, pos - 1);
|
||||
results.push({ name: serviceName, body });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: ALL PASS (including existing regression tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
|
||||
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
|
||||
|
||||
The serviceRe regex used [^}]* which stopped at the first '}'.
|
||||
Proto services with google.api.http annotations contain nested {}
|
||||
blocks, causing methods to be missed.
|
||||
|
||||
New extractServiceBlocks() uses a brace-depth counter (init depth=1
|
||||
after opening {, scan char-by-char). Malformed protos with unclosed
|
||||
braces are silently skipped.
|
||||
|
||||
Addresses PR #626 review item 2 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run Full Test Suite
|
||||
|
||||
- [ ] **Step 1: Run all group-related tests**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 2: Run full test suite to catch regressions**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run`
|
||||
Expected: ALL PASS, 0 failures
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `cd gitnexus && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
---
|
||||
|
||||
### Task 6: CLI Integration Smoke Test
|
||||
|
||||
- [ ] **Step 1: Add CLI smoke test for path traversal**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
|
||||
|
||||
```typescript
|
||||
it('test_create_with_invalid_name_fails', () => {
|
||||
const result = runGroup(['create', '../../evil']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Invalid group name');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add test/integration/group/group-cli.test.ts
|
||||
git commit -m "test(group): add CLI smoke test for path traversal rejection
|
||||
|
||||
Verifies that 'group create ../../evil' fails with Invalid group name.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
@@ -1,175 +0,0 @@
|
||||
# PR #626 HIGH-Priority Fixes Design
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
|
||||
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
|
||||
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: Path Traversal via Group Name
|
||||
|
||||
**File:** `gitnexus/src/core/group/storage.ts`
|
||||
**Risk:** A group name like `../../etc` creates directories outside the intended path.
|
||||
|
||||
### Solution
|
||||
|
||||
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
|
||||
|
||||
- Call in `createGroupDir` (primary entry point)
|
||||
- Call in `getGroupDir` (defense in depth)
|
||||
- Throw descriptive error on invalid names
|
||||
|
||||
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
|
||||
|
||||
### Why regex over path.resolve + startsWith
|
||||
|
||||
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
|
||||
- Stricter: disallows spaces, dots, Unicode edge cases
|
||||
- Simpler to reason about
|
||||
|
||||
### Tests
|
||||
|
||||
- `../../evil` throws
|
||||
- `foo/bar` throws
|
||||
- Empty string throws
|
||||
- `my-group_01` passes
|
||||
- `A` (single char) passes
|
||||
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
|
||||
|
||||
### CLI/API entry points accepting groupName
|
||||
|
||||
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
|
||||
|
||||
| Command | Entry | Calls |
|
||||
|---------|-------|-------|
|
||||
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
|
||||
| `group add` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group remove` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
|
||||
| `group status` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group sync` | `cli/group.ts` action | `getGroupDir` |
|
||||
|
||||
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
|
||||
|
||||
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
|
||||
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
|
||||
|
||||
### Solution
|
||||
|
||||
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
|
||||
|
||||
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
|
||||
2. Initialise depth to 1 immediately after the opening `{`
|
||||
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
|
||||
4. Stop when depth reaches 0 (the matching closing `}`)
|
||||
5. Return name + body pairs
|
||||
|
||||
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
|
||||
|
||||
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
|
||||
|
||||
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
|
||||
|
||||
### Tests
|
||||
|
||||
- Proto with single service, no nesting (regression)
|
||||
- Proto with `google.api.http` nested braces inside RPC options
|
||||
- Proto with multiple services
|
||||
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
|
||||
- Malformed proto with unclosed brace (graceful handling)
|
||||
|
||||
---
|
||||
|
||||
## Fix 3: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
|
||||
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
|
||||
|
||||
### Solution
|
||||
|
||||
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
|
||||
|
||||
```text
|
||||
node_modules, vendor, target, build, dist,
|
||||
__pycache__, .venv, venv, .tox, .mypy_cache,
|
||||
.gradle, .mvn, out, bin
|
||||
```
|
||||
|
||||
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
|
||||
|
||||
Apply in both:
|
||||
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
|
||||
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
|
||||
|
||||
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
|
||||
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
|
||||
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
|
||||
|
||||
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
|
||||
|
||||
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
|
||||
|
||||
### Tests
|
||||
|
||||
- Directory named `vendor/` is skipped
|
||||
- Directory named `target/` is skipped
|
||||
- Directory named `__pycache__/` is skipped
|
||||
- Regular source directories are NOT skipped
|
||||
- Dotfile directories still skipped (regression)
|
||||
|
||||
---
|
||||
|
||||
## Fix 4: Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
|
||||
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
|
||||
|
||||
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
|
||||
|
||||
### Solution
|
||||
|
||||
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
|
||||
|
||||
```typescript
|
||||
// sync.ts — KEEP: cleans up only pools opened by this sync
|
||||
finally {
|
||||
for (const id of [...new Set(openPoolIds)]) {
|
||||
await closeLbug(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// cli/group.ts — REMOVE: blanket close that kills all pools
|
||||
finally {
|
||||
await closeLbug().catch(() => {}); // DELETE THIS
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
|
||||
|
||||
### Tests (unit level — mock pool adapter)
|
||||
|
||||
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
|
||||
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
|
||||
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- JSON -> LadybugDB migration (tracked in #606)
|
||||
- MEDIUM/LOW issues (items 5-10 from review summary)
|
||||
- Test gap coverage beyond what's needed for these 4 fixes
|
||||
- Any refactoring or architectural changes
|
||||
|
||||
## Execution Order
|
||||
|
||||
Fixes are independent — can be implemented in parallel or any order.
|
||||
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
|
||||
@@ -77,6 +77,7 @@ function findCanonicalRepoRoot(cwd) {
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
@@ -200,6 +201,7 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,6 +212,7 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
useDirectBinary = which.status === 0;
|
||||
} catch {
|
||||
@@ -222,6 +225,7 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
// npx fallback needs shell on Windows since npx is a .cmd script
|
||||
@@ -230,6 +234,7 @@ function runGitNexusCli(args, cwd, timeout) {
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -318,6 +323,7 @@ function handlePostToolUse(input) {
|
||||
timeout: 3000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
currentHead = (headResult.stdout || '').trim();
|
||||
} catch {
|
||||
|
||||
@@ -109,6 +109,7 @@ function hasGitNexusServerOwnerWindows(dbPathAbs, myPid) {
|
||||
encoding: 'utf-8',
|
||||
timeout: 6000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, GITNEXUS_HOOK_RM_TARGET: dbPathAbs },
|
||||
},
|
||||
);
|
||||
@@ -192,6 +193,7 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
|
||||
|
||||
@@ -203,6 +205,7 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
|
||||
encoding: 'utf-8',
|
||||
timeout: 500,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (ps.error) {
|
||||
if (ps.error.code === 'ETIMEDOUT') return true;
|
||||
|
||||
@@ -58,6 +58,7 @@ function findCanonicalRepoRoot(cwd) {
|
||||
timeout: 2000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const commonDir = (result.stdout || '').trim();
|
||||
@@ -201,6 +202,7 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
timeout,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
|
||||
@@ -208,6 +210,7 @@ function runGitNexusCli(cliPath, args, cwd, timeout) {
|
||||
timeout: timeout + 5000,
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js';
|
||||
|
||||
// Language support
|
||||
export { SupportedLanguages } from './languages.js';
|
||||
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
|
||||
export {
|
||||
getLanguageFromFilename,
|
||||
getSyntaxLanguageFromFilename,
|
||||
isBladeTemplateFilename,
|
||||
} from './language-detection.js';
|
||||
export type { MroStrategy } from './mro-strategy.js';
|
||||
|
||||
// Pipeline progress
|
||||
|
||||
@@ -56,11 +56,21 @@ for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel Blade templates are source templates whose filename convention ends
|
||||
* in `.blade.php`. They may contain PHP snippets, but the full file is not a
|
||||
* pure PHP translation unit and must not enter the generic PHP provider path.
|
||||
*/
|
||||
export const isBladeTemplateFilename = (filePath: string): boolean =>
|
||||
filePath.replace(/\\/g, '/').toLowerCase().endsWith('.blade.php');
|
||||
|
||||
/**
|
||||
* Map file extension to SupportedLanguage enum.
|
||||
* Returns null if the file extension is not recognized.
|
||||
*/
|
||||
export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => {
|
||||
if (isBladeTemplateFilename(filename)) return null;
|
||||
|
||||
// Fast path: check the extension map
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot >= 0) {
|
||||
@@ -138,6 +148,8 @@ const AUXILIARY_BASENAME_MAP: Record<string, string> = {
|
||||
* Returns 'text' for unrecognised files.
|
||||
*/
|
||||
export const getSyntaxLanguageFromFilename = (filePath: string): string => {
|
||||
if (isBladeTemplateFilename(filePath)) return 'markup';
|
||||
|
||||
const lang = getLanguageFromFilename(filePath);
|
||||
if (lang) return SYNTAX_MAP[lang];
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
@@ -10,6 +10,7 @@ export type PipelinePhase =
|
||||
| 'imports'
|
||||
| 'calls'
|
||||
| 'heritage'
|
||||
| 'scopeResolution'
|
||||
| 'communities'
|
||||
| 'processes'
|
||||
| 'enriching'
|
||||
|
||||
@@ -53,6 +53,10 @@ export interface SymbolDefinition {
|
||||
* `ScopeResolver.constraintCompatibility` hook during overload narrowing.
|
||||
* Absent for symbols that have no constraints (the common case). */
|
||||
templateConstraints?: unknown;
|
||||
/** True when the producing language marked this callable as explicit.
|
||||
* Currently used by C++ overload ranking to exclude explicit constructors
|
||||
* from implicit user-defined conversion candidates. */
|
||||
isExplicit?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
@@ -267,8 +267,11 @@ export interface ScopeLookup {
|
||||
|
||||
/** Call-site description passed to `arityCompatibility`. */
|
||||
export interface Callsite {
|
||||
/** Number of arguments at the call site. */
|
||||
readonly arity: number;
|
||||
/** Number of arguments at the call site, if available. */
|
||||
readonly arity?: number;
|
||||
/** Inferred argument types at the call site, one per argument.
|
||||
* An empty string entry means the type was not inferred. */
|
||||
readonly argumentTypes?: readonly string[];
|
||||
}
|
||||
|
||||
// ─── §2.4 ImportEdge ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const BACKEND_URL = 'http://localhost:4747';
|
||||
const REPO_NAME = 'mock-repo';
|
||||
|
||||
async function mockBackend(page: Page) {
|
||||
const repo = {
|
||||
name: REPO_NAME,
|
||||
path: '/tmp/mock-repo',
|
||||
repoPath: '/tmp/mock-repo',
|
||||
indexedAt: new Date().toISOString(),
|
||||
stats: { files: 1, nodes: 0, edges: 0, processes: 0 },
|
||||
};
|
||||
|
||||
await page.route(
|
||||
(url) => url.origin === BACKEND_URL && url.pathname === '/api/repos',
|
||||
(route) => route.fulfill({ json: [repo] }),
|
||||
);
|
||||
await page.route(
|
||||
(url) => url.origin === BACKEND_URL && url.pathname === '/api/repo',
|
||||
(route) => route.fulfill({ json: repo }),
|
||||
);
|
||||
await page.route(
|
||||
(url) => url.origin === BACKEND_URL && url.pathname === '/api/graph',
|
||||
(route) => route.fulfill({ json: { nodes: [], relationships: [] } }),
|
||||
);
|
||||
await page.route(
|
||||
(url) => url.origin === BACKEND_URL && url.pathname === '/api/heartbeat',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
body: ':ok\n\n',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function enterExploringView(page: Page) {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-testid="landing-repo-card"]').first().click();
|
||||
await expect(page.getByTestId('language-switcher')).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
test.describe('language switching', () => {
|
||||
test('switches Header language, updates document metadata, and persists after reload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockBackend(page);
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => window.localStorage.clear());
|
||||
|
||||
await enterExploringView(page);
|
||||
|
||||
await page.getByTestId('language-switcher').selectOption('zh-CN');
|
||||
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN');
|
||||
await expect(page.getByText('觉得不错就点星')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.localStorage.getItem('gitnexus.lng')))
|
||||
.toBe('zh-CN');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByTestId('language-switcher')).toHaveValue('zh-CN', { timeout: 20_000 });
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'zh-CN');
|
||||
await expect(page.getByText('觉得不错就点星')).toBeVisible();
|
||||
|
||||
await page.getByTestId('language-switcher').selectOption('en');
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for graph layout mode switching (Sequential / Radial layouts).
|
||||
*
|
||||
* Requires:
|
||||
* - gitnexus serve running on localhost:4747 with at least one indexed repo
|
||||
* - gitnexus-web dev server running on localhost:5173
|
||||
*
|
||||
* Skipped when servers aren't available (CI without services, etc.).
|
||||
* Set E2E=1 to force-run even without the availability check.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available on :4747');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available on :5173');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos — run gitnexus analyze first');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForGraphLoaded(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${FRONTEND_URL}?lng=en`);
|
||||
|
||||
// The app starts on the landing/onboarding screen. Pick the first repo card
|
||||
// (preferring a known repo name) and click it to load the graph.
|
||||
const landingCards = page.locator('[data-testid="landing-repo-card"]');
|
||||
const preferredCard = landingCards.filter({ hasText: /GitNexus|local-integration/ }).first();
|
||||
try {
|
||||
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
|
||||
const card = (await preferredCard.count()) > 0 ? preferredCard : landingCards.first();
|
||||
await card.click();
|
||||
} catch {
|
||||
// Landing screen may not appear (e.g. when ?server auto-connects)
|
||||
}
|
||||
|
||||
// Wait until the status bar confirms the graph is ready.
|
||||
const statusBar = page.getByRole('contentinfo');
|
||||
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
|
||||
await expect(statusBar).toContainText(/nodes/, { timeout: 20_000 });
|
||||
|
||||
// Finally confirm the sigma canvas is present.
|
||||
await page.waitForSelector('.sigma-container', { timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe('Graph Layout Modes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await waitForGraphLoaded(page);
|
||||
});
|
||||
|
||||
test('should switch between force, sequential, and radial layouts', async ({ page }) => {
|
||||
const forceTab = page.locator('button:has-text("Force Graph")');
|
||||
const sequentialTab = page.locator('button:has-text("Sequential Layout")');
|
||||
const radialTab = page.locator('button:has-text("Radial Layout")');
|
||||
|
||||
// Force Graph is the default active tab
|
||||
await expect(forceTab).toHaveClass(/bg-accent/);
|
||||
await expect(sequentialTab).not.toHaveClass(/bg-accent/);
|
||||
|
||||
// Switch to Sequential Layout
|
||||
await sequentialTab.click();
|
||||
await expect(sequentialTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
|
||||
await expect(forceTab).not.toHaveClass(/bg-accent/);
|
||||
|
||||
// All three layout tabs should be present in the tab bar
|
||||
await expect(radialTab).toBeVisible();
|
||||
|
||||
// Switch back to Force Graph
|
||||
await forceTab.click();
|
||||
await expect(forceTab).toHaveClass(/bg-accent/, { timeout: 5_000 });
|
||||
await expect(sequentialTab).not.toHaveClass(/bg-accent/);
|
||||
});
|
||||
|
||||
test('should interact with nodes in sequential layout', async ({ page }) => {
|
||||
await page.locator('button:has-text("Sequential Layout")').click();
|
||||
|
||||
// Click the first file-tree item in the sidebar (more reliable than a
|
||||
// blind canvas click, which may land on empty space). The FileTreePanel
|
||||
// renders node names as <span class="truncate font-mono text-xs">.
|
||||
// Clicking any of them calls setSelectedNode, which shows the selection
|
||||
// bar with the "Clear" button — the same mechanism used in
|
||||
// server-connect.spec.ts's "Turn Off All Highlights" test.
|
||||
const firstTreeItem = page.locator('span.truncate.font-mono').first();
|
||||
await firstTreeItem.waitFor({ state: 'visible', timeout: 10_000 });
|
||||
await firstTreeItem.click();
|
||||
|
||||
await expect(page.locator('text=Clear')).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
Generated
+624
-436
File diff suppressed because it is too large
Load Diff
+26
-10
@@ -18,24 +18,26 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.29",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.30",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.16.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"axios": "^1.16.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"dompurify": "^3.4.3",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
"graphology-layout-forceatlas2": "^0.10.1",
|
||||
"graphology-layout-noverlap": "^0.4.2",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.3.5",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lucide-react": "^1.14.0",
|
||||
@@ -44,27 +46,28 @@
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "^1.60.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": "^25.6.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vercel/node": "^5.8.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
@@ -73,5 +76,18 @@
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/static-config": {
|
||||
"ajv": "8.18.0"
|
||||
},
|
||||
"@vercel/node": {
|
||||
"path-to-regexp": "6.3.0",
|
||||
"undici": "6.24.0"
|
||||
},
|
||||
"@vercel/python-analysis": {
|
||||
"minimatch": "10.2.3",
|
||||
"smol-toml": "1.6.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-12
@@ -21,8 +21,11 @@ import {
|
||||
type BackendRepo,
|
||||
} from './services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
|
||||
import { formatBackendError } from './i18n/error-messages';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AppContent = () => {
|
||||
const { t } = useTranslation(['common', 'errors']);
|
||||
const {
|
||||
viewMode,
|
||||
setViewMode,
|
||||
@@ -54,7 +57,6 @@ const AppContent = () => {
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
// Use the canonical repo name from the server response so all subsequent
|
||||
// backend calls (queries, search, grep, readFile) scope to this repo.
|
||||
const repoName = result.repoInfo.name;
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
// Normalize both Windows (\) and Unix (/) path separators before splitting
|
||||
const projectName =
|
||||
@@ -104,6 +106,11 @@ const AppContent = () => {
|
||||
|
||||
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
|
||||
const autoConnectRan = useRef(false);
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => {
|
||||
tRef.current = t;
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -116,8 +123,8 @@ const AppContent = () => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
message: tRef.current('common:progress.connecting'),
|
||||
detail: tRef.current('common:progress.validatingServer'),
|
||||
});
|
||||
setViewMode('loading');
|
||||
|
||||
@@ -132,8 +139,8 @@ const AppContent = () => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
message: tRef.current('common:progress.connecting'),
|
||||
detail: tRef.current('common:progress.validatingServer'),
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
@@ -141,15 +148,15 @@ const AppContent = () => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
message: tRef.current('common:progress.downloadingGraph'),
|
||||
detail: tRef.current('common:progress.downloadedMb', { mb }),
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
message: tRef.current('common:progress.processing'),
|
||||
detail: tRef.current('common:progress.extractingFileContents'),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -173,8 +180,8 @@ const AppContent = () => {
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Failed to connect to server',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
message: tRef.current('errors:connectFailed'),
|
||||
detail: formatBackendError(err, tRef.current),
|
||||
});
|
||||
setTimeout(() => {
|
||||
setViewMode('onboarding');
|
||||
@@ -299,7 +306,7 @@ const AppContent = () => {
|
||||
|
||||
{serverDisconnected && (
|
||||
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
|
||||
Server connection lost — reconnecting…
|
||||
{t('errors:backend.reconnecting')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
import { Sparkles, Github } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface AnalyzeOnboardingProps {
|
||||
/** Called when analysis finishes and the repo is ready to load. */
|
||||
@@ -24,6 +25,8 @@ interface AnalyzeOnboardingProps {
|
||||
}
|
||||
|
||||
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
|
||||
@@ -47,11 +50,10 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Analyze your first repository
|
||||
{t('analyzeFirst.title')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live
|
||||
knowledge graph — right in your browser.
|
||||
{t('analyzeFirst.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +65,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
|
||||
Public repos only · Cloned locally by the server · No data leaves your machine
|
||||
{t('analyzeFirst.footer')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from '@/lib/lucide-icons';
|
||||
import type { JobProgress as AnalyzeJobProgress } from '../services/backend-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateAnalyzePhase } from '../i18n/progress';
|
||||
|
||||
interface AnalyzeProgressProps {
|
||||
progress: AnalyzeJobProgress;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
queued: 'Queued',
|
||||
cloning: 'Cloning repository',
|
||||
pulling: 'Pulling latest',
|
||||
extracting: 'Scanning files',
|
||||
structure: 'Building structure',
|
||||
parsing: 'Parsing code',
|
||||
imports: 'Resolving imports',
|
||||
calls: 'Tracing calls',
|
||||
heritage: 'Extracting inheritance',
|
||||
communities: 'Detecting communities',
|
||||
processes: 'Detecting processes',
|
||||
complete: 'Pipeline complete',
|
||||
lbug: 'Loading into database',
|
||||
fts: 'Creating search indexes',
|
||||
embeddings: 'Generating embeddings',
|
||||
done: 'Done',
|
||||
retrying: 'Retrying after crash',
|
||||
};
|
||||
|
||||
export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [startTime] = useState(() => Date.now());
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
@@ -38,11 +21,11 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
|
||||
|
||||
const formatElapsed = (ms: number) => {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
if (s < 60) return t('units.elapsedSeconds', { seconds: s });
|
||||
return t('units.elapsedMinutesSeconds', { minutes: Math.floor(s / 60), seconds: s % 60 });
|
||||
};
|
||||
|
||||
const label = PHASE_LABELS[progress.phase] || progress.message || progress.phase;
|
||||
const label = translateAnalyzePhase(progress.phase, progress.message, t);
|
||||
const pct = Math.max(0, Math.min(100, progress.percent));
|
||||
|
||||
return (
|
||||
@@ -69,7 +52,7 @@ export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) =>
|
||||
className="flex items-center gap-1.5 rounded-lg bg-red-500/10 px-3 py-1.5 text-xs text-red-400 transition-all duration-200 hover:bg-red-500/20"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
{t('actions.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useAppState } from '../hooks/useAppState';
|
||||
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { NODE_COLORS } from '../lib/constants';
|
||||
import { readFile, type ReadFileResult } from '../services/backend-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const getSyntaxLanguage = (filePath: string | undefined): string => {
|
||||
if (!filePath) return 'text';
|
||||
@@ -46,6 +47,7 @@ export interface CodeReferencesPanelProps {
|
||||
}
|
||||
|
||||
export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const {
|
||||
graph,
|
||||
selectedNode,
|
||||
@@ -294,14 +296,14 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="rounded p-2 text-text-secondary transition-colors hover:bg-cyan-500/10 hover:text-cyan-400"
|
||||
title="Expand Code Panel"
|
||||
title={t('graph:codePanel.expand')}
|
||||
>
|
||||
<PanelLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="my-1 h-px w-6 bg-border-subtle" />
|
||||
{showSelectedViewer && (
|
||||
<div className="rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-amber-400">
|
||||
SELECTED
|
||||
{t('graph:codePanel.selected')}
|
||||
</div>
|
||||
)}
|
||||
{showCitations && (
|
||||
@@ -325,20 +327,22 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<div
|
||||
onMouseDown={startResize}
|
||||
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent transition-colors hover:bg-cyan-500/25"
|
||||
title="Drag to resize"
|
||||
title={t('graph:codePanel.dragResize')}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-cyan-400" />
|
||||
<span className="text-sm font-semibold text-text-primary">Code Inspector</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{t('graph:codePanel.title')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{showCitations && (
|
||||
<button
|
||||
onClick={() => clearCodeReferences()}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400"
|
||||
title="Clear AI citations"
|
||||
title={t('graph:codePanel.clearCitations')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -346,7 +350,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Collapse Panel"
|
||||
title={t('common:actions.collapse')}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -361,7 +365,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/15 px-2 py-0.5">
|
||||
<MousePointerClick className="h-3 w-3 text-amber-400" />
|
||||
<span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
|
||||
Selected
|
||||
{t('graph:codePanel.selected')}
|
||||
</span>
|
||||
</div>
|
||||
<FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
|
||||
@@ -372,7 +376,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-amber-500/10 hover:text-amber-400"
|
||||
title="Clear selection"
|
||||
title={t('graph:codePanel.clearSelection')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -381,7 +385,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
{isLoadingFile ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading source...</span>
|
||||
<span className="text-sm">{t('graph:codePanel.loadingSource')}</span>
|
||||
</div>
|
||||
) : selectedFileContent ? (
|
||||
<SyntaxHighlighter
|
||||
@@ -420,12 +424,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
{selectedIsFile ? (
|
||||
<>
|
||||
Code not available in memory for{' '}
|
||||
<span className="font-mono">{selectedFilePath}</span>
|
||||
</>
|
||||
<>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })}</>
|
||||
) : (
|
||||
<>Select a file node to preview its contents.</>
|
||||
<>{t('graph:codePanel.selectFile')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -446,11 +447,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-cyan-500/25 bg-cyan-500/15 px-2 py-0.5">
|
||||
<Sparkles className="h-3 w-3 text-cyan-400" />
|
||||
<span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
|
||||
AI Citations
|
||||
{t('graph:codePanel.aiCitations')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-1 text-xs text-text-muted">
|
||||
{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}
|
||||
{t('graph:codePanel.references', { count: aiReferences.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
@@ -483,9 +484,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<span
|
||||
className="mt-0.5 flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase"
|
||||
style={{ backgroundColor: nodeColor, color: '#06060a' }}
|
||||
title={ref.label ?? 'Code'}
|
||||
title={ref.label ?? t('graph:codePanel.code')}
|
||||
>
|
||||
{ref.label ?? 'Code'}
|
||||
{ref.label ?? t('graph:codePanel.code')}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-text-primary">
|
||||
@@ -501,7 +502,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
</span>
|
||||
)}
|
||||
{totalLines > 0 && (
|
||||
<span className="text-text-muted"> • {totalLines} lines</span>
|
||||
<span className="text-text-muted">
|
||||
{' '}
|
||||
• {t('graph:codePanel.lines', { count: totalLines })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -518,7 +522,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
onFocusNode(nodeId);
|
||||
}}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Focus in graph"
|
||||
title={t('common:actions.focusInGraph')}
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -526,7 +530,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
<button
|
||||
onClick={() => removeCodeReference(ref.id)}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Remove"
|
||||
title={t('common:actions.remove')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -572,8 +576,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="px-3 py-3 text-sm text-text-muted">
|
||||
Code not available in memory for{' '}
|
||||
<span className="font-mono">{ref.filePath}</span>
|
||||
{t('graph:codePanel.codeNotAvailable', { path: ref.filePath })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useBackend } from '../hooks/useBackend';
|
||||
import { OnboardingGuide } from './OnboardingGuide';
|
||||
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
|
||||
import { RepoLanding } from './RepoLanding';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatBackendError } from '../i18n/error-messages';
|
||||
|
||||
interface DropZoneProps {
|
||||
onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise<void>;
|
||||
@@ -60,6 +62,8 @@ function Crossfade({ activeKey, children }: { activeKey: string; children: React
|
||||
// ── Phase cards ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SuccessCard() {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-surface p-7"
|
||||
@@ -76,10 +80,10 @@ function SuccessCard() {
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-emerald-400">
|
||||
Server Connected
|
||||
{t('success.title')}
|
||||
</h2>
|
||||
<p className="text-center text-sm leading-relaxed text-text-secondary">
|
||||
Preparing your code knowledge graph...
|
||||
{t('success.description')}
|
||||
</p>
|
||||
|
||||
{/* Subtle progress hint */}
|
||||
@@ -100,6 +104,8 @@ function SuccessCard() {
|
||||
}
|
||||
|
||||
function LoadingCard({ message }: { message: string }) {
|
||||
const { t } = useTranslation(['common', 'onboarding']);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-3xl border border-accent/20 bg-surface p-7"
|
||||
@@ -116,10 +122,10 @@ function LoadingCard({ message }: { message: string }) {
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-center text-lg font-semibold text-text-primary">
|
||||
{message || 'Connecting...'}
|
||||
{message || t('common:progress.connectingShort')}
|
||||
</h2>
|
||||
<p className="text-center text-sm leading-relaxed text-text-secondary">
|
||||
This may take a moment for large repositories
|
||||
{t('onboarding:loading.largeRepoHint')}
|
||||
</p>
|
||||
|
||||
{/* Decorative sparkle */}
|
||||
@@ -134,6 +140,7 @@ function LoadingCard({ message }: { message: string }) {
|
||||
// ── DropZone ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
const { t } = useTranslation(['common', 'errors']);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Backend polling for server detection
|
||||
@@ -163,7 +170,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
// appropriate screen (landing with repo cards, or analyze for zero repos).
|
||||
const handleAutoConnect = async () => {
|
||||
setPhase('loading');
|
||||
setLoadingMessage('Connecting...');
|
||||
setLoadingMessage(t('common:progress.connectingShort'));
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
@@ -179,8 +186,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
setPhase('landing');
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return;
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect';
|
||||
setError(message);
|
||||
setError(formatBackendError(err, t));
|
||||
setPhase('onboarding');
|
||||
}
|
||||
};
|
||||
@@ -193,7 +199,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
const connectToRepo = (repoName: string) => {
|
||||
autoConnectRan.current = true;
|
||||
setPhase('loading');
|
||||
setLoadingMessage('Loading graph...');
|
||||
setLoadingMessage(t('common:progress.loadingGraph'));
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
@@ -204,13 +210,17 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
detectedBackendUrl,
|
||||
(p, downloaded, total) => {
|
||||
if (p === 'validating') {
|
||||
setLoadingMessage('Validating server...');
|
||||
setLoadingMessage(t('common:progress.validatingServerEllipsis'));
|
||||
} else if (p === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 100) : null;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setLoadingMessage(pct ? `Downloading graph... ${pct}%` : `Downloading... ${mb} MB`);
|
||||
setLoadingMessage(
|
||||
pct
|
||||
? t('common:progress.downloadingWithPercent', { percent: pct })
|
||||
: t('common:progress.downloadingMb', { mb }),
|
||||
);
|
||||
} else if (p === 'extracting') {
|
||||
setLoadingMessage('Processing graph...');
|
||||
setLoadingMessage(t('common:progress.processingGraph'));
|
||||
}
|
||||
},
|
||||
abortController.signal,
|
||||
@@ -221,7 +231,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load graph');
|
||||
setError(formatBackendError(err, t));
|
||||
setPhase(detectedRepos.length > 0 ? 'landing' : 'analyze');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Brain, Loader2, Check, AlertCircle, Zap } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useState } from 'react';
|
||||
import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Embedding status indicator and trigger button
|
||||
* Shows in header when graph is loaded
|
||||
*/
|
||||
export const EmbeddingStatus = () => {
|
||||
const { t } = useTranslation('graph');
|
||||
const { embeddingStatus, embeddingProgress, startEmbeddings, graph, viewMode, serverBaseUrl } =
|
||||
useAppState();
|
||||
|
||||
@@ -63,10 +65,10 @@ export const EmbeddingStatus = () => {
|
||||
<button
|
||||
onClick={() => handleStartEmbeddings()}
|
||||
className="group flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-sm text-text-secondary transition-all hover:border-accent/50 hover:bg-hover hover:text-text-primary"
|
||||
title="Generate embeddings for semantic search"
|
||||
title={t('embedding.generateTitle')}
|
||||
>
|
||||
<Brain className="h-4 w-4 text-node-interface transition-colors group-hover:text-accent" />
|
||||
<span className="hidden sm:inline">Enable Semantic Search</span>
|
||||
<span className="hidden sm:inline">{t('embedding.enable')}</span>
|
||||
<Zap className="h-3 w-3 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -83,7 +85,7 @@ export const EmbeddingStatus = () => {
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-accent/30 bg-surface px-3 py-1.5 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-accent" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-text-secondary">Loading AI model...</span>
|
||||
<span className="text-xs text-text-secondary">{t('embedding.loadingModel')}</span>
|
||||
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
|
||||
@@ -108,7 +110,7 @@ export const EmbeddingStatus = () => {
|
||||
<Loader2 className="h-4 w-4 animate-spin text-node-function" />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-text-secondary">
|
||||
Embedding {processed}/{total} nodes
|
||||
{t('embedding.embeddingNodes', { processed, total })}
|
||||
</span>
|
||||
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
|
||||
<div
|
||||
@@ -126,7 +128,7 @@ export const EmbeddingStatus = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-node-interface/30 bg-surface px-3 py-1.5 text-sm text-text-secondary">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-node-interface" />
|
||||
<span className="text-xs">Creating vector index...</span>
|
||||
<span className="text-xs">{t('embedding.creatingIndex')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,10 +138,10 @@ export const EmbeddingStatus = () => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border border-node-function/30 bg-node-function/10 px-3 py-1.5 text-sm text-node-function"
|
||||
title="Semantic search is ready! Use natural language in the AI chat."
|
||||
title={t('embedding.readyTitle')}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">Semantic Ready</span>
|
||||
<span className="text-xs font-medium">{t('embedding.ready')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,10 +153,10 @@ export const EmbeddingStatus = () => {
|
||||
<button
|
||||
onClick={() => handleStartEmbeddings()}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/20"
|
||||
title="Embedding failed. Click to retry."
|
||||
title={t('embedding.errorTitle')}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-xs">Failed - Retry</span>
|
||||
<span className="text-xs">{t('embedding.failedRetry')}</span>
|
||||
</button>
|
||||
{fallbackDialog}
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Type,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO } from '../lib/constants';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
|
||||
@@ -200,7 +201,10 @@ const getNodeTypeIcon = (label: NodeLabel) => {
|
||||
case 'Import':
|
||||
return FileCode;
|
||||
case 'Variable':
|
||||
case 'Property':
|
||||
return Variable;
|
||||
case 'Const':
|
||||
return Target;
|
||||
default:
|
||||
return Variable;
|
||||
}
|
||||
@@ -211,6 +215,7 @@ interface FileTreePanelProps {
|
||||
}
|
||||
|
||||
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const {
|
||||
graph,
|
||||
visibleLabels,
|
||||
@@ -303,7 +308,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="rounded p-2 text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Expand Panel"
|
||||
title={t('graph:fileTree.expandPanel')}
|
||||
>
|
||||
<PanelLeft className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -314,7 +319,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
setActiveTab('files');
|
||||
}}
|
||||
className={`rounded p-2 transition-colors ${activeTab === 'files' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
||||
title="File Explorer"
|
||||
title={t('graph:fileTree.fileExplorer')}
|
||||
>
|
||||
<Folder className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -324,7 +329,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
setActiveTab('filters');
|
||||
}}
|
||||
className={`rounded p-2 transition-colors ${activeTab === 'filters' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
||||
title="Filters"
|
||||
title={t('graph:fileTree.filters')}
|
||||
>
|
||||
<Filter className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -345,7 +350,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Explorer
|
||||
{t('graph:fileTree.explorer')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('filters')}
|
||||
@@ -355,13 +360,13 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
Filters
|
||||
{t('graph:fileTree.filters')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Collapse Panel"
|
||||
title={t('graph:fileTree.collapsePanel')}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -375,7 +380,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<Search className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
placeholder={t('graph:fileTree.searchFiles')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded border border-border-subtle bg-elevated py-1.5 pr-3 pl-8 text-xs text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none"
|
||||
@@ -386,7 +391,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
{/* File tree */}
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
|
||||
{fileTree.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">No files loaded</div>
|
||||
<div className="px-3 py-4 text-center text-xs text-text-muted">
|
||||
{t('graph:fileTree.noFilesLoaded')}
|
||||
</div>
|
||||
) : (
|
||||
fileTree.map((node) => (
|
||||
<TreeItem
|
||||
@@ -409,11 +416,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
|
||||
<div className="mb-3">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Node Types
|
||||
{t('graph:fileTree.nodeTypes')}
|
||||
</h3>
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Toggle visibility of node types in the graph
|
||||
</p>
|
||||
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.nodeTypesDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -449,11 +454,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
{/* Edge Type Toggles */}
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Edge Types
|
||||
{t('graph:fileTree.edgeTypes')}
|
||||
</h3>
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Toggle visibility of relationship types
|
||||
</p>
|
||||
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.edgeTypesDesc')}</p>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{ALL_EDGE_TYPES.map((edgeType) => {
|
||||
@@ -488,19 +491,17 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
<Target className="mr-1.5 inline h-3 w-3" />
|
||||
Focus Depth
|
||||
{t('graph:fileTree.focusDepth')}
|
||||
</h3>
|
||||
<p className="mb-3 text-[11px] text-text-muted">
|
||||
Show nodes within N hops of selection
|
||||
</p>
|
||||
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.focusDepthDesc')}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ value: null, label: 'All' },
|
||||
{ value: 1, label: '1 hop' },
|
||||
{ value: 2, label: '2 hops' },
|
||||
{ value: 3, label: '3 hops' },
|
||||
{ value: 5, label: '5 hops' },
|
||||
{ value: null, label: t('graph:fileTree.all') },
|
||||
{ value: 1, label: t('graph:fileTree.hops', { count: 1 }) },
|
||||
{ value: 2, label: t('graph:fileTree.hops', { count: 2 }) },
|
||||
{ value: 3, label: t('graph:fileTree.hops', { count: 3 }) },
|
||||
{ value: 5, label: t('graph:fileTree.hops', { count: 5 }) },
|
||||
].map(({ value, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
@@ -517,14 +518,16 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
</div>
|
||||
|
||||
{depthFilter !== null && !selectedNode && (
|
||||
<p className="mt-2 text-[10px] text-amber-400">Select a node to apply depth filter</p>
|
||||
<p className="mt-2 text-[10px] text-amber-400">
|
||||
{t('graph:fileTree.selectNodeDepth')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 border-t border-border-subtle pt-4">
|
||||
<h3 className="mb-3 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
||||
Color Legend
|
||||
{t('graph:fileTree.colorLegend')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
@@ -558,8 +561,8 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
||||
{graph && (
|
||||
<div className="border-t border-border-subtle bg-elevated/50 px-3 py-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
||||
<span>{graph.nodes.length} nodes</span>
|
||||
<span>{graph.relationships.length} edges</span>
|
||||
<span>{t('common:counts.nodes', { count: graph.nodes.length })}</span>
|
||||
<span>{t('common:counts.edges', { count: graph.relationships.length })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
Pause,
|
||||
Lightbulb,
|
||||
LightbulbOff,
|
||||
Network,
|
||||
GitBranch,
|
||||
Target,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useSigma } from '../hooks/useSigma';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import {
|
||||
knowledgeGraphToGraphology,
|
||||
knowledgeGraphToTreeGraphology,
|
||||
knowledgeGraphToCirclesGraphology,
|
||||
filterGraphByDepth,
|
||||
SigmaNodeAttributes,
|
||||
SigmaEdgeAttributes,
|
||||
@@ -21,12 +26,14 @@ import {
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { QueryFAB } from './QueryFAB';
|
||||
import Graph from 'graphology';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface GraphCanvasHandle {
|
||||
focusNode: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
const { t } = useTranslation('graph');
|
||||
const {
|
||||
graph,
|
||||
setSelectedNode,
|
||||
@@ -46,6 +53,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
clearAICitationHighlights,
|
||||
clearBlastRadius,
|
||||
animatedNodes,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useAppState();
|
||||
const [hoveredNodeName, setHoveredNodeName] = useState<string | null>(null);
|
||||
|
||||
@@ -147,8 +156,22 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
blastRadiusNodeIds: effectiveBlastRadiusNodeIds,
|
||||
animatedNodes: effectiveAnimatedNodes,
|
||||
visibleEdgeTypes,
|
||||
layoutMode: graphViewMode,
|
||||
});
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(mode: 'force' | 'tree' | 'circles') => {
|
||||
if (mode === graphViewMode) return;
|
||||
setSelectedNode(null);
|
||||
setSigmaSelectedNode(null);
|
||||
setHoveredNodeName(null);
|
||||
setGraphViewMode(mode);
|
||||
// Reset zoom when switching views
|
||||
resetZoom();
|
||||
},
|
||||
[graphViewMode, resetZoom, setGraphViewMode, setSelectedNode, setSigmaSelectedNode],
|
||||
);
|
||||
|
||||
// Expose focusNode to parent via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -172,25 +195,30 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
useEffect(() => {
|
||||
if (!graph) return;
|
||||
|
||||
// Build communityMemberships map from MEMBER_OF relationships
|
||||
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
|
||||
const communityMemberships = new Map<string, number>();
|
||||
graph.relationships.forEach((rel) => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
// Find the community node to get its index
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
if (communityNode && communityNode.label === 'Community') {
|
||||
// Extract community index from id (e.g., "comm_5" -> 5)
|
||||
const numericPart = rel.targetId.replace('comm_', '');
|
||||
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
|
||||
communityMemberships.set(rel.sourceId, communityIdx);
|
||||
let sigmaGraph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes>;
|
||||
|
||||
if (graphViewMode === 'tree') {
|
||||
sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
} else if (graphViewMode === 'circles') {
|
||||
sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
} else {
|
||||
// Build community memberships map from MEMBER_OF relationships
|
||||
const communityMemberships = new Map<string, number>();
|
||||
graph.relationships.forEach((rel) => {
|
||||
if (rel.type === 'MEMBER_OF') {
|
||||
const communityNode = nodeById.get(rel.targetId);
|
||||
if (communityNode && communityNode.label === 'Community') {
|
||||
const numericPart = rel.targetId.replace('comm_', '');
|
||||
const communityIdx = /^\d+$/.test(numericPart) ? parseInt(numericPart, 10) : 0;
|
||||
communityMemberships.set(rel.sourceId, communityIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
}
|
||||
|
||||
const sigmaGraph = knowledgeGraphToGraphology(graph, communityMemberships);
|
||||
setSigmaGraph(sigmaGraph);
|
||||
}, [graph, nodeById, setSigmaGraph]);
|
||||
}, [graph, nodeById, setSigmaGraph, graphViewMode]);
|
||||
|
||||
// Update node visibility when filters change
|
||||
useEffect(() => {
|
||||
@@ -203,7 +231,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
|
||||
sigma.refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
|
||||
}, [visibleLabels, depthFilter, appSelectedNode]);
|
||||
}, [graph, graphViewMode, visibleLabels, depthFilter, appSelectedNode]);
|
||||
|
||||
// Sync app selected node with sigma
|
||||
useEffect(() => {
|
||||
@@ -243,6 +271,53 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* View Mode Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('canvas.viewModes.label')}
|
||||
className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 gap-1 rounded-lg border border-border-subtle bg-elevated/90 p-1 backdrop-blur-sm"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'force'}
|
||||
onClick={() => handleViewModeChange('force')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'force'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.force')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'tree'}
|
||||
onClick={() => handleViewModeChange('tree')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'tree'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.tree')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={graphViewMode === 'circles'}
|
||||
onClick={() => handleViewModeChange('circles')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
graphViewMode === 'circles'
|
||||
? 'bg-accent text-white'
|
||||
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
{t('canvas.viewModes.circles')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sigma container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -268,7 +343,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
onClick={handleClearSelection}
|
||||
className="ml-2 rounded px-2 py-0.5 text-xs text-text-secondary transition-colors hover:bg-white/10 hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
{t('canvas.clear')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -278,21 +353,21 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Zoom In"
|
||||
title={t('canvas.zoomIn')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Zoom Out"
|
||||
title={t('canvas.zoomOut')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Fit to Screen"
|
||||
title={t('canvas.fit')}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -305,7 +380,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
<button
|
||||
onClick={handleFocusSelected}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-accent/30 bg-accent/20 text-accent transition-colors hover:bg-accent/30"
|
||||
title="Focus on Selected Node"
|
||||
title={t('canvas.focusSelected')}
|
||||
>
|
||||
<Focus className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -316,7 +391,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
<button
|
||||
onClick={handleClearSelection}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Clear Selection"
|
||||
title={t('canvas.clearSelection')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -333,7 +408,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
? 'animate-pulse border-accent bg-accent text-white shadow-glow'
|
||||
: 'border-border-subtle bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
|
||||
} `}
|
||||
title={isLayoutRunning ? 'Stop Layout' : 'Run Layout Again'}
|
||||
title={isLayoutRunning ? t('canvas.stopLayout') : t('canvas.runLayout')}
|
||||
>
|
||||
{isLayoutRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -343,7 +418,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
{isLayoutRunning && (
|
||||
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/20 px-3 py-1.5 backdrop-blur-sm">
|
||||
<div className="h-2 w-2 animate-ping rounded-full bg-emerald-400" />
|
||||
<span className="text-xs font-medium text-emerald-400">Layout optimizing...</span>
|
||||
<span className="text-xs font-medium text-emerald-400">
|
||||
{t('canvas.layoutOptimizing')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -359,7 +436,9 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
|
||||
? 'flex h-10 w-10 items-center justify-center rounded-lg border border-cyan-400/40 bg-cyan-500/15 text-cyan-200 transition-colors hover:border-cyan-300/60 hover:bg-cyan-500/20'
|
||||
: 'flex h-10 w-10 items-center justify-center rounded-lg border border-border-subtle bg-elevated text-text-muted transition-colors hover:bg-hover hover:text-text-primary'
|
||||
}
|
||||
title={isAIHighlightsEnabled ? 'Turn off all highlights' : 'Turn on AI highlights'}
|
||||
title={
|
||||
isAIHighlightsEnabled ? t('canvas.turnOffHighlights') : t('canvas.turnOnHighlights')
|
||||
}
|
||||
data-testid="ai-highlights-toggle"
|
||||
>
|
||||
{isAIHighlightsEnabled ? (
|
||||
|
||||
@@ -21,9 +21,12 @@ import {
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GraphNode } from 'gitnexus-shared';
|
||||
import { EmbeddingStatus } from './EmbeddingStatus';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { translateProgressMessage } from '../i18n/progress';
|
||||
|
||||
// Color mapping for node types in search results
|
||||
const NODE_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -55,6 +58,7 @@ export const Header = ({
|
||||
onAnalyzeComplete,
|
||||
onReposChanged,
|
||||
}: HeaderProps) => {
|
||||
const { t } = useTranslation(['common', 'header']);
|
||||
const {
|
||||
projectName,
|
||||
graph,
|
||||
@@ -208,7 +212,7 @@ export const Header = ({
|
||||
{availableRepos.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Repositories
|
||||
{t('header:repositories')}
|
||||
</div>
|
||||
{availableRepos.map((repo) => (
|
||||
<div
|
||||
@@ -232,7 +236,7 @@ export const Header = ({
|
||||
</span>
|
||||
{repo.name === projectName && (
|
||||
<span className="shrink-0 font-mono text-[10px] text-accent">
|
||||
active
|
||||
{t('header:active')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -245,7 +249,7 @@ export const Header = ({
|
||||
setReanalyzeProgress({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Starting...',
|
||||
message: t('common:progress.starting'),
|
||||
});
|
||||
try {
|
||||
const { jobId } = await startAnalyze({
|
||||
@@ -282,8 +286,8 @@ export const Header = ({
|
||||
}`}
|
||||
title={
|
||||
reanalyzing === repo.name
|
||||
? 'Re-analyzing...'
|
||||
: `Re-analyze ${repo.name}`
|
||||
? t('header:reanalyzing')
|
||||
: t('header:reanalyzeRepo', { repoName: repo.name })
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -317,7 +321,7 @@ export const Header = ({
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
|
||||
title={`Delete ${repo.name}`}
|
||||
title={t('header:deleteRepo', { repoName: repo.name })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -332,7 +336,10 @@ export const Header = ({
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
|
||||
{t('header:reanalyzingRepo', {
|
||||
repoName: reanalyzing,
|
||||
message: translateProgressMessage(reanalyzeProgress.message, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-elevated">
|
||||
@@ -359,7 +366,7 @@ export const Header = ({
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="text-sm text-text-secondary">
|
||||
Analyze a new repository...
|
||||
{t('header:analyzeNew')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -378,7 +385,7 @@ export const Header = ({
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search nodes..."
|
||||
placeholder={t('header:searchNodes')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -399,7 +406,7 @@ export const Header = ({
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-text-muted">
|
||||
No nodes found for “{searchQuery}”
|
||||
{t('header:noNodesFound', { query: searchQuery })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
@@ -441,7 +448,7 @@ export const Header = ({
|
||||
className="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-pink-600 px-3.5 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:from-purple-500 hover:to-pink-500 hover:shadow-xl"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Star if cool</span>
|
||||
<span className="hidden sm:inline">{t('header:starIfCool')}</span>
|
||||
<Star className="h-3.5 w-3.5 transition-all group-hover:fill-yellow-300 group-hover:text-yellow-300" />
|
||||
<span className="hidden sm:inline">✨</span>
|
||||
</a>
|
||||
@@ -449,24 +456,26 @@ export const Header = ({
|
||||
{/* Stats */}
|
||||
{graph && (
|
||||
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
|
||||
<span>{nodeCount} nodes</span>
|
||||
<span>{edgeCount} edges</span>
|
||||
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
|
||||
<span>{t('common:counts.edges', { count: edgeCount })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Embedding Status */}
|
||||
<EmbeddingStatus />
|
||||
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Icon buttons */}
|
||||
<button
|
||||
onClick={() => setSettingsPanelOpen(true)}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="AI Settings"
|
||||
title={t('header:aiSettings')}
|
||||
>
|
||||
<Settings className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
<button
|
||||
title="Help"
|
||||
title={t('header:help')}
|
||||
onClick={() => setHelpDialogBoxOpen(true)}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
@@ -483,7 +492,7 @@ export const Header = ({
|
||||
} `}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>Nexus AI</span>
|
||||
<span>{t('common:app.nexusAI')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, GitBranch, Search, Filter, Zap, Keyboard, BarChart2, HelpCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface HelpPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -12,34 +13,33 @@ type TabId = 'overview' | 'graph' | 'search' | 'ai' | 'shortcuts' | 'status';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <HelpCircle className="h-4 w-4" /> },
|
||||
{ id: 'graph', label: 'Graph & nodes', icon: <GitBranch className="h-4 w-4" /> },
|
||||
{ id: 'search', label: 'Search & filter', icon: <Search className="h-4 w-4" /> },
|
||||
{ id: 'ai', label: 'Nexus AI', icon: <Zap className="h-4 w-4" /> },
|
||||
{ id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard className="h-4 w-4" /> },
|
||||
{ id: 'status', label: 'Status bar', icon: <BarChart2 className="h-4 w-4" /> },
|
||||
{ id: 'overview', icon: <HelpCircle className="h-4 w-4" /> },
|
||||
{ id: 'graph', icon: <GitBranch className="h-4 w-4" /> },
|
||||
{ id: 'search', icon: <Search className="h-4 w-4" /> },
|
||||
{ id: 'ai', icon: <Zap className="h-4 w-4" /> },
|
||||
{ id: 'shortcuts', icon: <Keyboard className="h-4 w-4" /> },
|
||||
{ id: 'status', icon: <BarChart2 className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'Search nodes', mac: '⌘ K', win: 'Ctrl K' },
|
||||
{ label: 'Deselect / close', mac: 'Esc', win: 'Esc' },
|
||||
{ labelKey: 'shortcuts.searchNodes', mac: '⌘ K', win: 'Ctrl K' },
|
||||
{ labelKey: 'shortcuts.deselectClose', mac: 'Esc', win: 'Esc' },
|
||||
];
|
||||
|
||||
const nodeColors = [
|
||||
{ color: '#10b981', label: 'Function', desc: 'Function declarations' },
|
||||
{ color: '#3b82f6', label: 'File', desc: 'Source files' },
|
||||
{ color: '#f59e0b', label: 'Class', desc: 'Class declarations' },
|
||||
{ color: '#14b8a6', label: 'Method', desc: 'Class methods' },
|
||||
{ color: '#ec4899', label: 'Interface', desc: 'TypeScript interfaces' },
|
||||
{ color: '#6366f1', label: 'Folder', desc: 'Directory nodes' },
|
||||
{ color: '#10b981', labelKey: 'nodeTypes.function', descKey: 'nodeTypes.functionDesc' },
|
||||
{ color: '#3b82f6', labelKey: 'nodeTypes.file', descKey: 'nodeTypes.fileDesc' },
|
||||
{ color: '#f59e0b', labelKey: 'nodeTypes.class', descKey: 'nodeTypes.classDesc' },
|
||||
{ color: '#14b8a6', labelKey: 'nodeTypes.method', descKey: 'nodeTypes.methodDesc' },
|
||||
{ color: '#ec4899', labelKey: 'nodeTypes.interface', descKey: 'nodeTypes.interfaceDesc' },
|
||||
{ color: '#6366f1', labelKey: 'nodeTypes.folder', descKey: 'nodeTypes.folderDesc' },
|
||||
];
|
||||
|
||||
const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
const getStatusItems = (t: (key: string) => string, nodeCount: number, edgeCount: number) => [
|
||||
{
|
||||
badge: (
|
||||
<span
|
||||
@@ -53,8 +53,8 @@ const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
}}
|
||||
/>
|
||||
),
|
||||
title: 'Ready',
|
||||
desc: 'Graph is fully loaded and interactive',
|
||||
title: t('status.ready'),
|
||||
desc: t('status.readyDesc'),
|
||||
},
|
||||
{
|
||||
badge: (
|
||||
@@ -62,8 +62,8 @@ const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
{nodeCount}
|
||||
</span>
|
||||
),
|
||||
title: 'Nodes count',
|
||||
desc: 'Total files and symbols in the graph',
|
||||
title: t('status.nodesCount'),
|
||||
desc: t('status.nodesCountDesc'),
|
||||
},
|
||||
{
|
||||
badge: (
|
||||
@@ -71,8 +71,8 @@ const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
{edgeCount}
|
||||
</span>
|
||||
),
|
||||
title: 'Edges count',
|
||||
desc: 'Import / dependency connections',
|
||||
title: t('status.edgesCount'),
|
||||
desc: t('status.edgesCountDesc'),
|
||||
},
|
||||
{
|
||||
badge: (
|
||||
@@ -85,11 +85,11 @@ const getStatusItems = (nodeCount: number, edgeCount: number) => [
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
Semantic Ready
|
||||
{t('status.semanticReadyBadge')}
|
||||
</span>
|
||||
),
|
||||
title: 'AI index status',
|
||||
desc: 'Repo is fully indexed for AI queries',
|
||||
title: t('status.aiIndexStatus'),
|
||||
desc: t('status.aiIndexStatusDesc'),
|
||||
},
|
||||
// { badge: <span style={{ fontSize: 11, fontWeight: 500, color: '#9ca3af', flexShrink: 0 }}>typescript</span>, title: 'Language', desc: 'Primary language detected in the repo' },
|
||||
];
|
||||
@@ -119,6 +119,8 @@ function TabContent({
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation('help');
|
||||
|
||||
if (active === 'overview')
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -131,7 +133,7 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Getting started
|
||||
{t('overview.gettingStarted')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -143,11 +145,10 @@ function TabContent({
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>
|
||||
What is GitNexus?
|
||||
{t('overview.whatIsTitle')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
An interactive graph explorer for your codebase. Every file, function, and import
|
||||
becomes a node you can explore, query, and navigate visually.
|
||||
{t('overview.whatIsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -160,11 +161,10 @@ function TabContent({
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>
|
||||
Your current repo
|
||||
{t('overview.currentRepoTitle')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Loaded: <span style={{ color: '#a78bfa', fontFamily: 'monospace' }}></span> {nodeCount}{' '}
|
||||
nodes · {edgeCount} edges
|
||||
{t('overview.loadedCounts', { nodeCount, edgeCount })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -177,15 +177,16 @@ function TabContent({
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>
|
||||
Three ways to explore
|
||||
{t('overview.threeWaysTitle')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>1.</strong> Click nodes to inspect
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>1.</strong>{' '}
|
||||
{t('overview.wayInspect')}
|
||||
<br />
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>2.</strong> Search by name or type
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>2.</strong>{' '}
|
||||
{t('overview.waySearch')}
|
||||
<br />
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>3.</strong> Ask Nexus AI a natural
|
||||
language question
|
||||
<strong style={{ color: '#e2e2e8', fontWeight: 500 }}>3.</strong> {t('overview.wayAsk')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -198,11 +199,11 @@ function TabContent({
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 13, fontWeight: 500, color: '#e2e2e8', margin: '0 0 4px' }}>
|
||||
Navigation
|
||||
{t('overview.navigationTitle')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
· Scroll to zoom <br />
|
||||
· Click and drag to pan <br />· Double-click a node to focus its subgraph
|
||||
· {t('overview.navZoom')} <br />· {t('overview.navPan')} <br />·{' '}
|
||||
{t('overview.navFocus')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -220,44 +221,44 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Node color legend
|
||||
{t('graph.nodeColorLegend')}
|
||||
</p>
|
||||
|
||||
{nodeColors.map(({ color, label, desc }) => (
|
||||
<div key={label} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 2px' }}>
|
||||
{label} nodes
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>{desc}</p>
|
||||
{nodeColors.map(({ color, labelKey, descKey }) => {
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<div key={labelKey} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 2px' }}>
|
||||
{t('graph.nodeLabel', { label })}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0 }}>{t(descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ borderTop: '0.5px solid rgba(255,255,255,0.08)', margin: '4px 0' }} />
|
||||
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Node <strong style={{ color: '#e2e2e8', fontWeight: 500 }}>size</strong> reflects
|
||||
connection count — larger nodes are depended on by more files. Edges point from importer →
|
||||
imported.
|
||||
{t('graph.sizeDescription')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '10px 14px' }}
|
||||
>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Click any node to open its detail panel — showing imports, exports, and reverse
|
||||
dependencies.
|
||||
{t('graph.detailDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +276,7 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Search & filter
|
||||
{t('search.title')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -284,12 +285,11 @@ function TabContent({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<kbd style={kbdStyle}>⌘K</kbd>/<kbd style={kbdStyle}>Ctrl K</kbd>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: 0 }}>
|
||||
Search nodes
|
||||
{t('search.searchNodes')}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Search by filename, function name, or import path. Matching nodes are highlighted live
|
||||
in the graph.
|
||||
{t('search.searchDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -299,12 +299,11 @@ function TabContent({
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Filter style={{ width: 14, height: 14, color: '#a78bfa', flexShrink: 0 }} />
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: 0 }}>
|
||||
Filter panel
|
||||
{t('search.filterPanel')}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Use the filter icon in the left sidebar to isolate specific node types, hide leaf nodes,
|
||||
or focus on a depth range from a selected root.
|
||||
{t('search.filterDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -312,13 +311,13 @@ function TabContent({
|
||||
style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }}
|
||||
>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#e2e2e8', margin: '0 0 6px' }}>
|
||||
Search syntax
|
||||
{t('search.syntax')}
|
||||
</p>
|
||||
{[
|
||||
{ query: 'auth', hint: 'match by name fragment' },
|
||||
{ query: './utils/', hint: 'match by path prefix' },
|
||||
{ query: 'type:config', hint: 'filter by node type' },
|
||||
].map(({ query, hint }) => (
|
||||
{ query: 'auth', hintKey: 'search.hints.nameFragment' },
|
||||
{ query: './utils/', hintKey: 'search.hints.pathPrefix' },
|
||||
{ query: 'type:config', hintKey: 'search.hints.nodeType' },
|
||||
].map(({ query, hintKey }) => (
|
||||
<div
|
||||
key={query}
|
||||
style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 4 }}
|
||||
@@ -336,7 +335,7 @@ function TabContent({
|
||||
>
|
||||
{query}
|
||||
</code>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>{hint}</span>
|
||||
<span style={{ fontSize: 12, color: '#6b7280' }}>{t(hintKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,7 +354,7 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Nexus AI
|
||||
{t('ai.title')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -367,20 +366,19 @@ function TabContent({
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 12, fontWeight: 500, color: '#a78bfa', margin: '0 0 4px' }}>
|
||||
✓ Semantic Ready
|
||||
{t('ai.semanticReady')}
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: 0, lineHeight: 1.6 }}>
|
||||
Your repo is indexed and ready for semantic queries. Nexus AI understands code structure
|
||||
and relationships, not just file names.
|
||||
{t('ai.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: '4px 0 2px' }}>Try asking:</p>
|
||||
<p style={{ fontSize: 12, color: '#9ca3af', margin: '4px 0 2px' }}>{t('tryAsking')}</p>
|
||||
{[
|
||||
'"Which files depend on the auth module?"',
|
||||
'"Find circular dependencies in this repo"',
|
||||
'"What are the most connected components?"',
|
||||
'"Show me all files that import useEffect"',
|
||||
t('ai.questions.dependencies'),
|
||||
t('ai.questions.circular'),
|
||||
t('ai.questions.connected'),
|
||||
t('ai.questions.imports'),
|
||||
].map((q) => (
|
||||
<div
|
||||
key={q}
|
||||
@@ -400,8 +398,7 @@ function TabContent({
|
||||
<div style={{ borderTop: '0.5px solid rgba(255,255,255,0.08)', margin: '4px 0' }} />
|
||||
|
||||
<p style={{ fontSize: 12, color: '#6b7280', margin: 0, lineHeight: 1.6 }}>
|
||||
Open the prompt via the <span style={{ color: '#e2e2e8' }}>Nexus AI</span> button
|
||||
(top-right).
|
||||
{t('ai.openPrompt')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -428,7 +425,7 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Action
|
||||
{t('shortcuts.columns.action')}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
@@ -454,9 +451,9 @@ function TabContent({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shortcuts.map(({ label, mac, win }, i) => (
|
||||
{shortcuts.map(({ labelKey, mac, win }, i) => (
|
||||
<div
|
||||
key={label}
|
||||
key={labelKey}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 80px 88px',
|
||||
@@ -467,7 +464,7 @@ function TabContent({
|
||||
i < shortcuts.length - 1 ? '0.5px solid rgba(255,255,255,0.05)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{label}</span>
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t(labelKey)}</span>
|
||||
<span style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<kbd style={kbdStyle}>{mac}</kbd>
|
||||
</span>
|
||||
@@ -491,9 +488,9 @@ function TabContent({
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
Status bar explained
|
||||
{t('status.explained')}
|
||||
</p>
|
||||
{getStatusItems(nodeCount, edgeCount).map(({ badge, title, desc }) => (
|
||||
{getStatusItems(t, nodeCount, edgeCount).map(({ badge, title, desc }) => (
|
||||
<div
|
||||
key={title}
|
||||
style={{
|
||||
@@ -521,7 +518,9 @@ function TabContent({
|
||||
}
|
||||
|
||||
export const HelpPanel = ({ isOpen, onClose, nodeCount, edgeCount }: HelpPanelProps) => {
|
||||
const { t } = useTranslation('help');
|
||||
const [active, setActive] = useState<TabId>('overview');
|
||||
const localizedTabs = tabs.map((tab) => ({ ...tab, label: t(`tabs.${tab.id}`) }));
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -592,9 +591,9 @@ export const HelpPanel = ({ isOpen, onClose, nodeCount, edgeCount }: HelpPanelPr
|
||||
</div>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, color: '#e2e2e8', margin: 0 }}>
|
||||
Help & Reference
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: '#6b7280', margin: 0 }}>GitNexus — graph explorer</p>
|
||||
<p style={{ fontSize: 12, color: '#6b7280', margin: 0 }}>{t('footer')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -632,7 +631,7 @@ export const HelpPanel = ({ isOpen, onClose, nodeCount, edgeCount }: HelpPanelPr
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{tabs.map(({ id, label, icon }) => {
|
||||
{localizedTabs.map(({ id, label, icon }) => {
|
||||
const isActive = active === id;
|
||||
return (
|
||||
<button
|
||||
@@ -699,16 +698,14 @@ export const HelpPanel = ({ isOpen, onClose, nodeCount, edgeCount }: HelpPanelPr
|
||||
background: 'rgba(255,255,255,0.01)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, color: '#4b5563' }}>
|
||||
GitNexus — open source codebase graph explorer
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#4b5563' }}>{t('footerLong')}</span>
|
||||
<a
|
||||
href="https://github.com/abhigyanpatwari/GitNexus"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 11, color: '#a78bfa', textDecoration: 'none' }}
|
||||
>
|
||||
Docs & GitHub ↗
|
||||
{t('docsGithub')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Globe } from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../i18n/languages';
|
||||
|
||||
export const LanguageSwitcher = () => {
|
||||
const { t, i18n } = useTranslation('header');
|
||||
const currentLanguage = i18n.resolvedLanguage || i18n.language;
|
||||
const currentLanguageMetadata =
|
||||
SUPPORTED_LANGUAGES.find(
|
||||
(language) => language.code.toLowerCase() === currentLanguage.toLowerCase(),
|
||||
) ?? SUPPORTED_LANGUAGES[0];
|
||||
|
||||
const handleChange = (language: SupportedLanguage) => {
|
||||
void i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex h-9 items-center gap-1.5 rounded-md border border-border-subtle bg-surface px-2 text-text-secondary transition-colors hover:border-border-default hover:bg-hover hover:text-text-primary"
|
||||
title={t('selectLanguage')}
|
||||
>
|
||||
<Globe className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="sr-only">{t('language')}</span>
|
||||
<select
|
||||
data-testid="language-switcher"
|
||||
value={currentLanguageMetadata.code}
|
||||
aria-label={t('selectLanguage')}
|
||||
onChange={(event) => handleChange(event.target.value as SupportedLanguage)}
|
||||
className="cursor-pointer border-none bg-transparent text-xs font-medium outline-none"
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((language) => (
|
||||
<option
|
||||
key={language.code}
|
||||
value={language.code}
|
||||
className="bg-surface text-text-primary"
|
||||
>
|
||||
{language.nativeName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { PipelineProgress } from 'gitnexus-shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateProgressMessage } from '../i18n/progress';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
progress: PipelineProgress;
|
||||
}
|
||||
|
||||
export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const message = translateProgressMessage(progress.message, t);
|
||||
const detail = translateProgressMessage(progress.detail, t);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-void">
|
||||
{/* Background gradient effects */}
|
||||
@@ -32,11 +38,11 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
{/* Status text */}
|
||||
<div className="text-center">
|
||||
<p className="mb-1 font-mono text-sm text-text-secondary">
|
||||
{progress.message}
|
||||
{message}
|
||||
<span className="animate-pulse">|</span>
|
||||
</p>
|
||||
{progress.detail && (
|
||||
<p className="max-w-md truncate font-mono text-xs text-text-muted">{progress.detail}</p>
|
||||
<p className="max-w-md truncate font-mono text-xs text-text-muted">{detail}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,12 +52,15 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-node-file" />
|
||||
<span>
|
||||
{progress.stats.filesProcessed} / {progress.stats.totalFiles} files
|
||||
{t('graph:loading.filesProgress', {
|
||||
processed: progress.stats.filesProcessed,
|
||||
total: progress.stats.totalFiles,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-node-function" />
|
||||
<span>{progress.stats.nodesCreated} nodes</span>
|
||||
<span>{t('common:counts.nodes', { count: progress.stats.nodesCreated })}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check } from '@/lib/lucide-icons';
|
||||
|
||||
// Custom syntax theme
|
||||
@@ -38,6 +39,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
toolCalls,
|
||||
showCopyButton = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
@@ -125,7 +127,9 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
href={hrefStr}
|
||||
onClick={(e) => handleLinkClick(e, hrefStr)}
|
||||
className={`${baseParams} ${colorParams}`}
|
||||
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
|
||||
title={t(isNodeRef ? 'chat.viewNodeInCodePanel' : 'chat.openInCodePanel', {
|
||||
inner,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-inherit">{children}</span>
|
||||
@@ -182,7 +186,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
},
|
||||
pre: ({ children }: any) => <>{children}</>,
|
||||
}),
|
||||
[handleLinkClick],
|
||||
[handleLinkClick, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -205,14 +209,14 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 rounded border border-transparent px-2 py-1 text-xs text-text-muted transition-all hover:border-border-subtle hover:bg-surface hover:text-text-primary"
|
||||
title="Copy to clipboard"
|
||||
title={t('actions.copy')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{copied ? 'Copied' : 'Copy'}</span>
|
||||
<span>{copied ? t('actions.copied') : t('actions.copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense, useEffect, useRef, useState, lazy } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { AlertTriangle, Maximize2 } from '@/lib/lucide-icons';
|
||||
@@ -55,6 +56,7 @@ interface MermaidDiagramProps {
|
||||
}
|
||||
|
||||
export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
const { t } = useTranslation(['graph']);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -98,7 +100,7 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
const processData: any = showModal
|
||||
? {
|
||||
id: 'ai-generated',
|
||||
label: 'AI Generated Diagram',
|
||||
label: t('graph:diagram.aiGenerated'),
|
||||
processType: 'intra_community',
|
||||
steps: [], // Empty - we'll render raw mermaid
|
||||
edges: [],
|
||||
@@ -112,12 +114,12 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
<div className="my-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-rose-300">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-medium">Diagram Error</span>
|
||||
<span className="font-medium">{t('graph:diagram.error')}</span>
|
||||
</div>
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-rose-200/70">{error}</pre>
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-text-muted hover:text-text-secondary">
|
||||
Show source
|
||||
{t('graph:diagram.showSource')}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-surface p-2 text-xs text-text-muted">
|
||||
{code}
|
||||
@@ -134,12 +136,12 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-subtle bg-surface/60 px-3 py-2">
|
||||
<span className="text-[10px] font-medium tracking-wider text-text-muted uppercase">
|
||||
Diagram
|
||||
{t('graph:diagram.label')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Expand"
|
||||
title={t('graph:diagram.expandTitle')}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -161,7 +163,9 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
|
||||
|
||||
{/* Use ProcessFlowModal for expansion */}
|
||||
{showModal && processData && (
|
||||
<Suspense fallback={<div className="p-4 text-sm text-text-muted">Loading diagram…</div>}>
|
||||
<Suspense
|
||||
fallback={<div className="p-4 text-sm text-text-muted">{t('graph:diagram.loading')}</div>}
|
||||
>
|
||||
<ProcessFlowModal process={processData} onClose={() => setShowModal(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Check, Copy, Terminal, Server, Zap, Sparkles } from '@/lib/lucide-icons';
|
||||
import { REQUIRED_NODE_VERSION } from '../config/ui-constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Design constants ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +10,7 @@ const isDev = import.meta.env.DEV;
|
||||
// ── Copy-to-clipboard button ─────────────────────────────────────────────────
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -32,7 +34,7 @@ function CopyButton({ text }: { text: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
aria-label={copied ? t('guide.copiedAria') : t('guide.copyAria')}
|
||||
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 transition-all duration-200 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none ${
|
||||
copied
|
||||
? 'bg-emerald-400/10 text-emerald-400'
|
||||
@@ -128,6 +130,7 @@ function StepRow({
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const isVisible = state !== 'waiting';
|
||||
|
||||
return (
|
||||
@@ -151,7 +154,7 @@ function StepRow({
|
||||
</span>
|
||||
{state === 'done' && (
|
||||
<span className="animate-fade-in font-mono text-[10px] tracking-wider text-emerald-400/60 uppercase">
|
||||
done
|
||||
{t('guide.done')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,6 +171,8 @@ function StepRow({
|
||||
// ── Polling status bar ────────────────────────────────────────────────────────
|
||||
|
||||
function PollingBar() {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex animate-fade-in items-center gap-3 rounded-xl border border-accent/15 bg-accent/5 px-4 py-3"
|
||||
@@ -183,12 +188,12 @@ function PollingBar() {
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
Listening for server
|
||||
{t('guide.listeningForServer')}
|
||||
<span className="ml-0.5 inline-flex text-text-muted">
|
||||
<span className="animate-pulse">...</span>
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">Will auto-connect when detected</p>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">{t('guide.willAutoConnect')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -201,8 +206,9 @@ interface OnboardingGuideProps {
|
||||
}
|
||||
|
||||
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const primary = isDev ? 'npm run --prefix gitnexus serve' : 'npx gitnexus@latest serve';
|
||||
const termLabel = isDev ? 'Start backend' : 'Terminal';
|
||||
const termLabel = isDev ? t('guide.startBackend') : t('guide.terminal');
|
||||
|
||||
// Step states: step 1 = copy command, step 2 = run/wait, step 3 = auto-connect
|
||||
// Once polling starts the user has presumably run the command — mark step 1 done.
|
||||
@@ -226,12 +232,10 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Start your local server
|
||||
{t('guide.startServer')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
{isDev
|
||||
? 'Fire up the Express backend in a separate terminal to unlock the full graph.'
|
||||
: 'One command is all it takes. The browser connects automatically.'}
|
||||
{isDev ? t('guide.devDescription') : t('guide.prodDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,8 +252,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
<StepRow
|
||||
state={step1State}
|
||||
number={1}
|
||||
title="Copy the command"
|
||||
description={isPolling ? undefined : 'Click the icon in the terminal to copy.'}
|
||||
title={t('guide.copyCommand')}
|
||||
description={isPolling ? undefined : t('guide.copyCommandDescription')}
|
||||
>
|
||||
<TerminalWindow command={primary} label={termLabel} isActive={step1State === 'active'} />
|
||||
|
||||
@@ -259,13 +263,13 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
<div className="my-3 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or install globally
|
||||
{t('guide.orInstallGlobally')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
<TerminalWindow
|
||||
command="npm install -g gitnexus && gitnexus serve"
|
||||
label="Global install"
|
||||
label={t('guide.globalInstall')}
|
||||
isActive={false}
|
||||
/>
|
||||
</>
|
||||
@@ -276,10 +280,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
<StepRow
|
||||
state={step2State}
|
||||
number={2}
|
||||
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
|
||||
description={
|
||||
isPolling ? undefined : 'Open a terminal at the project root, paste, and hit Enter.'
|
||||
}
|
||||
title={isPolling ? t('guide.waitingForServer') : t('guide.pasteAndRun')}
|
||||
description={isPolling ? undefined : t('guide.pasteAndRunDescription')}
|
||||
>
|
||||
{isPolling && <PollingBar />}
|
||||
</StepRow>
|
||||
@@ -288,8 +290,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
<StepRow
|
||||
state={step3State}
|
||||
number={3}
|
||||
title="Auto-connects and opens the graph"
|
||||
description="No refresh needed — the page detects the server automatically."
|
||||
title={t('guide.autoConnects')}
|
||||
description={t('guide.autoConnectsDescription')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -297,7 +299,7 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
<div className="mt-6 flex items-center justify-center gap-1.5 border-t border-border-subtle pt-5 text-xs text-text-muted">
|
||||
<Server className="h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
Requires{' '}
|
||||
{t('guide.requires')}{' '}
|
||||
<a
|
||||
href="https://nodejs.org"
|
||||
target="_blank"
|
||||
@@ -309,7 +311,7 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
|
||||
</span>
|
||||
<span className="mx-1 text-border-default">·</span>
|
||||
<Terminal className="h-3 w-3 shrink-0" />
|
||||
<span>Port 4747</span>
|
||||
<span>{t('guide.port')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Focus, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import mermaid from 'mermaid';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -59,6 +60,7 @@ export const ProcessFlowModal = ({
|
||||
onFocusInGraph,
|
||||
isFullScreen = false,
|
||||
}: ProcessFlowModalProps) => {
|
||||
const { t } = useTranslation(['graph', 'common']);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -171,13 +173,13 @@ export const ProcessFlowModal = ({
|
||||
diagramRef.current!.innerHTML = `
|
||||
<div class="text-center p-8">
|
||||
<div class="text-red-400 text-sm font-medium mb-2">
|
||||
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
|
||||
${isSizeError ? t('graph:processFlow.diagramTooLarge') : t('graph:processFlow.renderError')}
|
||||
</div>
|
||||
<div class="text-slate-400 text-xs max-w-md">
|
||||
${
|
||||
isSizeError
|
||||
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
|
||||
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
|
||||
? t('graph:processFlow.tooComplex', { count: process.steps?.length || 0 })
|
||||
: t('graph:processFlow.unableToRender', { count: process.steps?.length || 0 })
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,7 +188,7 @@ export const ProcessFlowModal = ({
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [process]);
|
||||
}, [process, t]);
|
||||
|
||||
// Close on escape
|
||||
useEffect(() => {
|
||||
@@ -242,7 +244,9 @@ export const ProcessFlowModal = ({
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative z-10 border-b border-white/10 px-6 py-5">
|
||||
<h2 className="text-lg font-semibold text-white">Process: {process.label}</h2>
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t('graph:processFlow.title', { label: process.label })}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Diagram */}
|
||||
@@ -271,7 +275,7 @@ export const ProcessFlowModal = ({
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Zoom out (-)"
|
||||
title={t('graph:processFlow.zoomOutTitle')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -281,7 +285,7 @@ export const ProcessFlowModal = ({
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Zoom in (+)"
|
||||
title={t('graph:processFlow.zoomInTitle')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -289,9 +293,9 @@ export const ProcessFlowModal = ({
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
title="Reset zoom and pan"
|
||||
title={t('graph:processFlow.resetTitle')}
|
||||
>
|
||||
Reset View
|
||||
{t('graph:processFlow.resetView')}
|
||||
</button>
|
||||
{onFocusInGraph && (
|
||||
<button
|
||||
@@ -299,7 +303,7 @@ export const ProcessFlowModal = ({
|
||||
className="flex items-center gap-2 rounded-lg bg-cyan-400 px-5 py-2.5 text-sm font-medium text-slate-900 shadow-lg shadow-cyan-500/20 transition-all hover:bg-cyan-300"
|
||||
>
|
||||
<Focus className="h-4 w-4" />
|
||||
Toggle Focus
|
||||
{t('graph:processFlow.toggleFocus')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -307,13 +311,13 @@ export const ProcessFlowModal = ({
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-purple-500/20 transition-all hover:bg-purple-500"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Mermaid
|
||||
{t('graph:processFlow.copyMermaid')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Close
|
||||
{t('common:actions.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
GitBranch,
|
||||
Search,
|
||||
@@ -26,6 +27,7 @@ import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
|
||||
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
|
||||
|
||||
export const ProcessesPanel = () => {
|
||||
const { t } = useTranslation(['graph']);
|
||||
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
|
||||
@@ -120,7 +122,7 @@ export const ProcessesPanel = () => {
|
||||
if (!allStepsMap.has(stepId)) {
|
||||
allStepsMap.set(stepId, {
|
||||
id: stepId,
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
name: row.name || row[1] || t('graph:processes.unknownStep'),
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
});
|
||||
@@ -158,7 +160,7 @@ export const ProcessesPanel = () => {
|
||||
|
||||
const combinedProcessData: ProcessData = {
|
||||
id: 'combined-all',
|
||||
label: `All Processes (${allProcessIds.length} combined)`,
|
||||
label: t('graph:processes.allProcessesLabel', { count: allProcessIds.length }),
|
||||
processType: 'cross_community', // Treat as cross-community for styling
|
||||
steps: allSteps,
|
||||
edges: allEdges,
|
||||
@@ -171,7 +173,7 @@ export const ProcessesPanel = () => {
|
||||
} finally {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
}, [processes, runQuery]);
|
||||
}, [processes, runQuery, t]);
|
||||
|
||||
// Load process steps and open modal
|
||||
const handleViewProcess = useCallback(
|
||||
@@ -191,7 +193,7 @@ export const ProcessesPanel = () => {
|
||||
|
||||
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
|
||||
id: row.id || row[0],
|
||||
name: row.name || row[1] || 'Unknown',
|
||||
name: row.name || row[1] || t('graph:processes.unknownStep'),
|
||||
filePath: row.filePath || row[2],
|
||||
stepNumber: row.stepNumber || row.step || row[3] || 0,
|
||||
}));
|
||||
@@ -244,7 +246,7 @@ export const ProcessesPanel = () => {
|
||||
setLoadingProcess(null);
|
||||
}
|
||||
},
|
||||
[runQuery, graph],
|
||||
[runQuery, graph, t],
|
||||
);
|
||||
|
||||
// Cache for process steps (so we don't re-query when toggling focus)
|
||||
@@ -327,10 +329,11 @@ export const ProcessesPanel = () => {
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-surface">
|
||||
<GitBranch className="h-7 w-7 text-text-muted" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">No Processes Detected</h3>
|
||||
<h3 className="mb-2 text-base font-medium text-text-primary">
|
||||
{t('graph:processes.emptyTitle')}
|
||||
</h3>
|
||||
<p className="max-w-xs text-sm text-text-secondary">
|
||||
Processes are execution flows traced from entry points. Load a codebase to see detected
|
||||
processes.
|
||||
{t('graph:processes.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -347,7 +350,7 @@ export const ProcessesPanel = () => {
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Filter processes..."
|
||||
placeholder={t('graph:processes.filterPlaceholder')}
|
||||
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
/>
|
||||
</div>
|
||||
@@ -356,7 +359,7 @@ export const ProcessesPanel = () => {
|
||||
className="flex items-center gap-2 text-xs text-text-muted"
|
||||
data-testid="process-list-loaded"
|
||||
>
|
||||
<span>{totalCount} processes detected</span>
|
||||
<span>{t('graph:processes.detected', { count: totalCount })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -374,9 +377,11 @@ export const ProcessesPanel = () => {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">
|
||||
Full Process Map
|
||||
{t('graph:processes.fullMap')}
|
||||
</h4>
|
||||
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('graph:processes.viewCombined', { count: totalCount })}
|
||||
</p>
|
||||
</div>
|
||||
{loadingProcess === 'all' ? (
|
||||
<span className="mr-1 animate-spin">
|
||||
@@ -401,7 +406,9 @@ export const ProcessesPanel = () => {
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<Zap className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{t('graph:processes.crossCommunity')}
|
||||
</span>
|
||||
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
|
||||
{filteredProcesses.cross.length}
|
||||
</span>
|
||||
@@ -438,7 +445,9 @@ export const ProcessesPanel = () => {
|
||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||
)}
|
||||
<Home className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{t('graph:processes.intraCommunity')}
|
||||
</span>
|
||||
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
|
||||
{filteredProcesses.intra.length}
|
||||
</span>
|
||||
@@ -492,6 +501,7 @@ const ProcessItem = ({
|
||||
onView,
|
||||
onToggleFocus,
|
||||
}: ProcessItemProps) => {
|
||||
const { t } = useTranslation(['graph']);
|
||||
// Determine row styling - focused gets special highlight
|
||||
const rowClass = isFocused
|
||||
? 'bg-amber-950/40 border border-amber-500/50 ring-1 ring-amber-400/30'
|
||||
@@ -508,11 +518,11 @@ const ProcessItem = ({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-text-primary">{process.label}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{process.stepCount} steps</span>
|
||||
<span>{t('graph:processes.steps', { count: process.stepCount })}</span>
|
||||
{process.clusters.length > 0 && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{process.clusters.length} clusters</span>
|
||||
<span>{t('graph:processes.clusters', { count: process.clusters.length })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -525,7 +535,11 @@ const ProcessItem = ({
|
||||
? 'animate-pulse border border-amber-400/40 bg-amber-500/20 text-amber-400 opacity-100 hover:bg-amber-500/30 hover:text-amber-300'
|
||||
: 'border border-white/10 bg-white/5 text-text-muted opacity-0 group-hover:opacity-100 hover:border-cyan-400/40 hover:bg-cyan-500/20 hover:text-cyan-400'
|
||||
}`}
|
||||
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
|
||||
title={
|
||||
isFocused
|
||||
? t('graph:processes.removeHighlightTitle')
|
||||
: t('graph:processes.highlightTitle')
|
||||
}
|
||||
data-testid="process-highlight-button"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4" />
|
||||
@@ -541,16 +555,16 @@ const ProcessItem = ({
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="animate-pulse">Loading...</span>
|
||||
<span className="animate-pulse">{t('graph:processes.loading')}</span>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Viewing
|
||||
{t('graph:processes.viewing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
View
|
||||
{t('graph:processes.view')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -10,31 +10,33 @@ import {
|
||||
Table,
|
||||
} from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const EXAMPLE_QUERIES = [
|
||||
{
|
||||
label: 'All Functions',
|
||||
labelKey: 'functions',
|
||||
query: `MATCH (n:Function) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`,
|
||||
},
|
||||
{
|
||||
label: 'All Classes',
|
||||
labelKey: 'classes',
|
||||
query: `MATCH (n:Class) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`,
|
||||
},
|
||||
{
|
||||
label: 'All Interfaces',
|
||||
labelKey: 'interfaces',
|
||||
query: `MATCH (n:Interface) RETURN n.id AS id, n.name AS name, n.filePath AS path LIMIT 50`,
|
||||
},
|
||||
{
|
||||
label: 'Function Calls',
|
||||
labelKey: 'calls',
|
||||
query: `MATCH (a:File)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.id AS id, a.name AS caller, b.name AS callee LIMIT 50`,
|
||||
},
|
||||
{
|
||||
label: 'Import Dependencies',
|
||||
labelKey: 'imports',
|
||||
query: `MATCH (a:File)-[r:CodeRelation {type: 'IMPORTS'}]->(b:File) RETURN a.id AS id, a.name AS from, b.name AS imports LIMIT 50`,
|
||||
},
|
||||
];
|
||||
|
||||
export const QueryFAB = () => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const {
|
||||
setHighlightedNodeIds,
|
||||
setQueryResult,
|
||||
@@ -86,13 +88,13 @@ export const QueryFAB = () => {
|
||||
if (!query.trim() || isRunning) return;
|
||||
|
||||
if (!graph) {
|
||||
setError('No project loaded. Load a project first.');
|
||||
setError(t('graph:queryFab.noProject'));
|
||||
return;
|
||||
}
|
||||
|
||||
const ready = await isDatabaseReady();
|
||||
if (!ready) {
|
||||
setError('Database not ready. Please wait for loading to complete.');
|
||||
setError(t('graph:queryFab.dbNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,13 +149,22 @@ export const QueryFAB = () => {
|
||||
setQueryResult({ rows, nodeIds, executionTime });
|
||||
setHighlightedNodeIds(new Set(nodeIds));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Query execution failed');
|
||||
setError(err instanceof Error ? err.message : t('graph:queryFab.executionFailed'));
|
||||
setQueryResult(null);
|
||||
setHighlightedNodeIds(new Set());
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [query, isRunning, graph, isDatabaseReady, runQuery, setHighlightedNodeIds, setQueryResult]);
|
||||
}, [
|
||||
query,
|
||||
isRunning,
|
||||
graph,
|
||||
isDatabaseReady,
|
||||
runQuery,
|
||||
setHighlightedNodeIds,
|
||||
setQueryResult,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
@@ -189,7 +200,7 @@ export const QueryFAB = () => {
|
||||
className="group absolute bottom-4 left-4 z-20 flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-2.5 text-sm font-medium text-white shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span>Query</span>
|
||||
<span>{t('graph:queryFab.query')}</span>
|
||||
{queryResult && queryResult.nodeIds.length > 0 && (
|
||||
<span className="ml-1 rounded-md bg-white/20 px-1.5 py-0.5 text-xs font-semibold">
|
||||
{queryResult.nodeIds.length}
|
||||
@@ -209,7 +220,7 @@ export const QueryFAB = () => {
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-500">
|
||||
<Terminal className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium">Cypher Query</span>
|
||||
<span className="text-sm font-medium">{t('graph:queryFab.cypherQuery')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
@@ -239,7 +250,7 @@ export const QueryFAB = () => {
|
||||
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>Examples</span>
|
||||
<span>{t('graph:queryFab.examples')}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
@@ -249,11 +260,11 @@ export const QueryFAB = () => {
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 animate-fade-in rounded-lg border border-border-subtle bg-surface py-1 shadow-xl">
|
||||
{EXAMPLE_QUERIES.map((example) => (
|
||||
<button
|
||||
key={example.label}
|
||||
key={example.labelKey}
|
||||
onClick={() => handleSelectExample(example.query)}
|
||||
className="w-full px-3 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
{example.label}
|
||||
{t(`graph:queryFab.exampleLabels.${example.labelKey}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -266,7 +277,7 @@ export const QueryFAB = () => {
|
||||
onClick={handleClear}
|
||||
className="rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
{t('graph:queryFab.clear')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -279,7 +290,7 @@ export const QueryFAB = () => {
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>Run</span>
|
||||
<span>{t('graph:queryFab.run')}</span>
|
||||
<kbd className="ml-1 rounded bg-white/20 px-1 py-0.5 text-[10px]">⌘↵</kbd>
|
||||
</button>
|
||||
</div>
|
||||
@@ -297,12 +308,13 @@ export const QueryFAB = () => {
|
||||
<div className="flex items-center justify-between bg-cyan-500/5 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-text-secondary">
|
||||
<span className="font-semibold text-cyan-400">{queryResult.rows.length}</span> rows
|
||||
<span className="font-semibold text-cyan-400">{queryResult.rows.length}</span>{' '}
|
||||
{t('graph:queryFab.rows')}
|
||||
</span>
|
||||
{queryResult.nodeIds.length > 0 && (
|
||||
<span className="text-text-secondary">
|
||||
<span className="font-semibold text-cyan-400">{queryResult.nodeIds.length}</span>{' '}
|
||||
highlighted
|
||||
{t('graph:queryFab.highlighted')}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted">{queryResult.executionTime.toFixed(1)}ms</span>
|
||||
@@ -313,7 +325,7 @@ export const QueryFAB = () => {
|
||||
onClick={clearQueryHighlights}
|
||||
className="text-xs text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
Clear
|
||||
{t('graph:queryFab.clear')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -362,7 +374,7 @@ export const QueryFAB = () => {
|
||||
</table>
|
||||
{queryResult.rows.length > 50 && (
|
||||
<div className="border-t border-border-subtle bg-surface px-3 py-2 text-xs text-text-muted">
|
||||
Showing 50 of {queryResult.rows.length} rows
|
||||
{t('graph:queryFab.showingRows', { count: queryResult.rows.length })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useState, useRef, useEffect, useId } from 'react';
|
||||
import {
|
||||
Github,
|
||||
Gitlab,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Check,
|
||||
@@ -23,23 +24,35 @@ import {
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type InputMode = 'github' | 'local';
|
||||
type InputMode = 'github' | 'gitlab' | 'local';
|
||||
|
||||
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
|
||||
const GITLAB_RE = /^https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+(\/.*)?$/i;
|
||||
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
|
||||
|
||||
function isValidGithubUrl(value: string): boolean {
|
||||
return GITHUB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
function isValidGitlabUrl(value: string): boolean {
|
||||
return GITLAB_RE.test(value.trim());
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg bg-elevated p-1" role="tablist" aria-label="Input type">
|
||||
<div
|
||||
className="flex gap-1 rounded-lg bg-elevated p-1"
|
||||
role="tablist"
|
||||
aria-label={t('repoAnalyzer.inputType')}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'github'}
|
||||
@@ -51,7 +64,20 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
||||
} `}
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
GitHub URL
|
||||
{t('repoAnalyzer.githubUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={mode === 'gitlab'}
|
||||
onClick={() => onChange('gitlab')}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
mode === 'gitlab'
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
} `}
|
||||
>
|
||||
<Gitlab className="h-3 w-3" />
|
||||
{t('repoAnalyzer.gitlabUrl')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
@@ -64,7 +90,7 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
|
||||
} `}
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
Local Folder
|
||||
{t('repoAnalyzer.localFolder')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -83,6 +109,7 @@ function AnalyzeButton({
|
||||
onClick: () => void;
|
||||
variant: 'onboarding' | 'sheet';
|
||||
}) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const sizeClass =
|
||||
variant === 'onboarding' ? 'w-full px-5 py-3.5 text-sm' : 'w-full px-4 py-3 text-sm';
|
||||
return (
|
||||
@@ -96,7 +123,7 @@ function AnalyzeButton({
|
||||
} `}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
<span>{isLoading ? 'Starting analysis...' : 'Analyze Repository'}</span>
|
||||
<span>{isLoading ? t('repoAnalyzer.starting') : t('repoAnalyzer.analyzeRepository')}</span>
|
||||
{canSubmit && !isLoading && <ArrowRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
);
|
||||
@@ -105,6 +132,8 @@ function AnalyzeButton({
|
||||
// ── Done state ───────────────────────────────────────────────────────────────
|
||||
|
||||
function DoneState({ repoName }: { repoName: string }) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex animate-fade-in flex-col items-center gap-3 py-4"
|
||||
@@ -115,10 +144,10 @@ function DoneState({ repoName }: { repoName: string }) {
|
||||
<Check className="h-6 w-6 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-emerald-400">Analysis complete</p>
|
||||
<p className="text-sm font-medium text-emerald-400">{t('repoAnalyzer.complete')}</p>
|
||||
<p className="mt-0.5 font-mono text-xs text-text-muted">{repoName}</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary">Loading graph...</p>
|
||||
<p className="text-xs text-text-secondary">{t('repoAnalyzer.loadingGraph')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,17 +163,19 @@ export interface RepoAnalyzerProps {
|
||||
}
|
||||
|
||||
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
|
||||
const { t } = useTranslation(['common', 'errors', 'onboarding']);
|
||||
const inputId = useId();
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [phase, setPhase] = useState<InternalPhase>('input');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<JobProgress>({
|
||||
phase: 'queued',
|
||||
percent: 0,
|
||||
message: 'Queued',
|
||||
message: t('common:analyzePhases.queued'),
|
||||
});
|
||||
const [completedRepoName, setCompletedRepoName] = useState('');
|
||||
|
||||
@@ -162,6 +193,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
const handleModeChange = (m: InputMode) => {
|
||||
setMode(m);
|
||||
setGithubUrl('');
|
||||
setGitlabUrl('');
|
||||
setLocalPath('');
|
||||
setValidationError(null);
|
||||
};
|
||||
@@ -175,15 +207,21 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
: mode === 'gitlab'
|
||||
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
setValidationError('Please enter a valid GitHub repository URL.');
|
||||
setValidationError(t('errors:invalidGithubUrl'));
|
||||
return;
|
||||
}
|
||||
if (mode === 'gitlab' && !isValidGitlabUrl(gitlabUrl)) {
|
||||
setValidationError('Please enter a valid GitLab repository URL.');
|
||||
return;
|
||||
}
|
||||
if (mode === 'local' && localPath.trim().length < 2) {
|
||||
setValidationError('Please enter a folder path.');
|
||||
setValidationError(t('errors:missingFolderPath'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,18 +229,30 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
setPhase('starting');
|
||||
|
||||
try {
|
||||
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
|
||||
const request =
|
||||
mode === 'github'
|
||||
? { url: githubUrl.trim() }
|
||||
: mode === 'gitlab'
|
||||
? { url: gitlabUrl.trim() }
|
||||
: { path: localPath.trim() };
|
||||
const { jobId } = await startAnalyze(request);
|
||||
jobIdRef.current = jobId;
|
||||
setPhase('analyzing');
|
||||
|
||||
const nameSource = mode === 'github' ? githubUrl.trim() : localPath.trim();
|
||||
const nameSource =
|
||||
mode === 'github'
|
||||
? githubUrl.trim()
|
||||
: mode === 'gitlab'
|
||||
? gitlabUrl.trim()
|
||||
: localPath.trim();
|
||||
const controller = streamAnalyzeProgress(
|
||||
jobId,
|
||||
(p) => setProgress(p),
|
||||
(data) => {
|
||||
const name =
|
||||
data.repoName ?? nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? 'repository';
|
||||
data.repoName ??
|
||||
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
|
||||
t('onboarding:repoAnalyzer.defaultRepoName');
|
||||
setCompletedRepoName(name);
|
||||
setPhase('done');
|
||||
sseControllerRef.current = null;
|
||||
@@ -212,13 +262,13 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
}, 1200);
|
||||
},
|
||||
(errMsg) => {
|
||||
setValidationError(errMsg || 'Analysis failed. Check server logs.');
|
||||
setValidationError(errMsg || t('errors:analysisFailed'));
|
||||
setPhase('error');
|
||||
},
|
||||
);
|
||||
sseControllerRef.current = controller;
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : 'Failed to start analysis');
|
||||
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
|
||||
setPhase('error');
|
||||
}
|
||||
};
|
||||
@@ -233,7 +283,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
jobIdRef.current = null;
|
||||
}
|
||||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: 'Queued' });
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
};
|
||||
|
||||
const isLoading = phase === 'starting';
|
||||
@@ -252,7 +302,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
GitHub Repository URL
|
||||
{t('onboarding:repoAnalyzer.githubRepositoryUrl')}
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
@@ -297,6 +347,59 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GitLab URL input */}
|
||||
{showInput && mode === 'gitlab' && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
{t('onboarding:repoAnalyzer.gitlabRepositoryUrl')}
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
validationError && phase === 'error'
|
||||
? 'border-red-500/50'
|
||||
: isValidGitlabUrl(gitlabUrl)
|
||||
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-default focus-within:border-accent/40'
|
||||
} `}
|
||||
>
|
||||
<Gitlab className="h-4 w-4 shrink-0 text-text-muted" />
|
||||
<input
|
||||
id={inputId}
|
||||
type="url"
|
||||
value={gitlabUrl}
|
||||
onChange={(e) => {
|
||||
setGitlabUrl(e.target.value);
|
||||
if (validationError) setValidationError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSubmit && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleAnalyze();
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
placeholder="https://gitlab.com/owner/repo"
|
||||
autoComplete="url"
|
||||
spellCheck={false}
|
||||
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
|
||||
/>
|
||||
{gitlabUrl.length > 10 && (
|
||||
<div className="shrink-0">
|
||||
{isValidGitlabUrl(gitlabUrl) ? (
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">{t('onboarding:repoAnalyzer.gitlabSupported')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
@@ -304,7 +407,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
htmlFor={`${inputId}-local`}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
>
|
||||
Local Folder Path
|
||||
{t('onboarding:repoAnalyzer.localFolderPath')}
|
||||
</label>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
|
||||
@@ -367,7 +470,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Browse for folder
|
||||
{t('onboarding:repoAnalyzer.browseForFolder')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -410,14 +513,14 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
}}
|
||||
className="flex-1 cursor-pointer rounded-xl border border-border-subtle bg-elevated px-4 py-2.5 text-sm text-text-secondary transition-all duration-200 hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
Try again
|
||||
{t('common:actions.tryAgain')}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="cursor-pointer px-4 py-2.5 text-sm text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Dismiss
|
||||
{t('common:actions.dismiss')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -429,7 +532,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
||||
onClick={onCancel}
|
||||
className="w-full cursor-pointer py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Hide (analysis continues in background)
|
||||
{t('onboarding:repoAnalyzer.hideBackground')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,26 +15,29 @@
|
||||
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
|
||||
import { RepoAnalyzer } from './RepoAnalyzer';
|
||||
import type { BackendRepo } from '../services/backend-client';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
function formatRelativeTime(dateStr: string, t: TFunction): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffMins < 1) return t('onboarding:landing.time.justNow');
|
||||
if (diffMins < 60) return t('onboarding:landing.time.minutesAgo', { count: diffMins });
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffHours < 24) return t('onboarding:landing.time.hoursAgo', { count: diffHours });
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
if (diffDays < 30) return t('onboarding:landing.time.daysAgo', { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// ── Repo card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) {
|
||||
const { t } = useTranslation(['common', 'onboarding']);
|
||||
const stats = repo.stats;
|
||||
|
||||
return (
|
||||
@@ -53,7 +56,7 @@ function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void })
|
||||
</div>
|
||||
{repo.indexedAt && (
|
||||
<p className="mt-1 pl-6 text-xs text-text-muted">
|
||||
Indexed {formatRelativeTime(repo.indexedAt)}
|
||||
{t('onboarding:landing.indexed', { time: formatRelativeTime(repo.indexedAt, t) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -64,17 +67,18 @@ function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void })
|
||||
<div className="mt-3 flex flex-wrap gap-2 pl-6">
|
||||
{stats.files != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<FileCode className="h-3 w-3" /> {stats.files.toLocaleString()} files
|
||||
<FileCode className="h-3 w-3" /> {t('common:counts.files', { count: stats.files })}
|
||||
</span>
|
||||
)}
|
||||
{stats.nodes != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<Layers className="h-3 w-3" /> {stats.nodes.toLocaleString()} symbols
|
||||
<Layers className="h-3 w-3" /> {t('common:counts.symbols', { count: stats.nodes })}
|
||||
</span>
|
||||
)}
|
||||
{stats.processes != null && stats.processes > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
|
||||
<Sparkles className="h-3 w-3" /> {stats.processes} flows
|
||||
<Sparkles className="h-3 w-3" />{' '}
|
||||
{t('common:counts.flows', { count: stats.processes })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -92,6 +96,8 @@ interface RepoLandingProps {
|
||||
}
|
||||
|
||||
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
|
||||
const { t } = useTranslation('onboarding');
|
||||
|
||||
return (
|
||||
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
|
||||
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
|
||||
@@ -109,10 +115,10 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
Choose a repository
|
||||
{t('landing.chooseRepository')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
|
||||
Select an indexed repository to explore, or analyze a new one.
|
||||
{t('landing.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,7 +134,7 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
<span className="text-[11px] tracking-widest text-text-muted uppercase">
|
||||
or analyze new
|
||||
{t('landing.orAnalyzeNew')}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border-subtle" />
|
||||
</div>
|
||||
@@ -140,8 +146,7 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
|
||||
|
||||
{/* Footer hint */}
|
||||
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
|
||||
Public & private repos · Cloned locally by the server · No data leaves
|
||||
your machine
|
||||
{t('landing.footer')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,9 @@ import { ToolCallCard } from './ToolCallCard';
|
||||
import { isProviderConfigured } from '../core/llm/settings-service';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||
import { ProcessesPanel } from './ProcessesPanel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
export const RightPanel = () => {
|
||||
const { t } = useTranslation(['chat', 'common']);
|
||||
const {
|
||||
isRightPanelOpen,
|
||||
setRightPanelOpen,
|
||||
@@ -202,10 +204,10 @@ export const RightPanel = () => {
|
||||
};
|
||||
|
||||
const chatSuggestions = [
|
||||
'Explain the project architecture',
|
||||
'What does this project do?',
|
||||
'Show me the most important files',
|
||||
'Find all API handlers',
|
||||
t('chat:suggestions.architecture'),
|
||||
t('chat:suggestions.whatDoes'),
|
||||
t('chat:suggestions.importantFiles'),
|
||||
t('chat:suggestions.apiHandlers'),
|
||||
];
|
||||
|
||||
if (!isRightPanelOpen) return null;
|
||||
@@ -225,7 +227,7 @@ export const RightPanel = () => {
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
<span>Nexus AI</span>
|
||||
<span>{t('chat:tabs.chat')}</span>
|
||||
</button>
|
||||
|
||||
{/* Processes Tab */}
|
||||
@@ -238,9 +240,9 @@ export const RightPanel = () => {
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span>Processes</span>
|
||||
<span>{t('chat:tabs.processes')}</span>
|
||||
<span className="rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
NEW
|
||||
{t('chat:newBadge')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -249,7 +251,7 @@ export const RightPanel = () => {
|
||||
<button
|
||||
onClick={() => setRightPanelOpen(false)}
|
||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
||||
title="Close Panel"
|
||||
title={t('chat:actions.closePanel')}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -270,12 +272,12 @@ export const RightPanel = () => {
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isAgentReady && (
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-300">
|
||||
Configure AI
|
||||
{t('chat:badges.configureAI')}
|
||||
</span>
|
||||
)}
|
||||
{isAgentInitializing && (
|
||||
<span className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Connecting
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {t('chat:badges.connecting')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -296,10 +298,9 @@ export const RightPanel = () => {
|
||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
|
||||
🧠
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-medium">Ask me anything</h3>
|
||||
<h3 className="mb-2 text-base font-medium">{t('chat:empty.title')}</h3>
|
||||
<p className="mb-5 text-sm leading-relaxed text-text-secondary">
|
||||
I can help you understand the architecture, find functions, or explain
|
||||
connections.
|
||||
{t('chat:empty.description')}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{chatSuggestions.map((suggestion) => (
|
||||
@@ -323,7 +324,7 @@ export const RightPanel = () => {
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-text-muted" />
|
||||
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
|
||||
You
|
||||
{t('chat:roles.you')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pl-6 text-sm text-text-primary">{message.content}</div>
|
||||
@@ -336,7 +337,7 @@ export const RightPanel = () => {
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-accent" />
|
||||
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
|
||||
Nexus AI
|
||||
{t('chat:roles.assistant')}
|
||||
</span>
|
||||
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-accent" />
|
||||
@@ -394,7 +395,7 @@ export const RightPanel = () => {
|
||||
|
||||
{/* Scroll to bottom */}
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
aria-label={t('chat:actions.scrollBottom')}
|
||||
onClick={() => scrollToBottom()}
|
||||
className={`absolute bottom-20 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary shadow-lg transition-all duration-200 hover:border-accent hover:text-accent ${
|
||||
!isAtBottom && chatMessages.length > 0
|
||||
@@ -403,7 +404,7 @@ export const RightPanel = () => {
|
||||
}`}
|
||||
>
|
||||
<ArrowDown className="mr-1 inline h-3.5 w-3.5" />
|
||||
Scroll to bottom
|
||||
{t('chat:actions.scrollBottom')}
|
||||
</button>
|
||||
|
||||
{/* Input */}
|
||||
@@ -414,7 +415,7 @@ export const RightPanel = () => {
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about the codebase..."
|
||||
placeholder={t('chat:input.placeholder')}
|
||||
rows={1}
|
||||
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
style={{ height: '36px', overflowY: 'hidden' }}
|
||||
@@ -422,15 +423,15 @@ export const RightPanel = () => {
|
||||
<button
|
||||
onClick={clearChat}
|
||||
className="px-2 py-1 text-xs text-text-muted transition-colors hover:text-text-primary"
|
||||
title="Clear chat"
|
||||
title={t('chat:actions.clearChat')}
|
||||
>
|
||||
Clear
|
||||
{t('common:actions.clear')}
|
||||
</button>
|
||||
{isChatLoading ? (
|
||||
<button
|
||||
onClick={stopChatResponse}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md bg-red-500/80 text-white transition-all hover:bg-red-500"
|
||||
title="Stop response"
|
||||
title={t('chat:actions.stopResponse')}
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</button>
|
||||
@@ -449,8 +450,8 @@ export const RightPanel = () => {
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{isProviderConfigured()
|
||||
? 'Initializing AI agent...'
|
||||
: 'Configure an LLM provider to enable chat.'}
|
||||
? t('chat:input.initializing')
|
||||
: t('chat:input.configureProvider')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
|
||||
import { ProviderConfigCard } from './settings/ProviderConfigCard';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
isOpen: boolean;
|
||||
@@ -51,6 +52,7 @@ const OpenRouterModelCombobox = ({
|
||||
isLoading,
|
||||
onLoadModels,
|
||||
}: OpenRouterModelComboboxProps) => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -142,7 +144,7 @@ const OpenRouterModelCombobox = ({
|
||||
value={searchTerm}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or type model ID..."
|
||||
placeholder={t('searchModelPlaceholder')}
|
||||
className="flex-1 bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -150,7 +152,7 @@ const OpenRouterModelCombobox = ({
|
||||
<span
|
||||
className={`flex-1 truncate font-mono text-sm ${value ? 'text-text-primary' : 'text-text-muted'}`}
|
||||
>
|
||||
{displayValue || 'Select or type a model...'}
|
||||
{displayValue || t('selectModelPlaceholder')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -167,20 +169,20 @@ const OpenRouterModelCombobox = ({
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-6 text-center text-sm text-text-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading models...
|
||||
{t('loadingModels')}
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="px-4 py-4 text-center">
|
||||
{models.length === 0 ? (
|
||||
<div className="text-sm text-text-muted">
|
||||
<Search className="mx-auto mb-2 h-5 w-5 opacity-50" />
|
||||
<p>Type a model ID or press Enter</p>
|
||||
<p className="mt-1 text-xs">e.g. openai/gpt-4o</p>
|
||||
<p>{t('customModelHint')}</p>
|
||||
<p className="mt-1 text-xs">{t('customModelExample')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-text-muted">
|
||||
<p>No models match "{searchTerm}"</p>
|
||||
<p className="mt-1 text-xs">Press Enter to use as custom ID</p>
|
||||
<p>{t('noModelsMatch', { searchTerm })}</p>
|
||||
<p className="mt-1 text-xs">{t('pressEnterCustom')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -198,7 +200,7 @@ const OpenRouterModelCombobox = ({
|
||||
))}
|
||||
{filteredModels.length > 50 && (
|
||||
<div className="border-t border-border-subtle px-4 py-2 text-center text-xs text-text-muted">
|
||||
+{filteredModels.length - 50} more • Refine your search
|
||||
{t('moreModels', { count: filteredModels.length - 50 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -248,6 +250,7 @@ export const SettingsPanel = ({
|
||||
isBackendConnected,
|
||||
onBackendUrlChange,
|
||||
}: SettingsPanelProps) => {
|
||||
const { t } = useTranslation(['common', 'settings']);
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
@@ -338,6 +341,7 @@ export const SettingsPanel = ({
|
||||
'openrouter',
|
||||
'minimax',
|
||||
'glm',
|
||||
'deepseek',
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -354,8 +358,8 @@ export const SettingsPanel = ({
|
||||
<Brain className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
|
||||
<p className="text-xs text-text-muted">Configure your LLM provider</p>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{t('settings:title')}</h2>
|
||||
<p className="text-xs text-text-muted">{t('settings:subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -371,16 +375,18 @@ export const SettingsPanel = ({
|
||||
{/* Local Server */}
|
||||
{backendUrl !== undefined && onBackendUrlChange && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">Local Server</label>
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
{t('settings:localServer')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-text-muted" />
|
||||
<span className="text-sm text-text-secondary">Backend URL</span>
|
||||
<span className="text-sm text-text-secondary">{t('settings:backendUrl')}</span>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-text-muted">
|
||||
{isBackendConnected ? 'Connected' : 'Not connected'}
|
||||
{isBackendConnected ? t('settings:connected') : t('settings:notConnected')}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
@@ -390,17 +396,16 @@ export const SettingsPanel = ({
|
||||
placeholder="http://localhost:4747"
|
||||
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 placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Run <code className="rounded bg-elevated px-1 py-0.5">gitnexus serve</code> to
|
||||
start the local server
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t('settings:runServeHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">Provider</label>
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
{t('settings:provider')}
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{providers.map((provider) => (
|
||||
<button
|
||||
@@ -429,7 +434,9 @@ export const SettingsPanel = ({
|
||||
? '⚡'
|
||||
: provider === 'glm'
|
||||
? '🔮'
|
||||
: '☁️'}
|
||||
: provider === 'deepseek'
|
||||
? '🐋'
|
||||
: '☁️'}
|
||||
</div>
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
</button>
|
||||
@@ -438,7 +445,7 @@ export const SettingsPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
API keys are stored in session storage and will be cleared when you close this tab.
|
||||
{t('settings:apiKeySession')}
|
||||
</div>
|
||||
|
||||
{/* OpenAI Settings */}
|
||||
@@ -447,10 +454,10 @@ export const SettingsPanel = ({
|
||||
title="OpenAI"
|
||||
apiKey={{
|
||||
value: settings.openai?.apiKey ?? '',
|
||||
placeholder: 'Enter your OpenAI API key',
|
||||
helperText: 'Get your API key from',
|
||||
placeholder: t('settings:providers.openai.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.openai.helperText'),
|
||||
helperLink: 'https://platform.openai.com/api-keys',
|
||||
helperLinkLabel: 'OpenAI Platform',
|
||||
helperLinkLabel: t('settings:providers.openai.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['openai'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
@@ -461,7 +468,7 @@ export const SettingsPanel = ({
|
||||
}}
|
||||
model={{
|
||||
value: settings.openai?.model ?? 'gpt-5.2-chat',
|
||||
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
|
||||
placeholder: t('settings:providers.openai.modelPlaceholder'),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -472,7 +479,8 @@ export const SettingsPanel = ({
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="h-4 w-4" />
|
||||
Base URL <span className="font-normal text-text-muted">(optional)</span>
|
||||
{t('settings:baseUrl')}{' '}
|
||||
<span className="font-normal text-text-muted">({t('settings:optional')})</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -483,12 +491,11 @@ export const SettingsPanel = ({
|
||||
openai: { ...prev.openai!, baseUrl: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.openai.com/v1 (default)"
|
||||
placeholder={t('settings:providers.openai.baseUrlPlaceholder')}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Leave empty to use the default OpenAI API. Set a custom URL for proxies or
|
||||
compatible APIs.
|
||||
{t('settings:providers.openai.baseUrlHint')}
|
||||
</p>
|
||||
</div>
|
||||
</ProviderConfigCard>
|
||||
@@ -500,10 +507,10 @@ export const SettingsPanel = ({
|
||||
title="Google Gemini"
|
||||
apiKey={{
|
||||
value: settings.gemini?.apiKey ?? '',
|
||||
placeholder: 'Enter your Google AI API key',
|
||||
helperText: 'Get your API key from',
|
||||
placeholder: t('settings:providers.gemini.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.gemini.helperText'),
|
||||
helperLink: 'https://aistudio.google.com/app/apikey',
|
||||
helperLinkLabel: 'Google AI Studio',
|
||||
helperLinkLabel: t('settings:providers.gemini.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['gemini'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
@@ -514,7 +521,7 @@ export const SettingsPanel = ({
|
||||
}}
|
||||
model={{
|
||||
value: settings.gemini?.model ?? 'gemini-2.0-flash',
|
||||
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
|
||||
placeholder: t('settings:providers.gemini.modelPlaceholder'),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -530,10 +537,10 @@ export const SettingsPanel = ({
|
||||
title="Anthropic"
|
||||
apiKey={{
|
||||
value: settings.anthropic?.apiKey ?? '',
|
||||
placeholder: 'Enter your Anthropic API key',
|
||||
helperText: 'Get your API key from',
|
||||
placeholder: t('settings:providers.anthropic.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.anthropic.helperText'),
|
||||
helperLink: 'https://console.anthropic.com/settings/keys',
|
||||
helperLinkLabel: 'Anthropic Console',
|
||||
helperLinkLabel: t('settings:providers.anthropic.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['anthropic'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
@@ -544,7 +551,7 @@ export const SettingsPanel = ({
|
||||
}}
|
||||
model={{
|
||||
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
|
||||
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
|
||||
placeholder: t('settings:providers.anthropic.modelPlaceholder'),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
@@ -560,7 +567,7 @@ export const SettingsPanel = ({
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
{t('settings:apiKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -572,7 +579,7 @@ export const SettingsPanel = ({
|
||||
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your Azure OpenAI API key"
|
||||
placeholder={t('settings:providers.azure.apiKeyPlaceholder')}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
@@ -592,7 +599,7 @@ export const SettingsPanel = ({
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="h-4 w-4" />
|
||||
Endpoint
|
||||
{t('settings:endpoint')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -609,7 +616,9 @@ export const SettingsPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Deployment Name</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:deploymentName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.deploymentName ?? ''}
|
||||
@@ -619,14 +628,16 @@ export const SettingsPanel = ({
|
||||
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., gpt-4o-deployment"
|
||||
placeholder={t('settings:providers.azure.deploymentNamePlaceholder')}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:model')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
|
||||
@@ -642,7 +653,9 @@ export const SettingsPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">API Version</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:apiVersion')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
|
||||
@@ -659,14 +672,14 @@ export const SettingsPanel = ({
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Configure your Azure OpenAI service in the{' '}
|
||||
{t('settings:azureHint')}{' '}
|
||||
<a
|
||||
href="https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Azure Portal
|
||||
{t('settings:azurePortal')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -678,7 +691,8 @@ export const SettingsPanel = ({
|
||||
{/* How to run Ollama */}
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<p className="text-xs leading-relaxed text-amber-300">
|
||||
<span className="font-medium">📋 Quick Start:</span> Install Ollama from{' '}
|
||||
<span className="font-medium">{t('settings:providers.ollama.quickStart')}</span>{' '}
|
||||
{t('settings:providers.ollama.installFrom')}{' '}
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
target="_blank"
|
||||
@@ -687,7 +701,7 @@ export const SettingsPanel = ({
|
||||
>
|
||||
ollama.ai
|
||||
</a>
|
||||
, then run:
|
||||
{t('settings:providers.ollama.thenRun')}
|
||||
</p>
|
||||
<code className="mt-2 block rounded-lg bg-black/30 px-3 py-2 font-mono text-sm text-amber-200">
|
||||
ollama serve
|
||||
@@ -697,7 +711,7 @@ export const SettingsPanel = ({
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Server className="h-4 w-4" />
|
||||
Base URL
|
||||
{t('settings:baseUrl')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -719,18 +733,21 @@ export const SettingsPanel = ({
|
||||
}
|
||||
disabled={isCheckingOllama}
|
||||
className="rounded-xl border border-border-subtle bg-elevated px-3 py-3 text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary disabled:opacity-50"
|
||||
title="Check connection"
|
||||
title={t('settings:checkConnection')}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Default port is <code className="rounded bg-elevated px-1 py-0.5">11434</code>.
|
||||
{t('settings:defaultPort')}{' '}
|
||||
<code className="rounded bg-elevated px-1 py-0.5">11434</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:model')}
|
||||
</label>
|
||||
|
||||
{ollamaError && !isCheckingOllama && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2">
|
||||
@@ -750,11 +767,11 @@ export const SettingsPanel = ({
|
||||
ollama: { ...prev.ollama!, model: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="e.g., llama3.2, mistral, codellama"
|
||||
placeholder={t('settings:providers.ollama.modelPlaceholder')}
|
||||
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 placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Pull a model with{' '}
|
||||
{t('settings:pullModel')}{' '}
|
||||
<code className="rounded bg-elevated px-1 py-0.5">ollama pull llama3.2</code>
|
||||
</p>
|
||||
</div>
|
||||
@@ -767,10 +784,10 @@ export const SettingsPanel = ({
|
||||
title="OpenRouter"
|
||||
apiKey={{
|
||||
value: settings.openrouter?.apiKey ?? '',
|
||||
placeholder: 'Enter your OpenRouter API key',
|
||||
helperText: 'Get your API key from',
|
||||
placeholder: t('settings:providers.openrouter.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.openrouter.helperText'),
|
||||
helperLink: 'https://openrouter.ai/keys',
|
||||
helperLinkLabel: 'OpenRouter Keys',
|
||||
helperLinkLabel: t('settings:providers.openrouter.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['openrouter'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
@@ -781,7 +798,9 @@ export const SettingsPanel = ({
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:model')}
|
||||
</label>
|
||||
<OpenRouterModelCombobox
|
||||
value={settings.openrouter?.model ?? ''}
|
||||
onChange={(model) =>
|
||||
@@ -795,14 +814,14 @@ export const SettingsPanel = ({
|
||||
onLoadModels={loadOpenRouterModels}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Browse all models at{' '}
|
||||
{t('settings:browseModels')}{' '}
|
||||
<a
|
||||
href="https://openrouter.ai/models"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
OpenRouter Models
|
||||
{t('settings:openRouterModels')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -815,10 +834,10 @@ export const SettingsPanel = ({
|
||||
title="MiniMax"
|
||||
apiKey={{
|
||||
value: settings.minimax?.apiKey ?? '',
|
||||
placeholder: 'Enter your MiniMax API key',
|
||||
helperText: 'Get your API key from',
|
||||
placeholder: t('settings:providers.minimax.apiKeyPlaceholder'),
|
||||
helperText: t('settings:providers.minimax.helperText'),
|
||||
helperLink: 'https://platform.minimax.io',
|
||||
helperLinkLabel: 'MiniMax Platform',
|
||||
helperLinkLabel: t('settings:providers.minimax.helperLinkLabel'),
|
||||
isVisible: !!showApiKey['minimax'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
@@ -829,24 +848,61 @@ export const SettingsPanel = ({
|
||||
}}
|
||||
model={{
|
||||
value: settings.minimax?.model ?? 'MiniMax-M2.5',
|
||||
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
|
||||
placeholder: t('settings:providers.minimax.modelPlaceholder'),
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
minimax: { ...prev.minimax!, model: value },
|
||||
})),
|
||||
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
|
||||
helperText: t('settings:providers.minimax.helperModel'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DeepSeek Settings */}
|
||||
{settings.activeProvider === 'deepseek' && (
|
||||
<ProviderConfigCard
|
||||
title="DeepSeek"
|
||||
apiKey={{
|
||||
value: settings.deepseek?.apiKey ?? '',
|
||||
placeholder: 'Enter your DeepSeek API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://platform.deepseek.com/api_keys',
|
||||
helperLinkLabel: 'DeepSeek Platform',
|
||||
isVisible: !!showApiKey['deepseek'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
deepseek: { ...prev.deepseek!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('deepseek'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.deepseek?.model ?? 'deepseek-v4-flash',
|
||||
placeholder: 'e.g., deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
deepseek: { ...prev.deepseek!, model: value },
|
||||
})),
|
||||
helperText:
|
||||
'deepseek-v4-flash (default), deepseek-v4-pro, deepseek-chat (V3), deepseek-reasoner (R1)',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs text-text-muted">
|
||||
Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and
|
||||
requires round-tripping reasoning content.
|
||||
</p>
|
||||
</ProviderConfigCard>
|
||||
)}
|
||||
|
||||
{/* GLM Settings */}
|
||||
{settings.activeProvider === 'glm' && (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
{t('settings:apiKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -858,7 +914,7 @@ export const SettingsPanel = ({
|
||||
glm: { ...prev.glm!, apiKey: e.target.value },
|
||||
}))
|
||||
}
|
||||
placeholder="Enter your Z.AI API key"
|
||||
placeholder={t('settings:providers.glm.apiKeyPlaceholder')}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
@@ -874,20 +930,22 @@ export const SettingsPanel = ({
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Get your API key from{' '}
|
||||
{t('settings:providers.openai.helperText')}{' '}
|
||||
<a
|
||||
href="https://docs.z.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Z.AI Platform
|
||||
{t('settings:zaiPlatform')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Model</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:model')}
|
||||
</label>
|
||||
<select
|
||||
value={settings.glm?.model ?? 'GLM-5'}
|
||||
onChange={(e) =>
|
||||
@@ -907,7 +965,9 @@ export const SettingsPanel = ({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">Base URL</label>
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{t('settings:baseUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.glm?.baseUrl ?? 'https://api.z.ai/api/coding/paas/v4'}
|
||||
@@ -920,9 +980,7 @@ export const SettingsPanel = ({
|
||||
placeholder="https://api.z.ai/api/coding/paas/v4"
|
||||
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 placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">{t('settings:glmCodingApi')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -934,10 +992,10 @@ export const SettingsPanel = ({
|
||||
🔒
|
||||
</div>
|
||||
<div className="text-xs leading-relaxed text-text-muted">
|
||||
<span className="font-medium text-text-secondary">Privacy:</span> Your API keys are
|
||||
stored only in your browser's session storage and are cleared when the tab closes.
|
||||
They're sent directly to the LLM provider when you chat. Your code never leaves your
|
||||
machine.
|
||||
<span className="font-medium text-text-secondary">
|
||||
{t('settings:privacyLabel')}
|
||||
</span>{' '}
|
||||
{t('settings:privacyFull')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -949,13 +1007,13 @@ export const SettingsPanel = ({
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="flex animate-fade-in items-center gap-1.5 text-green-400">
|
||||
<Check className="h-4 w-4" />
|
||||
Settings saved
|
||||
{t('settings:settingsSaved')}
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="flex animate-fade-in items-center gap-1.5 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Failed to save
|
||||
{t('settings:failedToSave')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -964,13 +1022,13 @@ export const SettingsPanel = ({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
Cancel
|
||||
{t('common:actions.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-dim"
|
||||
>
|
||||
Save Settings
|
||||
{t('settings:saveSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Heart } from '@/lib/lucide-icons';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { translateProgressMessage } from '../i18n/progress';
|
||||
|
||||
export const StatusBar = () => {
|
||||
const { graph, progress } = useAppState();
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
|
||||
const nodeCount = graph?.nodes.length ?? 0;
|
||||
const edgeCount = graph?.relationships.length ?? 0;
|
||||
@@ -37,12 +40,12 @@ export const StatusBar = () => {
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{progress.message}</span>
|
||||
<span>{translateProgressMessage(progress.message, t)}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5" data-testid="status-ready">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-node-function" />
|
||||
<span>Ready</span>
|
||||
<span>{t('common:progress.ready')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -56,10 +59,10 @@ export const StatusBar = () => {
|
||||
>
|
||||
<Heart className="h-3.5 w-3.5 animate-pulse fill-pink-500/40 text-pink-500 transition-all duration-200 group-hover:scale-110 group-hover:fill-pink-500" />
|
||||
<span className="text-[11px] font-medium text-pink-400 transition-colors group-hover:text-pink-300">
|
||||
Sponsor
|
||||
{t('graph:statusBar.sponsor')}
|
||||
</span>
|
||||
<span className="hidden text-[10px] text-pink-300/50 italic transition-colors group-hover:text-pink-300/80 md:inline">
|
||||
need to buy some API credits to run SWE-bench 😅
|
||||
{t('graph:statusBar.sponsorHint')}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -67,9 +70,9 @@ export const StatusBar = () => {
|
||||
<div className="flex items-center gap-3" data-testid="graph-stats">
|
||||
{graph && (
|
||||
<>
|
||||
<span>{nodeCount} nodes</span>
|
||||
<span>{t('common:counts.nodes', { count: nodeCount })}</span>
|
||||
<span className="text-border-default">•</span>
|
||||
<span>{edgeCount} edges</span>
|
||||
<span>{t('common:counts.edges', { count: edgeCount })}</span>
|
||||
{primaryLanguage && (
|
||||
<>
|
||||
<span className="text-border-default">•</span>
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Square,
|
||||
} from '@/lib/lucide-icons';
|
||||
import type { ToolCallInfo } from '../core/llm/types';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ToolCallCardProps {
|
||||
toolCall: ToolCallInfo;
|
||||
@@ -25,7 +28,7 @@ interface ToolCallCardProps {
|
||||
/**
|
||||
* Format tool arguments for display
|
||||
*/
|
||||
const formatArgs = (args: Record<string, unknown>): string => {
|
||||
const formatArgs = (args: Record<string, unknown>, t: TFunction): string => {
|
||||
if (!args || Object.keys(args).length === 0) {
|
||||
return '';
|
||||
}
|
||||
@@ -34,7 +37,7 @@ const formatArgs = (args: Record<string, unknown>): string => {
|
||||
if ('cypher' in args && typeof args.cypher === 'string') {
|
||||
let result = '';
|
||||
if ('query' in args && typeof args.query === 'string') {
|
||||
result += `Search: "${args.query}"\n\n`;
|
||||
result += t('graph:toolCall.searchPrefix', { query: args.query }) + '\n\n';
|
||||
}
|
||||
result += args.cypher;
|
||||
return result;
|
||||
@@ -75,6 +78,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
||||
bgColor: 'bg-rose-500/10',
|
||||
borderColor: 'border-rose-500/30',
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
icon: <Square className="h-3.5 w-3.5 fill-current" />,
|
||||
color: 'text-amber-300',
|
||||
bgColor: 'bg-amber-500/10',
|
||||
borderColor: 'border-amber-500/30',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Sparkles className="h-3.5 w-3.5" />,
|
||||
@@ -88,24 +98,25 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
|
||||
/**
|
||||
* Get a friendly display name for the tool
|
||||
*/
|
||||
const getToolDisplayName = (name: string): string => {
|
||||
const getToolDisplayName = (name: string, t: TFunction): string => {
|
||||
const names: Record<string, string> = {
|
||||
// Current 7-tool architecture
|
||||
search: '🔍 Search Code',
|
||||
cypher: '🔗 Cypher Query',
|
||||
grep: '🔎 Pattern Search',
|
||||
read: '📄 Read File',
|
||||
overview: '🗺️ Codebase Overview',
|
||||
explore: '🔬 Deep Dive',
|
||||
impact: '💥 Impact Analysis',
|
||||
search: t('graph:toolCall.tools.search'),
|
||||
cypher: t('graph:toolCall.tools.cypher'),
|
||||
grep: t('graph:toolCall.tools.grep'),
|
||||
read: t('graph:toolCall.tools.read'),
|
||||
overview: t('graph:toolCall.tools.overview'),
|
||||
explore: t('graph:toolCall.tools.explore'),
|
||||
impact: t('graph:toolCall.tools.impact'),
|
||||
};
|
||||
return names[name] || name;
|
||||
};
|
||||
|
||||
export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCardProps) => {
|
||||
const { t } = useTranslation(['common', 'graph']);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const status = getStatusDisplay(toolCall.status);
|
||||
const formattedArgs = formatArgs(toolCall.args);
|
||||
const formattedArgs = formatArgs(toolCall.args, t);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -131,13 +142,13 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
|
||||
{/* Tool name */}
|
||||
<span className="flex-1 text-sm font-medium text-text-primary">
|
||||
{getToolDisplayName(toolCall.name)}
|
||||
{getToolDisplayName(toolCall.name, t)}
|
||||
</span>
|
||||
|
||||
{/* Status indicator */}
|
||||
<span className={`flex items-center gap-1 text-xs ${status.color}`}>
|
||||
{status.icon}
|
||||
<span className="capitalize">{toolCall.status}</span>
|
||||
<span className="capitalize">{t(`graph:toolCall.status.${toolCall.status}`)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +159,7 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
{formattedArgs && (
|
||||
<div className="border-b border-border-subtle/50 px-3 py-2">
|
||||
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
|
||||
{toolCall.name === 'cypher' ? 'Query' : 'Input'}
|
||||
{toolCall.name === 'cypher' ? t('graph:toolCall.query') : t('graph:toolCall.input')}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-surface/50 p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
|
||||
{formattedArgs}
|
||||
@@ -160,12 +171,12 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
{toolCall.result && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
|
||||
Result
|
||||
{t('graph:toolCall.result')}
|
||||
</div>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded bg-surface/50">
|
||||
<pre className="p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
|
||||
{toolCall.result.length > 3000
|
||||
? toolCall.result.slice(0, 3000) + '\n\n... (truncated)'
|
||||
? toolCall.result.slice(0, 3000) + '\n\n' + t('common:progress.truncated')
|
||||
: toolCall.result}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -176,7 +187,7 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
|
||||
{toolCall.status === 'running' && !toolCall.result && (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Executing...</span>
|
||||
<span>{t('common:progress.executing')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Snail, Rocket, SkipForward } from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WebGPUFallbackDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -20,6 +21,7 @@ export const WebGPUFallbackDialog = ({
|
||||
onSkip,
|
||||
nodeCount,
|
||||
}: WebGPUFallbackDialogProps) => {
|
||||
const { t } = useTranslation('graph');
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -69,10 +71,10 @@ export const WebGPUFallbackDialog = ({
|
||||
🤔
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">WebGPU said "nope"</h2>
|
||||
<p className="mt-0.5 text-sm text-text-muted">
|
||||
Your browser doesn't support GPU acceleration
|
||||
</p>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{t('embedding.fallback.title')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-sm text-text-muted">{t('embedding.fallback.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,24 +82,31 @@ export const WebGPUFallbackDialog = ({
|
||||
{/* Content */}
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<p className="text-sm leading-relaxed text-text-secondary">
|
||||
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as
|
||||
smart. The graph still works fine though!
|
||||
{t('embedding.fallback.description')}
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-border-subtle bg-elevated/50 p-4">
|
||||
<p className="text-sm text-text-secondary">
|
||||
<span className="font-medium text-text-primary">Your options:</span>
|
||||
<span className="font-medium text-text-primary">
|
||||
{t('embedding.fallback.options')}
|
||||
</span>
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm text-text-muted">
|
||||
<li className="flex items-start gap-2">
|
||||
<Snail className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-400" />
|
||||
<span>
|
||||
<strong className="text-text-secondary">Use CPU</strong> — Works but{' '}
|
||||
{isSmallCodebase ? 'a bit' : 'way'} slower
|
||||
<strong className="text-text-secondary">{t('embedding.fallback.useCpu')}</strong>{' '}
|
||||
—{' '}
|
||||
{isSmallCodebase
|
||||
? t('embedding.fallback.useCpuDescriptionSmall')
|
||||
: t('embedding.fallback.useCpuDescriptionLarge')}
|
||||
{nodeCount > 0 && (
|
||||
<span className="text-text-muted">
|
||||
{' '}
|
||||
(~{estimatedMinutes} min for {nodeCount} nodes)
|
||||
{t('embedding.fallback.estimated', {
|
||||
minutes: estimatedMinutes,
|
||||
count: nodeCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -105,8 +114,8 @@ export const WebGPUFallbackDialog = ({
|
||||
<li className="flex items-start gap-2">
|
||||
<SkipForward className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
|
||||
<span>
|
||||
<strong className="text-text-secondary">Skip it</strong> — Graph works, just no AI
|
||||
semantic search
|
||||
<strong className="text-text-secondary">{t('embedding.fallback.skipIt')}</strong>{' '}
|
||||
— {t('embedding.fallback.skipDescription')}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -115,11 +124,11 @@ export const WebGPUFallbackDialog = ({
|
||||
{isSmallCodebase && (
|
||||
<p className="flex items-center gap-1.5 rounded-lg bg-node-function/10 px-3 py-2 text-xs text-node-function">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Small codebase detected! CPU should be fine.
|
||||
{t('embedding.fallback.smallCodebase')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">💡 Tip: Try Chrome or Edge for WebGPU support</p>
|
||||
<p className="text-xs text-text-muted">{t('embedding.fallback.tip')}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -129,7 +138,7 @@ export const WebGPUFallbackDialog = ({
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2.5 text-sm font-medium text-text-secondary transition-all hover:bg-hover hover:text-text-primary"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
Skip Embeddings
|
||||
{t('embedding.fallback.skipEmbeddings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUseCPU}
|
||||
@@ -140,7 +149,9 @@ export const WebGPUFallbackDialog = ({
|
||||
}`}
|
||||
>
|
||||
<Snail className="h-4 w-4" />
|
||||
Use CPU {isSmallCodebase ? '(Recommended)' : '(Slow)'}
|
||||
{isSmallCodebase
|
||||
? t('embedding.fallback.useCpuRecommended')
|
||||
: t('embedding.fallback.useCpuSlow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Eye, EyeOff, Key } from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type ApiKeyField = {
|
||||
value: string;
|
||||
@@ -35,6 +36,8 @@ export const ProviderConfigCard = ({
|
||||
model,
|
||||
children,
|
||||
}: ProviderConfigCardProps) => {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -48,7 +51,7 @@ export const ProviderConfigCard = ({
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
|
||||
<Key className="h-4 w-4" />
|
||||
API Key
|
||||
{t('apiKey')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
@@ -76,7 +79,7 @@ export const ProviderConfigCard = ({
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{apiKey.helperLinkLabel ?? 'Learn more'}
|
||||
{apiKey.helperLinkLabel ?? t('learnMore')}
|
||||
</a>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -87,7 +90,7 @@ export const ProviderConfigCard = ({
|
||||
{model && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-text-secondary">
|
||||
{model.label ?? 'Model'}
|
||||
{model.label ?? t('model')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
export const ERROR_RESET_DELAY_MS = 3000;
|
||||
export const BACKEND_URL_DEBOUNCE_MS = 500;
|
||||
|
||||
export const DEFAULT_BACKEND_URL = 'http://localhost:4747';
|
||||
export const DEFAULT_BACKEND_URL =
|
||||
(typeof window !== 'undefined' && window.__GITNEXUS_CONFIG__?.backendUrl) ||
|
||||
'http://localhost:4747';
|
||||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
*/
|
||||
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import {
|
||||
SystemMessage,
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
ToolMessage,
|
||||
type BaseMessage,
|
||||
} from '@langchain/core/messages';
|
||||
import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
@@ -23,10 +29,17 @@ import type {
|
||||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
GLMConfig,
|
||||
DeepSeekConfig,
|
||||
AgentStreamChunk,
|
||||
AgentHistoryMessage,
|
||||
} from './types';
|
||||
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
import {
|
||||
DeepSeekChatOpenAI,
|
||||
normalizeMessageContent,
|
||||
normalizeToolCalls,
|
||||
} from './deepseek-chat-model';
|
||||
|
||||
/**
|
||||
* System prompt for the Graph RAG agent
|
||||
@@ -124,6 +137,7 @@ When generating diagrams:
|
||||
BAD: A[User's Data] --> B(Process & Save)
|
||||
GOOD: A["User Data"] --> B["Process and Save"]
|
||||
`;
|
||||
|
||||
export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
switch (config.provider) {
|
||||
case 'openai': {
|
||||
@@ -264,6 +278,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
});
|
||||
}
|
||||
|
||||
case 'deepseek': {
|
||||
const deepseekConfig = config as DeepSeekConfig;
|
||||
|
||||
if (!deepseekConfig.apiKey || deepseekConfig.apiKey.trim() === '') {
|
||||
throw new Error('DeepSeek API key is required but was not provided');
|
||||
}
|
||||
|
||||
return new DeepSeekChatOpenAI({
|
||||
apiKey: deepseekConfig.apiKey,
|
||||
modelName: deepseekConfig.model,
|
||||
temperature: deepseekConfig.temperature ?? 0.1,
|
||||
maxTokens: deepseekConfig.maxTokens,
|
||||
configuration: {
|
||||
apiKey: deepseekConfig.apiKey,
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
},
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as any).provider}`);
|
||||
}
|
||||
@@ -324,11 +358,74 @@ export const createGraphRAGAgent = (
|
||||
/**
|
||||
* Message type for agent conversation
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage;
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
/** Capture assistant/tool messages for providers that require exact transcript replay. */
|
||||
captureHistory?: boolean;
|
||||
/** When aborted (e.g. user clicked Stop), the stream ends with a `cancelled` chunk. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const isAbortError = (error: unknown, signal?: AbortSignal): boolean => {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return true;
|
||||
if (error instanceof Error && error.name === 'AbortError') return true;
|
||||
if (signal?.aborted) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
return new HumanMessage(message.content);
|
||||
}
|
||||
if (message.role === 'tool') {
|
||||
return new ToolMessage({
|
||||
content: message.content,
|
||||
tool_call_id: message.toolCallId,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
});
|
||||
}
|
||||
return new AIMessage({
|
||||
content: message.content,
|
||||
...(typeof message.reasoningContent === 'string'
|
||||
? { additional_kwargs: { reasoning_content: message.reasoningContent } }
|
||||
: {}),
|
||||
...(message.toolCalls?.length ? { tool_calls: message.toolCalls } : {}),
|
||||
} as any);
|
||||
});
|
||||
|
||||
export const serializeAgentHistoryMessages = (
|
||||
messages: unknown[],
|
||||
startIndex = 0,
|
||||
): AgentHistoryMessage[] => {
|
||||
const serialized: AgentHistoryMessage[] = [];
|
||||
for (const rawMessage of messages.slice(startIndex)) {
|
||||
const msg: any = rawMessage;
|
||||
const msgType = msg?._getType?.() || msg?.type || msg?.constructor?.name || 'unknown';
|
||||
if (msgType === 'ai' || msgType === 'AIMessage') {
|
||||
const reasoningContent = (msg.additional_kwargs || msg.kwargs)?.reasoning_content;
|
||||
const toolCalls = normalizeToolCalls(msg.tool_calls);
|
||||
serialized.push({
|
||||
role: 'assistant',
|
||||
content: normalizeMessageContent(msg.content),
|
||||
...(toolCalls?.length && typeof reasoningContent === 'string' ? { reasoningContent } : {}),
|
||||
...(toolCalls?.length ? { toolCalls } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (msgType === 'tool' || msgType === 'ToolMessage') {
|
||||
serialized.push({
|
||||
role: 'tool',
|
||||
content: normalizeMessageContent(msg.content),
|
||||
toolCallId: String(msg.tool_call_id ?? ''),
|
||||
...(typeof msg.name === 'string' ? { name: msg.name } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a response from the agent
|
||||
* Uses BOTH streamModes for best of both worlds:
|
||||
@@ -340,18 +437,17 @@ export interface AgentMessage {
|
||||
export async function* streamAgentResponse(
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[],
|
||||
options: AgentRuntimeOptions = {},
|
||||
): AsyncGenerator<AgentStreamChunk> {
|
||||
try {
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const formattedMessages = buildLangChainMessages(messages);
|
||||
|
||||
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
|
||||
const stream = await agent.stream({ messages: formattedMessages }, {
|
||||
streamMode: ['values', 'messages'] as any,
|
||||
// Allow longer tool/reasoning loops (more Cursor-like persistence)
|
||||
recursionLimit: 50,
|
||||
signal: options.signal,
|
||||
} as any);
|
||||
|
||||
// Track what we've yielded to avoid duplicates
|
||||
@@ -364,8 +460,15 @@ export async function* streamAgentResponse(
|
||||
// Anything before the first tool call should be treated as "reasoning/narration"
|
||||
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
|
||||
let hasSeenToolCallThisTurn = false;
|
||||
// Track the last set of messages so we can persist the raw assistant/tool
|
||||
// transcript for the next user turn.
|
||||
let lastStepMessages: any[] | null = null;
|
||||
|
||||
for await (const event of stream) {
|
||||
if (options.signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Events come as [streamMode, data] tuples when using multiple modes
|
||||
// or just data when using single mode
|
||||
let mode: string;
|
||||
@@ -422,10 +525,11 @@ export async function* streamAgentResponse(
|
||||
// - After all tools are done: treat as final content
|
||||
const isReasoning =
|
||||
!hasSeenToolCallThisTurn || toolCalls.length > 0 || pendingToolCalls > 0;
|
||||
yield {
|
||||
type: isReasoning ? 'reasoning' : 'content',
|
||||
[isReasoning ? 'reasoning' : 'content']: content,
|
||||
};
|
||||
if (isReasoning) {
|
||||
yield { type: 'reasoning', reasoning: content };
|
||||
} else {
|
||||
yield { type: 'content', content };
|
||||
}
|
||||
}
|
||||
|
||||
// Track tool calls from message chunks
|
||||
@@ -482,6 +586,9 @@ export async function* streamAgentResponse(
|
||||
// Handle 'values' mode - state snapshots for structure
|
||||
if (mode === 'values' && data?.messages) {
|
||||
const stepMessages = data.messages || [];
|
||||
if (options.captureHistory) {
|
||||
lastStepMessages = stepMessages;
|
||||
}
|
||||
|
||||
// Process new messages for tool calls/results we might have missed
|
||||
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
|
||||
@@ -535,12 +642,28 @@ export async function* streamAgentResponse(
|
||||
}
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
yield { type: 'cancelled' };
|
||||
return;
|
||||
}
|
||||
|
||||
// DEBUG: Stream completed normally
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Stream completed normally, yielding done');
|
||||
}
|
||||
yield { type: 'done' };
|
||||
|
||||
yield {
|
||||
type: 'done',
|
||||
historyMessages:
|
||||
options.captureHistory && lastStepMessages
|
||||
? serializeAgentHistoryMessages(lastStepMessages, formattedMessages.length)
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error, options.signal)) {
|
||||
yield { type: 'cancelled' };
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// DEBUG: Stream error
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -561,10 +684,7 @@ export const invokeAgent = async (
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[],
|
||||
): Promise<string> => {
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const formattedMessages = buildLangChainMessages(messages);
|
||||
|
||||
const result = await agent.invoke({ messages: formattedMessages });
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ChatOpenAI,
|
||||
ChatOpenAICompletions,
|
||||
type ChatOpenAICallOptions,
|
||||
type ChatOpenAICompletionsCallOptions,
|
||||
type ChatOpenAIFields,
|
||||
} from '@langchain/openai';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { BaseLanguageModelInput } from '@langchain/core/language_models/base';
|
||||
import type { AIMessageChunk } from '@langchain/core/messages';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
|
||||
import type { AgentToolCall } from './types';
|
||||
|
||||
/**
|
||||
* DeepSeek's thinking-mode chat API requires assistant `reasoning_content`
|
||||
* from prior turns to be replayed verbatim on the next request. LangChain
|
||||
* preserves the inbound value on `AIMessage.additional_kwargs`, but its
|
||||
* OpenAI-compatible outbound converter currently drops that provider-specific
|
||||
* field. This completions subclass keeps the behavior scoped to DeepSeek by
|
||||
* replacing only the serialized request messages immediately before the
|
||||
* DeepSeek API call.
|
||||
*/
|
||||
export class DeepSeekChatOpenAICompletions<
|
||||
CallOptions extends ChatOpenAICompletionsCallOptions = ChatOpenAICompletionsCallOptions,
|
||||
> extends ChatOpenAICompletions<CallOptions> {
|
||||
private activeMessages: BaseMessage[] | null = null;
|
||||
|
||||
private setActiveMessages(messages: BaseMessage[]): void {
|
||||
if (this.activeMessages !== null) {
|
||||
throw new Error('DeepSeekChatOpenAICompletions does not support overlapping requests');
|
||||
}
|
||||
this.activeMessages = messages;
|
||||
}
|
||||
|
||||
override async _generate(
|
||||
messages: BaseMessage[],
|
||||
options: this['ParsedCallOptions'],
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
): Promise<ChatResult> {
|
||||
this.setActiveMessages(messages);
|
||||
try {
|
||||
return await super._generate(messages, options, runManager);
|
||||
} finally {
|
||||
this.activeMessages = null;
|
||||
}
|
||||
}
|
||||
|
||||
override async *_streamResponseChunks(
|
||||
messages: BaseMessage[],
|
||||
options: this['ParsedCallOptions'],
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
this.setActiveMessages(messages);
|
||||
try {
|
||||
yield* super._streamResponseChunks(messages, options, runManager);
|
||||
} finally {
|
||||
this.activeMessages = null;
|
||||
}
|
||||
}
|
||||
|
||||
override async completionWithRetry(request: any, requestOptions?: any): Promise<any> {
|
||||
const messages = this.activeMessages
|
||||
? buildDeepSeekRequestMessages(this.activeMessages)
|
||||
: request.messages;
|
||||
return super.completionWithRetry({ ...request, messages }, requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible DeepSeek chat model with a DeepSeek-specific completions
|
||||
* serializer. Keeping this as a subclass avoids provider checks in the shared
|
||||
* agent streaming path and ensures LangChain `withConfig()` clones used by tool
|
||||
* binding retain the same request serialization behavior.
|
||||
*/
|
||||
export class DeepSeekChatOpenAI<
|
||||
CallOptions extends ChatOpenAICallOptions = ChatOpenAICallOptions,
|
||||
> extends ChatOpenAI<CallOptions> {
|
||||
private readonly deepSeekFields: ChatOpenAIFields;
|
||||
|
||||
constructor(fields: ChatOpenAIFields) {
|
||||
const deepSeekFields = {
|
||||
...fields,
|
||||
completions: new DeepSeekChatOpenAICompletions(fields),
|
||||
} as ChatOpenAIFields;
|
||||
super(deepSeekFields);
|
||||
this.deepSeekFields = deepSeekFields;
|
||||
}
|
||||
|
||||
override withConfig(
|
||||
config: Partial<CallOptions>,
|
||||
): Runnable<BaseLanguageModelInput, AIMessageChunk, CallOptions> {
|
||||
// Mirror ChatOpenAI.withConfig() for this LangChain version, but keep the
|
||||
// DeepSeek subclass. Calling super.withConfig() would drop our custom
|
||||
// completions serializer by returning a plain ChatOpenAI instance.
|
||||
const newModel = new DeepSeekChatOpenAI<CallOptions>(this.deepSeekFields);
|
||||
newModel.defaultOptions = {
|
||||
...this.defaultOptions,
|
||||
...config,
|
||||
} as typeof this.defaultOptions;
|
||||
return newModel;
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeMessageContent = (content: unknown): string => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((block: any) => block?.type === 'text' || typeof block === 'string')
|
||||
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
|
||||
.join('');
|
||||
}
|
||||
if (content == null) return '';
|
||||
return String(content);
|
||||
};
|
||||
|
||||
const normalizeToolCallArgs = (toolCall: any): Record<string, unknown> => {
|
||||
if (toolCall?.args && typeof toolCall.args === 'object') {
|
||||
return toolCall.args as Record<string, unknown>;
|
||||
}
|
||||
try {
|
||||
return toolCall?.function?.arguments ? JSON.parse(toolCall.function.arguments) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeToolCalls = (toolCalls: unknown): AgentToolCall[] | undefined => {
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;
|
||||
return toolCalls.map((toolCall: any) => ({
|
||||
id: typeof toolCall?.id === 'string' ? toolCall.id : undefined,
|
||||
name: toolCall?.name || toolCall?.function?.name || 'unknown',
|
||||
args: normalizeToolCallArgs(toolCall),
|
||||
type: typeof toolCall?.type === 'string' ? toolCall.type : 'tool_call',
|
||||
}));
|
||||
};
|
||||
|
||||
const stringifyToolArguments = (args: unknown): string => {
|
||||
if (typeof args === 'string') return args;
|
||||
try {
|
||||
return JSON.stringify(args ?? {});
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOpenAIContent = (content: unknown): string | Array<Record<string, unknown>> => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return normalizeMessageContent(content);
|
||||
|
||||
const blocks = content.flatMap((block: any) => {
|
||||
if (typeof block === 'string') {
|
||||
return [{ type: 'text', text: block }];
|
||||
}
|
||||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||||
return [{ type: 'text', text: block.text }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
if (blocks.length === 0) return '';
|
||||
if (blocks.length === 1) return blocks[0].text as string;
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const getOpenAIRole = (message: any): string => {
|
||||
const messageType =
|
||||
message?._getType?.() || message?.type || message?.constructor?.name || 'unknown';
|
||||
if ((message.additional_kwargs || {}).__openai_role__ === 'developer') {
|
||||
return 'developer';
|
||||
}
|
||||
switch (messageType) {
|
||||
case 'human':
|
||||
case 'HumanMessage':
|
||||
return 'user';
|
||||
case 'ai':
|
||||
case 'AIMessage':
|
||||
return 'assistant';
|
||||
case 'system':
|
||||
case 'SystemMessage':
|
||||
return 'system';
|
||||
case 'tool':
|
||||
case 'ToolMessage':
|
||||
return 'tool';
|
||||
case 'function':
|
||||
case 'FunctionMessage':
|
||||
return 'function';
|
||||
default:
|
||||
return typeof message.role === 'string' ? message.role : 'user';
|
||||
}
|
||||
};
|
||||
|
||||
export const buildDeepSeekRequestMessages = (
|
||||
messages: Array<BaseMessage | Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> =>
|
||||
messages.map((message: any) => {
|
||||
const role = getOpenAIRole(message);
|
||||
const additionalKwargs =
|
||||
message.additional_kwargs && typeof message.additional_kwargs === 'object'
|
||||
? message.additional_kwargs
|
||||
: {};
|
||||
const requestMessage: Record<string, unknown> = {
|
||||
role,
|
||||
content: normalizeOpenAIContent(message.content),
|
||||
};
|
||||
|
||||
if (typeof message.name === 'string' && message.name.length > 0) {
|
||||
requestMessage.name = message.name;
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
const toolCalls = Array.isArray(message.tool_calls)
|
||||
? message.tool_calls
|
||||
: Array.isArray(additionalKwargs.tool_calls)
|
||||
? additionalKwargs.tool_calls
|
||||
: undefined;
|
||||
if (toolCalls?.length) {
|
||||
requestMessage.tool_calls = toolCalls.map((toolCall: any) => {
|
||||
if (toolCall?.function) {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
type: toolCall.type ?? 'function',
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: stringifyToolArguments(toolCall.function.arguments),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: toolCall?.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall?.name ?? 'unknown',
|
||||
arguments: stringifyToolArguments(toolCall?.args),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
if (additionalKwargs.function_call != null) {
|
||||
requestMessage.function_call = additionalKwargs.function_call;
|
||||
}
|
||||
if (toolCalls?.length && typeof additionalKwargs.reasoning_content === 'string') {
|
||||
requestMessage.reasoning_content = additionalKwargs.reasoning_content;
|
||||
}
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
if (role === 'tool' && typeof message.tool_call_id === 'string') {
|
||||
requestMessage.tool_call_id = message.tool_call_id;
|
||||
}
|
||||
|
||||
if (role === 'function' && typeof message.name === 'string') {
|
||||
requestMessage.name = message.name;
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
GLMConfig,
|
||||
DeepSeekConfig,
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
@@ -59,6 +60,10 @@ const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings =>
|
||||
...DEFAULT_LLM_SETTINGS.glm,
|
||||
...parsed?.glm,
|
||||
},
|
||||
deepseek: {
|
||||
...DEFAULT_LLM_SETTINGS.deepseek,
|
||||
...parsed?.deepseek,
|
||||
},
|
||||
});
|
||||
|
||||
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
|
||||
@@ -144,7 +149,9 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
||||
? Partial<Omit<MiniMaxConfig, 'provider'>>
|
||||
: T extends 'glm'
|
||||
? Partial<Omit<GLMConfig, 'provider'>>
|
||||
: never
|
||||
: T extends 'deepseek'
|
||||
? Partial<Omit<DeepSeekConfig, 'provider'>>
|
||||
: never
|
||||
>,
|
||||
): LLMSettings => {
|
||||
const current = loadSettings();
|
||||
@@ -239,6 +246,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
case 'deepseek': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
deepseek: {
|
||||
...(current.deepseek ?? {}),
|
||||
...(updates as Partial<Omit<DeepSeekConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
default: {
|
||||
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
|
||||
const updated: LLMSettings = { ...current };
|
||||
@@ -316,6 +334,10 @@ const providerBuilders: Record<LLMProvider, ProviderBuilder> = {
|
||||
maxTokens: settings.glm.maxTokens,
|
||||
} as GLMConfig;
|
||||
},
|
||||
deepseek: (settings) => {
|
||||
if (!settings.deepseek?.apiKey) return null;
|
||||
return { provider: 'deepseek', ...settings.deepseek } as DeepSeekConfig;
|
||||
},
|
||||
};
|
||||
|
||||
export const getActiveProviderConfig = (): ProviderConfig | null => {
|
||||
@@ -347,6 +369,24 @@ export const clearSettings = (): void => {
|
||||
}
|
||||
};
|
||||
|
||||
interface ProviderCapabilities {
|
||||
/** Provider requires hidden assistant/tool transcript replay across turns. */
|
||||
preserveAssistantTranscript: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
|
||||
preserveAssistantTranscript: false,
|
||||
};
|
||||
|
||||
const PROVIDER_CAPABILITIES: Partial<Record<LLMProvider, ProviderCapabilities>> = {
|
||||
deepseek: { preserveAssistantTranscript: true },
|
||||
};
|
||||
|
||||
export const getProviderCapabilities = (provider: LLMProvider): ProviderCapabilities => ({
|
||||
...DEFAULT_PROVIDER_CAPABILITIES,
|
||||
...PROVIDER_CAPABILITIES[provider],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get display name for a provider
|
||||
*/
|
||||
@@ -368,6 +408,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
|
||||
return 'MiniMax';
|
||||
case 'glm':
|
||||
return 'GLM (Z.AI)';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
@@ -398,6 +440,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
||||
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
|
||||
case 'glm':
|
||||
return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5'];
|
||||
case 'deepseek':
|
||||
return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* LLM Provider Types
|
||||
*
|
||||
* Type definitions for multi-provider LLM support.
|
||||
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
|
||||
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, and DeepSeek.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,8 @@ export type LLMProvider =
|
||||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'minimax'
|
||||
| 'glm';
|
||||
| 'glm'
|
||||
| 'deepseek';
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
@@ -106,6 +107,15 @@ export interface GLMConfig extends BaseProviderConfig {
|
||||
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek configuration — OpenAI-compatible API
|
||||
*/
|
||||
export interface DeepSeekConfig extends BaseProviderConfig {
|
||||
provider: 'deepseek';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'deepseek-v4-flash', 'deepseek-v4-pro'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
@@ -117,7 +127,8 @@ export type ProviderConfig =
|
||||
| OllamaConfig
|
||||
| OpenRouterConfig
|
||||
| MiniMaxConfig
|
||||
| GLMConfig;
|
||||
| GLMConfig
|
||||
| DeepSeekConfig;
|
||||
|
||||
/**
|
||||
* Stored settings (what goes to localStorage)
|
||||
@@ -136,6 +147,7 @@ export interface LLMSettings {
|
||||
openrouter?: Partial<Omit<OpenRouterConfig, 'provider'>>;
|
||||
minimax?: Partial<Omit<MiniMaxConfig, 'provider'>>;
|
||||
glm?: Partial<Omit<GLMConfig, 'provider'>>;
|
||||
deepseek?: Partial<Omit<DeepSeekConfig, 'provider'>>;
|
||||
|
||||
// Intelligent Clustering Settings
|
||||
intelligentClustering: boolean;
|
||||
@@ -197,6 +209,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
||||
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
||||
temperature: 0.1,
|
||||
},
|
||||
deepseek: {
|
||||
apiKey: '',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -219,6 +236,8 @@ export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
/** Hidden raw transcript for reconstructing future agent turns */
|
||||
historyMessages?: AgentHistoryMessage[];
|
||||
/** @deprecated Use steps instead for proper ordering */
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** Ordered steps: reasoning, tool calls, and final content interleaved */
|
||||
@@ -235,25 +254,50 @@ export interface ToolCallInfo {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
status: 'pending' | 'running' | 'completed' | 'error' | 'stopped';
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chunk from agent
|
||||
* Now supports step-based streaming where each step is a distinct message
|
||||
* Minimal tool-call payload needed to reconstruct prior assistant turns.
|
||||
*/
|
||||
export interface AgentStreamChunk {
|
||||
type: 'reasoning' | 'tool_call' | 'tool_result' | 'content' | 'error' | 'done';
|
||||
/** LLM's reasoning/thinking text (shown as a step) */
|
||||
reasoning?: string;
|
||||
/** Final answer content (streamed token by token) */
|
||||
content?: string;
|
||||
/** Tool call information */
|
||||
toolCall?: ToolCallInfo;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
export interface AgentToolCall {
|
||||
id?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
type: 'tool_call';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden per-turn transcript we keep so providers like DeepSeek can replay
|
||||
* the original assistant/tool exchange on later user turns.
|
||||
*/
|
||||
export type AgentHistoryMessage =
|
||||
| {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: AgentToolCall[];
|
||||
}
|
||||
| {
|
||||
role: 'tool';
|
||||
content: string;
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Streaming chunk from agent (discriminated union).
|
||||
* Each variant carries only its relevant fields, enabling exhaustive switch handling.
|
||||
*/
|
||||
export type AgentStreamChunk =
|
||||
| { type: 'reasoning'; reasoning: string }
|
||||
| { type: 'tool_call'; toolCall: ToolCallInfo }
|
||||
| { type: 'tool_result'; toolCall: ToolCallInfo }
|
||||
| { type: 'content'; content: string }
|
||||
| { type: 'error'; error: string }
|
||||
| { type: 'done'; historyMessages?: AgentHistoryMessage[] }
|
||||
| { type: 'cancelled' };
|
||||
|
||||
/**
|
||||
* A single step in the agent's execution
|
||||
* Used for displaying the agent's thought process
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { GraphStateProvider, useGraphState } from './graph';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <GraphStateProvider>{children}</GraphStateProvider>;
|
||||
}
|
||||
|
||||
describe('GraphState', () => {
|
||||
it('should have default graphViewMode as "force"', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
expect(result.current.graphViewMode).toBe('force');
|
||||
});
|
||||
|
||||
it('should toggle graphViewMode', () => {
|
||||
const { result } = renderHook(() => useGraphState(), { wrapper });
|
||||
act(() => {
|
||||
result.current.setGraphViewMode('tree');
|
||||
});
|
||||
expect(result.current.graphViewMode).toBe('tree');
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ interface GraphStateContextValue {
|
||||
setDepthFilter: (depth: number | null) => void;
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
}
|
||||
|
||||
const GraphStateContext = createContext<GraphStateContextValue | null>(null);
|
||||
@@ -27,6 +29,7 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<EdgeType[]>(DEFAULT_VISIBLE_EDGES);
|
||||
const [depthFilter, setDepthFilter] = useState<number | null>(null);
|
||||
const [highlightedNodeIds, setHighlightedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [graphViewMode, setGraphViewMode] = useState<'force' | 'tree' | 'circles'>('force');
|
||||
|
||||
const toggleLabelVisibility = useCallback((label: NodeLabel) => {
|
||||
setVisibleLabels((prev) =>
|
||||
@@ -54,8 +57,18 @@ export const GraphStateProvider = ({ children }: { children: ReactNode }) => {
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
}),
|
||||
[graph, selectedNode, visibleLabels, visibleEdgeTypes, depthFilter, highlightedNodeIds],
|
||||
[
|
||||
graph,
|
||||
selectedNode,
|
||||
visibleLabels,
|
||||
visibleEdgeTypes,
|
||||
depthFilter,
|
||||
highlightedNodeIds,
|
||||
graphViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
return <GraphStateContext.Provider value={value}>{children}</GraphStateContext.Provider>;
|
||||
|
||||
@@ -18,7 +18,12 @@ import type {
|
||||
ToolCallInfo,
|
||||
MessageStep,
|
||||
} from '../core/llm/types';
|
||||
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
|
||||
import {
|
||||
loadSettings,
|
||||
getActiveProviderConfig,
|
||||
getProviderCapabilities,
|
||||
saveSettings,
|
||||
} from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
import { type EdgeType } from '../lib/constants';
|
||||
import {
|
||||
@@ -35,10 +40,18 @@ import {
|
||||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { ERROR_RESET_DELAY_MS } from '../config/ui-constants';
|
||||
import i18n from '../i18n';
|
||||
import { normalizePath } from '../lib/path-resolution';
|
||||
import { FILE_REF_REGEX, NODE_REF_REGEX } from '../lib/grounding-patterns';
|
||||
import { GraphStateProvider, useGraphState } from './app-state/graph';
|
||||
|
||||
export const AUTO_START_EMBEDDINGS_STORAGE_KEY = 'gitnexus.autoStartEmbeddings';
|
||||
|
||||
export const shouldAutoStartEmbeddings = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return false;
|
||||
return window.localStorage.getItem(AUTO_START_EMBEDDINGS_STORAGE_KEY) === 'true';
|
||||
};
|
||||
|
||||
export type ViewMode = 'onboarding' | 'loading' | 'exploring';
|
||||
export type RightPanelTab = 'code' | 'chat';
|
||||
export type EmbeddingStatus = 'idle' | 'loading' | 'embedding' | 'indexing' | 'ready' | 'error';
|
||||
@@ -110,6 +123,10 @@ interface AppState {
|
||||
depthFilter: number | null;
|
||||
setDepthFilter: (depth: number | null) => void;
|
||||
|
||||
// Graph view mode
|
||||
graphViewMode: 'force' | 'tree' | 'circles';
|
||||
setGraphViewMode: (mode: 'force' | 'tree' | 'circles') => void;
|
||||
|
||||
// Query state
|
||||
highlightedNodeIds: Set<string>;
|
||||
setHighlightedNodeIds: (ids: Set<string>) => void;
|
||||
@@ -219,6 +236,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setDepthFilter,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
} = useGraphState();
|
||||
|
||||
// Right Panel
|
||||
@@ -529,6 +548,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setEmbeddingStatus('idle');
|
||||
return;
|
||||
}
|
||||
if (!shouldAutoStartEmbeddings()) {
|
||||
setEmbeddingStatus('idle');
|
||||
return;
|
||||
}
|
||||
startEmbeddings().catch((err) => {
|
||||
console.warn('Embeddings auto-start failed:', err);
|
||||
});
|
||||
@@ -565,6 +588,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
// Agent state — agent runs on main thread now (I/O-bound, not CPU-bound)
|
||||
const agentRef = useRef<any>(null);
|
||||
const chatAbortRef = useRef<AbortController | null>(null);
|
||||
const chatStateRef = useRef<'idle' | 'streaming' | 'aborting'>('idle');
|
||||
|
||||
const initializeAgent = useCallback(
|
||||
async (overrideProjectName?: string): Promise<void> => {
|
||||
@@ -623,6 +648,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
|
||||
const sendChatMessage = useCallback(
|
||||
async (message: string): Promise<void> => {
|
||||
if (chatStateRef.current !== 'idle') return;
|
||||
|
||||
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
|
||||
clearAICodeReferences();
|
||||
// Also clear previous tool-driven AI highlights (highlight_in_graph)
|
||||
@@ -649,7 +676,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: 'Wait a moment, vector index is being created.',
|
||||
content: i18n.t('common:chat.waitForVectorIndex'),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setChatMessages((prev) => [...prev, assistantMessage]);
|
||||
@@ -660,13 +687,30 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
}
|
||||
|
||||
setIsChatLoading(true);
|
||||
chatStateRef.current = 'streaming';
|
||||
setCurrentToolCalls([]);
|
||||
|
||||
chatAbortRef.current?.abort();
|
||||
const chatAbortController = new AbortController();
|
||||
chatAbortRef.current = chatAbortController;
|
||||
|
||||
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
|
||||
|
||||
// Prepare message history for agent (convert our format to AgentMessage format)
|
||||
const history: AgentMessage[] = [...chatMessages, userMessage].map((m) => ({
|
||||
role: m.role === 'tool' ? 'assistant' : m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const history: AgentMessage[] = [...chatMessages, userMessage].flatMap<AgentMessage>((m) => {
|
||||
if (m.role === 'user') {
|
||||
return [{ role: 'user', content: m.content }];
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
return m.toolCallId
|
||||
? [{ role: 'tool', content: m.content, toolCallId: m.toolCallId }]
|
||||
: [];
|
||||
}
|
||||
if (providerCapabilities.preserveAssistantTranscript && m.historyMessages?.length) {
|
||||
return m.historyMessages;
|
||||
}
|
||||
return [{ role: 'assistant', content: m.content }];
|
||||
});
|
||||
|
||||
// Create placeholder for assistant response
|
||||
const assistantMessageId = `assistant-${Date.now()}`;
|
||||
@@ -675,6 +719,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
// Keep toolCalls for backwards compat and currentToolCalls state
|
||||
const toolCallsForMessage: ToolCallInfo[] = [];
|
||||
let stepCounter = 0;
|
||||
let assistantHistoryMessages: ChatMessage['historyMessages'];
|
||||
|
||||
// Helper to update the message with current steps
|
||||
const updateMessage = () => {
|
||||
@@ -691,6 +736,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
historyMessages: assistantHistoryMessages,
|
||||
steps: [...stepsForMessage],
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
timestamp: existing?.timestamp ?? Date.now(),
|
||||
@@ -703,11 +749,13 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
});
|
||||
};
|
||||
let pendingUpdate = false;
|
||||
let rafHandle: number | null = null;
|
||||
const scheduleMessageUpdate = () => {
|
||||
if (pendingUpdate) return;
|
||||
pendingUpdate = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafHandle = requestAnimationFrame(() => {
|
||||
pendingUpdate = false;
|
||||
rafHandle = null;
|
||||
updateMessage();
|
||||
});
|
||||
};
|
||||
@@ -854,7 +902,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
if (idx < 0) {
|
||||
idx = toolCallsForMessage.findIndex((t) => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (idx >= 0) {
|
||||
if (idx >= 0 && toolCallsForMessage[idx].status !== 'stopped') {
|
||||
toolCallsForMessage[idx] = {
|
||||
...toolCallsForMessage[idx],
|
||||
result: tc.result,
|
||||
@@ -870,7 +918,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
(s.toolCall.id === tc.id ||
|
||||
(s.toolCall.name === tc.name && s.toolCall.status === 'running')),
|
||||
);
|
||||
if (stepIdx >= 0 && stepsForMessage[stepIdx].toolCall) {
|
||||
if (
|
||||
stepIdx >= 0 &&
|
||||
stepsForMessage[stepIdx].toolCall &&
|
||||
stepsForMessage[stepIdx].toolCall!.status !== 'stopped'
|
||||
) {
|
||||
stepsForMessage[stepIdx] = {
|
||||
...stepsForMessage[stepIdx],
|
||||
toolCall: {
|
||||
@@ -891,6 +943,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
targetIdx = prev.findIndex((t) => t.name === tc.name && !t.result);
|
||||
}
|
||||
if (targetIdx >= 0) {
|
||||
const target = prev[targetIdx];
|
||||
if (target.status === 'stopped') return prev;
|
||||
return prev.map((t, i) =>
|
||||
i === targetIdx ? { ...t, result: tc.result, status: 'completed' } : t,
|
||||
);
|
||||
@@ -973,6 +1027,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
assistantHistoryMessages = providerCapabilities.preserveAssistantTranscript
|
||||
? chunk.historyMessages
|
||||
: undefined;
|
||||
// Finalize the assistant message - just call updateMessage one more time
|
||||
scheduleMessageUpdate();
|
||||
break;
|
||||
@@ -984,14 +1041,26 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
const agent = agentRef.current;
|
||||
if (!agent) throw new Error('Agent not initialized');
|
||||
const { streamAgentResponse } = await import('../core/llm/agent');
|
||||
for await (const chunk of streamAgentResponse(agent, history)) {
|
||||
for await (const chunk of streamAgentResponse(agent, history, {
|
||||
captureHistory: providerCapabilities.preserveAssistantTranscript,
|
||||
signal: chatAbortController.signal,
|
||||
})) {
|
||||
if (chunk.type === 'cancelled') {
|
||||
break;
|
||||
}
|
||||
onChunk(chunk);
|
||||
}
|
||||
onChunk({ type: 'done' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
if (!chatAbortController.signal.aborted) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
}
|
||||
} finally {
|
||||
if (rafHandle != null) {
|
||||
cancelAnimationFrame(rafHandle);
|
||||
rafHandle = null;
|
||||
}
|
||||
chatStateRef.current = 'idle';
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
@@ -1011,17 +1080,52 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
);
|
||||
|
||||
const stopChatResponse = useCallback(() => {
|
||||
if (isChatLoading) {
|
||||
// Agent streaming will be interrupted by the AbortController in sendChatMessage
|
||||
setIsChatLoading(false);
|
||||
setCurrentToolCalls([]);
|
||||
}
|
||||
}, [isChatLoading]);
|
||||
if (!chatAbortRef.current) return;
|
||||
|
||||
chatStateRef.current = 'aborting';
|
||||
chatAbortRef.current.abort();
|
||||
chatAbortRef.current = null;
|
||||
|
||||
const stoppedLabel = i18n.t('chat:stopped');
|
||||
const markStoppedToolCall = (tc: ToolCallInfo): ToolCallInfo =>
|
||||
tc.status === 'running' || tc.status === 'pending'
|
||||
? { ...tc, status: 'stopped', result: stoppedLabel }
|
||||
: tc;
|
||||
|
||||
setCurrentToolCalls((prev) => prev.map(markStoppedToolCall));
|
||||
|
||||
setChatMessages((prev) => {
|
||||
const lastAssistantIdx = [...prev]
|
||||
.map((m, i) => (m.role === 'assistant' ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
.pop();
|
||||
if (lastAssistantIdx === undefined) return prev;
|
||||
|
||||
const message = prev[lastAssistantIdx];
|
||||
|
||||
const updated: ChatMessage = {
|
||||
...message,
|
||||
toolCalls: message.toolCalls?.map(markStoppedToolCall),
|
||||
steps: message.steps?.map((step) =>
|
||||
step.type === 'tool_call' && step.toolCall
|
||||
? { ...step, toolCall: markStoppedToolCall(step.toolCall) }
|
||||
: step,
|
||||
),
|
||||
};
|
||||
return prev.map((m, i) => (i === lastAssistantIdx ? updated : m));
|
||||
});
|
||||
|
||||
setIsChatLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
chatAbortRef.current?.abort();
|
||||
chatAbortRef.current = null;
|
||||
chatStateRef.current = 'idle';
|
||||
setChatMessages([]);
|
||||
setCurrentToolCalls([]);
|
||||
setAgentError(null);
|
||||
setIsChatLoading(false);
|
||||
}, []);
|
||||
|
||||
// Switch to a different repo on the connected server
|
||||
@@ -1032,8 +1136,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 0,
|
||||
message: 'Switching repository...',
|
||||
detail: `Loading ${repoName}`,
|
||||
message: i18n.t('common:progress.switchingRepository'),
|
||||
detail: i18n.t('common:progress.loadingRepository', { repo: repoName }),
|
||||
});
|
||||
setViewMode('loading');
|
||||
setIsAgentReady(false);
|
||||
@@ -1061,8 +1165,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Switching repository...',
|
||||
detail: 'Validating',
|
||||
message: i18n.t('common:progress.switchingRepository'),
|
||||
detail: i18n.t('common:progress.validating'),
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
@@ -1070,15 +1174,15 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
message: i18n.t('common:progress.downloadingGraph'),
|
||||
detail: i18n.t('common:progress.downloadedMb', { mb }),
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
message: i18n.t('common:progress.processing'),
|
||||
detail: i18n.t('common:progress.extractingFileContents'),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1110,8 +1214,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProgress({
|
||||
phase: 'error',
|
||||
percent: 0,
|
||||
message: 'Failed to switch repository',
|
||||
detail: err instanceof Error ? err.message : 'Unknown error',
|
||||
message: i18n.t('common:progress.failedSwitchRepository'),
|
||||
detail: err instanceof Error ? err.message : i18n.t('common:progress.unknownError'),
|
||||
});
|
||||
setIsAgentReady(false);
|
||||
agentRef.current = null;
|
||||
@@ -1228,6 +1332,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
toggleEdgeVisibility,
|
||||
depthFilter,
|
||||
setDepthFilter,
|
||||
graphViewMode,
|
||||
setGraphViewMode,
|
||||
highlightedNodeIds,
|
||||
setHighlightedNodeIds,
|
||||
aiCitationHighlightedNodeIds,
|
||||
|
||||
+1003
-75
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { BackendError } from '../services/backend-client';
|
||||
|
||||
export function formatBackendError(error: unknown, t: TFunction): string {
|
||||
if (error instanceof BackendError) {
|
||||
const seconds = error.retryAfterMs ? Math.ceil(error.retryAfterMs / 1000) : undefined;
|
||||
const fallback = error.message || t('errors:unknown');
|
||||
switch (error.code) {
|
||||
case 'network':
|
||||
return t('errors:backend.network', { defaultValue: fallback });
|
||||
case 'timeout':
|
||||
return t('errors:backend.timeout', { defaultValue: fallback });
|
||||
case 'rate_limited':
|
||||
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
|
||||
case 'not_found':
|
||||
return t('errors:backend.notFound', { defaultValue: fallback });
|
||||
case 'client':
|
||||
return t('errors:backend.client', { message: error.message, defaultValue: fallback });
|
||||
case 'server':
|
||||
return t('errors:backend.server', { message: error.message, defaultValue: fallback });
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : t('errors:unknown');
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
getLanguageMetadata,
|
||||
normalizeSupportedLanguage,
|
||||
} from './languages';
|
||||
import { namespaceList, resources } from './resources';
|
||||
|
||||
const DEFAULT_NAMESPACE = 'common';
|
||||
export const LANGUAGE_STORAGE_KEY = 'gitnexus.lng';
|
||||
|
||||
function syncDocumentLanguage(language: string | undefined): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const metadata = getLanguageMetadata(language);
|
||||
document.documentElement.lang = metadata.code;
|
||||
document.documentElement.dir = metadata.dir;
|
||||
}
|
||||
|
||||
function convertDetectedLanguage(language: string): string {
|
||||
return normalizeSupportedLanguage(language) ?? DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
function persistSupportedLanguage(language: string | undefined): void {
|
||||
const normalized = normalizeSupportedLanguage(language);
|
||||
if (!normalized || typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, normalized);
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export const i18nReady = i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: SUPPORTED_LANGUAGE_CODES,
|
||||
load: 'currentOnly',
|
||||
ns: namespaceList,
|
||||
defaultNS: DEFAULT_NAMESPACE,
|
||||
fallbackNS: false,
|
||||
returnEmptyString: false,
|
||||
interpolation: { escapeValue: false },
|
||||
react: { useSuspense: false },
|
||||
detection: {
|
||||
order: ['querystring', 'localStorage', 'navigator', 'htmlTag'],
|
||||
lookupQuerystring: 'lng',
|
||||
lookupLocalStorage: LANGUAGE_STORAGE_KEY,
|
||||
caches: [],
|
||||
convertDetectedLanguage,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const language = i18n.resolvedLanguage || i18n.language;
|
||||
syncDocumentLanguage(language);
|
||||
persistSupportedLanguage(language);
|
||||
});
|
||||
|
||||
i18n.on('languageChanged', (language) => {
|
||||
const resolvedLanguage = i18n.resolvedLanguage || language;
|
||||
syncDocumentLanguage(resolvedLanguage);
|
||||
persistSupportedLanguage(resolvedLanguage);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,42 @@
|
||||
export type SupportedLanguage = 'en' | 'zh-CN';
|
||||
|
||||
export interface LanguageMetadata {
|
||||
code: SupportedLanguage;
|
||||
nativeName: string;
|
||||
englishName: string;
|
||||
dir: 'ltr' | 'rtl';
|
||||
}
|
||||
|
||||
export const DEFAULT_LANGUAGE: SupportedLanguage = 'en';
|
||||
|
||||
export const SUPPORTED_LANGUAGES: LanguageMetadata[] = [
|
||||
{ code: 'en', nativeName: 'English', englishName: 'English', dir: 'ltr' },
|
||||
{ code: 'zh-CN', nativeName: '简体中文', englishName: 'Simplified Chinese', dir: 'ltr' },
|
||||
];
|
||||
|
||||
export const SUPPORTED_LANGUAGE_CODES = SUPPORTED_LANGUAGES.map((language) => language.code);
|
||||
|
||||
export function normalizeSupportedLanguage(
|
||||
code: string | undefined | null,
|
||||
): SupportedLanguage | null {
|
||||
const normalized = code?.trim().split('.')[0]?.replace(/_/g, '-').toLowerCase();
|
||||
if (!normalized) return null;
|
||||
if (normalized === 'en' || normalized.startsWith('en-')) return 'en';
|
||||
if (
|
||||
normalized === 'zh' ||
|
||||
normalized === 'zh-cn' ||
|
||||
normalized.startsWith('zh-cn-') ||
|
||||
normalized === 'zh-hans' ||
|
||||
normalized.startsWith('zh-hans-')
|
||||
) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getLanguageMetadata(code: string | undefined): LanguageMetadata {
|
||||
const normalized = normalizeSupportedLanguage(code);
|
||||
return (
|
||||
SUPPORTED_LANGUAGES.find((language) => language.code === normalized) ?? SUPPORTED_LANGUAGES[0]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
export function translateAnalyzePhase(
|
||||
phase: string,
|
||||
message: string | undefined,
|
||||
t: TFunction,
|
||||
): string {
|
||||
const key = `common:analyzePhases.${phase}`;
|
||||
const translated = t(key, { defaultValue: '' });
|
||||
return translated || message || phase;
|
||||
}
|
||||
|
||||
export function translateProgressMessage(message: string | undefined, t: TFunction): string {
|
||||
if (!message) return '';
|
||||
const key = PROGRESS_MESSAGE_KEYS[message];
|
||||
return key ? t(key) : message;
|
||||
}
|
||||
|
||||
const PROGRESS_MESSAGE_KEYS: Record<string, string> = {
|
||||
'Connecting...': 'common:progress.connectingShort',
|
||||
'Connecting to server...': 'common:progress.connecting',
|
||||
'Validating server': 'common:progress.validatingServer',
|
||||
'Validating server...': 'common:progress.validatingServerEllipsis',
|
||||
'Downloading graph...': 'common:progress.downloadingGraph',
|
||||
'Extracting file contents': 'common:progress.extractingFileContents',
|
||||
'Processing...': 'common:progress.processing',
|
||||
'Processing graph...': 'common:progress.processingGraph',
|
||||
'Loading graph...': 'common:progress.loadingGraph',
|
||||
Queued: 'common:analyzePhases.queued',
|
||||
'Starting...': 'common:progress.starting',
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Resource } from 'i18next';
|
||||
|
||||
const localeModules = import.meta.glob('../locales/*/*.json', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
export const resources: Resource = {};
|
||||
export const namespaces = new Set<string>();
|
||||
|
||||
for (const [path, translations] of Object.entries(localeModules)) {
|
||||
const match = path.match(/\.\.\/locales\/([^/]+)\/([^/.]+)\.json$/);
|
||||
if (!match) continue;
|
||||
const [, language, namespace] = match;
|
||||
resources[language] ??= {};
|
||||
resources[language][namespace] = translations;
|
||||
namespaces.add(namespace);
|
||||
}
|
||||
|
||||
export const namespaceList = Array.from(namespaces).sort();
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import { NODE_SIZES } from './constants';
|
||||
|
||||
export interface CirclesNodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
/** Logical ring index 0 (innermost) … RING_COUNT-1 (outermost) */
|
||||
ring: number;
|
||||
/** Angle in radians, stored so the physics can use it as an anchor */
|
||||
angle: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Target radius (px) for each ring. Ring 0 is innermost. */
|
||||
export const CIRCLES_RING_RADII = [90, 240, 420, 620] as const;
|
||||
|
||||
/**
|
||||
* Half-width of the allowed radial band around each ring centre.
|
||||
* Keep this small enough that adjacent rings never overlap.
|
||||
* Current ring gaps: 150 / 180 / 200 px → band = 45 leaves 60-110 px of clear air.
|
||||
*/
|
||||
export const CIRCLES_BAND_HALF = 45;
|
||||
|
||||
/** Number of rings (= number of layers). */
|
||||
export const RING_COUNT = CIRCLES_RING_RADII.length; // 4
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer assignment — identical to tree-layout so the same node types
|
||||
// end up in the same conceptual layer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TYPE_TO_RING: Record<string, number> = {
|
||||
// Ring 0 – innermost: structural containers
|
||||
Project: 0,
|
||||
Package: 0,
|
||||
Module: 0,
|
||||
Folder: 0,
|
||||
Namespace: 0,
|
||||
|
||||
// Ring 1 – files
|
||||
File: 1,
|
||||
Section: 1,
|
||||
Import: 1,
|
||||
Route: 1,
|
||||
Tool: 1,
|
||||
|
||||
// Ring 2 – type definitions
|
||||
Class: 2,
|
||||
Interface: 2,
|
||||
Enum: 2,
|
||||
Type: 2,
|
||||
Struct: 2,
|
||||
Trait: 2,
|
||||
Union: 2,
|
||||
Record: 2,
|
||||
Typedef: 2,
|
||||
Template: 2,
|
||||
TypeAlias: 2,
|
||||
|
||||
// Ring 3 – outermost: functions / methods / variables
|
||||
Function: 3,
|
||||
Method: 3,
|
||||
Impl: 3,
|
||||
Delegate: 3,
|
||||
Constructor: 3,
|
||||
Variable: 3,
|
||||
Const: 3,
|
||||
Static: 3,
|
||||
Property: 3,
|
||||
Decorator: 3,
|
||||
Annotation: 3,
|
||||
Macro: 3,
|
||||
CodeElement: 3,
|
||||
};
|
||||
|
||||
const DEFAULT_RING = 1;
|
||||
|
||||
/** Hierarchy edges used for angular-allocation grouping. */
|
||||
export const CIRCLES_HIERARCHY_RELATIONS = new Set([
|
||||
'CONTAINS',
|
||||
'DEFINES',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getNodeRing(node: GraphNode): number {
|
||||
return TYPE_TO_RING[node.label] ?? DEFAULT_RING;
|
||||
}
|
||||
|
||||
function calculateNodeSize(ring: number, nodeType: NodeLabel): number {
|
||||
const baseSize = NODE_SIZES[nodeType] || 6;
|
||||
const ringMultiplier = Math.max(0.6, 1 - ring * 0.12);
|
||||
return baseSize * ringMultiplier;
|
||||
}
|
||||
|
||||
function deterministicHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return (Math.abs(hash) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function buildHierarchyMaps(graph: KnowledgeGraph) {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
if (!CIRCLES_HIERARCHY_RELATIONS.has(rel.type)) continue;
|
||||
|
||||
if (!childrenByParent.has(rel.sourceId)) childrenByParent.set(rel.sourceId, []);
|
||||
childrenByParent.get(rel.sourceId)!.push(rel.targetId);
|
||||
|
||||
if (!parentsByChild.has(rel.targetId)) parentsByChild.set(rel.targetId, []);
|
||||
parentsByChild.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
|
||||
return { childrenByParent, parentsByChild };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parent-centred angular allocation
|
||||
//
|
||||
// Each parent's children are placed in an arc CENTRED on the parent's own
|
||||
// angle, with arc size proportional to child count. This prevents the
|
||||
// sequential-concatenation bias (where the largest group's arc centre drifts
|
||||
// to 90° / 270° regardless of where the parent sits) that caused top-bottom
|
||||
// crowding in the previous sequential allocation.
|
||||
//
|
||||
// Overlapping initial arcs are fine — the physics simulation's angular spread
|
||||
// force resolves them during the simulation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initParentCentredAngles(
|
||||
graph: KnowledgeGraph,
|
||||
parentsByChild: Map<string, string[]>,
|
||||
): Map<string, CirclesNodePosition> {
|
||||
const positions = new Map<string, CirclesNodePosition>();
|
||||
|
||||
// Group nodes by ring
|
||||
const nodesByRing: GraphNode[][] = Array.from({ length: RING_COUNT }, () => []);
|
||||
const nodeRingMap = new Map<string, number>();
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const ring = getNodeRing(node);
|
||||
if (ring >= 0 && ring < RING_COUNT) {
|
||||
nodesByRing[ring].push(node);
|
||||
nodeRingMap.set(node.id, ring);
|
||||
}
|
||||
}
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
// --- Ring 0: sorted alphabetically, evenly spaced around full circle ---
|
||||
const ring0Nodes = [...nodesByRing[0]].sort((a, b) =>
|
||||
a.properties.name.localeCompare(b.properties.name),
|
||||
);
|
||||
|
||||
if (ring0Nodes.length > 0) {
|
||||
const count = ring0Nodes.length;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const node = ring0Nodes[i];
|
||||
const angle = (i / count) * TWO_PI;
|
||||
const r = CIRCLES_RING_RADII[0];
|
||||
positions.set(node.id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(0, node.label),
|
||||
ring: 0,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rings 1-3: parent-centred arc placement ---
|
||||
for (let ring = 1; ring < RING_COUNT; ring++) {
|
||||
const ringNodes = nodesByRing[ring];
|
||||
if (ringNodes.length === 0) continue;
|
||||
|
||||
const r = CIRCLES_RING_RADII[ring];
|
||||
|
||||
// Find each node's primary parent: placed ancestor with highest ring index
|
||||
// (so a Method prefers its Class over a distant Package).
|
||||
const assignedParent = new Map<string, string>();
|
||||
for (const node of ringNodes) {
|
||||
const parents = parentsByChild.get(node.id) ?? [];
|
||||
let bestParent: string | null = null;
|
||||
let bestParentRing = -1;
|
||||
for (const p of parents) {
|
||||
if (!positions.has(p)) continue;
|
||||
const pRing = nodeRingMap.get(p) ?? -1;
|
||||
if (pRing > bestParentRing) {
|
||||
bestParentRing = pRing;
|
||||
bestParent = p;
|
||||
}
|
||||
}
|
||||
if (bestParent) assignedParent.set(node.id, bestParent);
|
||||
}
|
||||
|
||||
// Bucket into parent groups and orphans
|
||||
const childrenOfParent = new Map<string, GraphNode[]>();
|
||||
const orphans: GraphNode[] = [];
|
||||
|
||||
for (const node of ringNodes) {
|
||||
const p = assignedParent.get(node.id);
|
||||
if (!p) {
|
||||
orphans.push(node);
|
||||
} else {
|
||||
if (!childrenOfParent.has(p)) childrenOfParent.set(p, []);
|
||||
childrenOfParent.get(p)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const children of childrenOfParent.values()) {
|
||||
children.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
}
|
||||
orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
const totalParented = ringNodes.length - orphans.length;
|
||||
const parentedFraction = totalParented > 0 ? totalParented / ringNodes.length : 0;
|
||||
|
||||
// Place each parent's children in an arc centred on the parent's angle.
|
||||
// Arc size ∝ child count relative to all parented nodes.
|
||||
for (const [parentId, children] of childrenOfParent) {
|
||||
if (children.length === 0) continue;
|
||||
|
||||
const parentAngle = positions.get(parentId)?.angle ?? 0;
|
||||
const slotArc = (children.length / totalParented) * parentedFraction * TWO_PI;
|
||||
const startAngle = parentAngle - slotArc / 2;
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const angle = startAngle + (i + 0.5) * (slotArc / children.length);
|
||||
positions.set(children[i].id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(ring, children[i].label),
|
||||
ring,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Orphans: spread evenly in their proportional arc, centred at angle = π
|
||||
// (left side), away from the 0° / ±π boundary to avoid wrapping artefacts.
|
||||
if (orphans.length > 0) {
|
||||
const orphanFraction = orphans.length / ringNodes.length;
|
||||
const orphanArc = orphanFraction * TWO_PI;
|
||||
// Centre orphan arc at π so it doesn't overlap with the typical 0° cluster
|
||||
const orphanStart = Math.PI - orphanArc / 2;
|
||||
for (let i = 0; i < orphans.length; i++) {
|
||||
const angle = orphanStart + (i + 0.5) * (orphanArc / orphans.length);
|
||||
positions.set(orphans[i].id, {
|
||||
x: r * Math.cos(angle),
|
||||
y: r * Math.sin(angle),
|
||||
size: calculateNodeSize(ring, orphans[i].label),
|
||||
ring,
|
||||
angle,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Circles view layout: concentric rings with parent-centred angular allocation.
|
||||
*
|
||||
* Ring 0 (innermost) = Folders/Packages
|
||||
* Ring 1 = Files
|
||||
* Ring 2 = Classes/Interfaces
|
||||
* Ring 3 (outermost) = Functions/Methods/Variables
|
||||
*
|
||||
* Returns initial positions; the physics simulation in useSigma.ts refines
|
||||
* them using radial gravity + hard band clamping, angular spread, and 2D
|
||||
* repulsion — identical in structure to the tree-view physics.
|
||||
*/
|
||||
export function calculateCirclesLayout(graph: KnowledgeGraph): Map<string, CirclesNodePosition> {
|
||||
const { parentsByChild } = buildHierarchyMaps(graph);
|
||||
|
||||
// 1. Parent-centred angular allocation — no top/bottom bias
|
||||
const positions = initParentCentredAngles(graph, parentsByChild);
|
||||
|
||||
// 2. Subtle radial jitter only — angular jitter would fight the centred placement
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const jitter = (deterministicHash(nodeId + 'r') - 0.5) * 10; // ±10 px
|
||||
const r = CIRCLES_RING_RADII[pos.ring] + jitter;
|
||||
pos.x = r * Math.cos(pos.angle);
|
||||
pos.y = r * Math.sin(pos.angle);
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -101,7 +101,9 @@ export const getCommunityColor = (communityIndex: number): string => {
|
||||
return COMMUNITY_COLORS[communityIndex % COMMUNITY_COLORS.length];
|
||||
};
|
||||
|
||||
// Labels to show by default (hide imports and variables by default as they clutter)
|
||||
// Labels to show by default (hide imports by default as they clutter).
|
||||
// Property/Const are the Kotlin/Java equivalents of Variable — include them so
|
||||
// Kotlin repos don't appear to have no leaf nodes.
|
||||
export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
||||
'Project',
|
||||
'Package',
|
||||
@@ -111,6 +113,8 @@ export const DEFAULT_VISIBLE_LABELS: NodeLabel[] = [
|
||||
'Class',
|
||||
'Function',
|
||||
'Method',
|
||||
'Property', // Kotlin/Java fields (HAS_PROPERTY + DEFINES File→Property)
|
||||
'Const', // Top-level constants
|
||||
'Interface',
|
||||
'Enum',
|
||||
'Type',
|
||||
@@ -127,6 +131,8 @@ export const FILTERABLE_LABELS: NodeLabel[] = [
|
||||
'Function',
|
||||
'Method',
|
||||
'Variable',
|
||||
'Property', // Kotlin/Java field nodes
|
||||
'Const',
|
||||
'Decorator',
|
||||
'Import',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { knowledgeGraphToTreeGraphology, knowledgeGraphToCirclesGraphology } from './graph-adapter';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
import { EDGE_INFO } from './constants';
|
||||
|
||||
function makeNode(id: string, label: string, name: string): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: label as any,
|
||||
properties: { name, filePath: '', startLine: 1, endLine: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('knowledgeGraphToTreeGraphology', () => {
|
||||
it('should create a graph with tree layout', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('root', 'Project', 'MyProject'),
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'root', targetId: 'folder' },
|
||||
{ id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' },
|
||||
{ id: 'r3', type: 'CALLS', sourceId: 'file', targetId: 'root' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.hasNode('root')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('folder')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('file')).toBe(true);
|
||||
|
||||
const rootAttrs = sigmaGraph.getNodeAttributes('root');
|
||||
const folderAttrs = sigmaGraph.getNodeAttributes('folder');
|
||||
const fileAttrs = sigmaGraph.getNodeAttributes('file');
|
||||
|
||||
// Tree view is inverted vertically, so files sit above containers.
|
||||
expect(fileAttrs.y).toBeLessThan(rootAttrs.y);
|
||||
expect(fileAttrs.y).toBeLessThan(folderAttrs.y);
|
||||
|
||||
// Nodes should have reasonable sizes
|
||||
expect(rootAttrs.size).toBeGreaterThan(2);
|
||||
expect(folderAttrs.size).toBeGreaterThan(2);
|
||||
expect(fileAttrs.size).toBeGreaterThan(2);
|
||||
|
||||
expect(rootAttrs.treeAnchorX).toBe(rootAttrs.x);
|
||||
expect(rootAttrs.treeAnchorY).toBe(rootAttrs.y);
|
||||
expect(rootAttrs.treeLayer).toBe(0);
|
||||
expect(fileAttrs.treeLayer).toBe(1);
|
||||
});
|
||||
|
||||
it('should style hierarchy edges differently from cross-cutting edges', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
// MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive.
|
||||
expect(sigmaGraph.size).toBe(2);
|
||||
|
||||
const attrsByType = new Map<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
sigmaGraph.forEachEdge((_edge, attrs) => {
|
||||
attrsByType.set(attrs.relationType, attrs);
|
||||
});
|
||||
|
||||
const containsAttrs = attrsByType.get('CONTAINS');
|
||||
expect(containsAttrs).toBeDefined();
|
||||
expect(containsAttrs!.isHierarchyEdge).toBe(true);
|
||||
expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color);
|
||||
|
||||
const callsAttrs = attrsByType.get('CALLS');
|
||||
expect(callsAttrs).toBeDefined();
|
||||
expect(callsAttrs!.isHierarchyEdge).toBe(false);
|
||||
expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
|
||||
it('should treat imports as cross-cutting edges in tree view', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'File', 'b.ts')],
|
||||
relationships: [{ id: 'r1', type: 'IMPORTS', sourceId: 'a', targetId: 'b' }],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
sigmaGraph.forEachEdge((edge, attrs) => {
|
||||
if (attrs.relationType === 'IMPORTS') {
|
||||
expect(attrs.isHierarchyEdge).toBe(false);
|
||||
expect(attrs.color).toBe(EDGE_INFO.IMPORTS.color);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a medium-sized graph without dropping nodes or edges', () => {
|
||||
// 2000 nodes + 4000 edges — exercises the adaptive spring iteration path (14 iters).
|
||||
// Structural assertion only: wall-clock timing is too variable across CI machines.
|
||||
const nodes: GraphNode[] = Array.from({ length: 2000 }, (_, i) =>
|
||||
makeNode(`n${i}`, i % 4 === 0 ? 'Folder' : i % 4 === 1 ? 'File' : 'Function', `node${i}`),
|
||||
);
|
||||
const relationships = Array.from({ length: 4000 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
type: i % 3 === 0 ? 'CONTAINS' : 'CALLS',
|
||||
sourceId: `n${i % 2000}`,
|
||||
targetId: `n${(i + 7) % 2000}`,
|
||||
}));
|
||||
const graph: KnowledgeGraph = { nodes, relationships };
|
||||
|
||||
const sigmaGraph = knowledgeGraphToTreeGraphology(graph);
|
||||
|
||||
// All nodes that have a tree-layout position must be present in the output.
|
||||
expect(sigmaGraph.order).toBe(2000);
|
||||
// Every relationship whose source and target both exist should produce an edge.
|
||||
// Self-loops (sourceId === targetId) are excluded — the adapter skips them.
|
||||
const selfLoops = relationships.filter((r) => r.sourceId === r.targetId).length;
|
||||
expect(sigmaGraph.size).toBe(relationships.length - selfLoops);
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledgeGraphToCirclesGraphology', () => {
|
||||
it('should place nodes into ring positions based on their type', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('fn', 'Function', 'doSomething'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'file' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'file', targetId: 'fn' },
|
||||
],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.hasNode('folder')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('file')).toBe(true);
|
||||
expect(sigmaGraph.hasNode('fn')).toBe(true);
|
||||
|
||||
// Each node carries its ring index and anchor coordinates
|
||||
const folderAttrs = sigmaGraph.getNodeAttributes('folder');
|
||||
const fileAttrs = sigmaGraph.getNodeAttributes('file');
|
||||
const fnAttrs = sigmaGraph.getNodeAttributes('fn');
|
||||
|
||||
expect(typeof folderAttrs.circlesRing).toBe('number');
|
||||
expect(typeof folderAttrs.circlesAnchorX).toBe('number');
|
||||
expect(typeof folderAttrs.circlesAnchorY).toBe('number');
|
||||
|
||||
// Folders/Packages live in ring 0 (innermost); Files in ring 1; Functions in ring 3.
|
||||
expect(folderAttrs.circlesRing).toBe(0);
|
||||
expect(fileAttrs.circlesRing).toBe(1);
|
||||
expect(fnAttrs.circlesRing).toBe(3);
|
||||
|
||||
// Tree anchor attributes must NOT be set in circles mode
|
||||
expect(folderAttrs.treeAnchorX).toBeUndefined();
|
||||
expect(folderAttrs.treeAnchorY).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should style hierarchy edges differently from cross-cutting edges', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'File', 'a.ts'), makeNode('b', 'Function', 'fn')],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'a', targetId: 'b' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'a', targetId: 'b' },
|
||||
],
|
||||
};
|
||||
|
||||
// MultiGraph allows multiple edges per pair — both CONTAINS and CALLS must survive.
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
expect(sigmaGraph.size).toBe(2);
|
||||
|
||||
const attrsByType = new Map<string, { isHierarchyEdge?: boolean; color: string }>();
|
||||
sigmaGraph.forEachEdge((_, attrs) => {
|
||||
attrsByType.set(attrs.relationType, attrs);
|
||||
});
|
||||
|
||||
const containsAttrs = attrsByType.get('CONTAINS');
|
||||
expect(containsAttrs).toBeDefined();
|
||||
expect(containsAttrs!.isHierarchyEdge).toBe(true);
|
||||
expect(containsAttrs!.color).toBe(EDGE_INFO.CONTAINS.color);
|
||||
|
||||
const callsAttrs = attrsByType.get('CALLS');
|
||||
expect(callsAttrs).toBeDefined();
|
||||
expect(callsAttrs!.isHierarchyEdge).toBe(false);
|
||||
expect(callsAttrs!.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
|
||||
it('should treat CALLS as a cross-cutting edge in circles view', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [makeNode('a', 'Function', 'fnA'), makeNode('b', 'Function', 'fnB')],
|
||||
relationships: [{ id: 'r1', type: 'CALLS', sourceId: 'a', targetId: 'b' }],
|
||||
};
|
||||
|
||||
const sigmaGraph = knowledgeGraphToCirclesGraphology(graph);
|
||||
|
||||
sigmaGraph.forEachEdge((_, attrs) => {
|
||||
expect(attrs.isHierarchyEdge).toBe(false);
|
||||
expect(attrs.color).toBe(EDGE_INFO.CALLS.color);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import Graph from 'graphology';
|
||||
import Graph, { MultiGraph } from 'graphology';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import { NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
import { EDGE_INFO, NODE_COLORS, NODE_SIZES, getCommunityColor } from './constants';
|
||||
import { calculateTreeLayout } from './tree-layout';
|
||||
import { calculateCirclesLayout } from './circles-layout';
|
||||
|
||||
export interface SigmaNodeAttributes {
|
||||
x: number;
|
||||
@@ -17,6 +19,13 @@ export interface SigmaNodeAttributes {
|
||||
zIndex?: number;
|
||||
highlighted?: boolean;
|
||||
mass?: number; // ForceAtlas2 mass - higher = more repulsion
|
||||
treeAnchorX?: number;
|
||||
treeAnchorY?: number;
|
||||
treeLayer?: number;
|
||||
circlesAnchorX?: number;
|
||||
circlesAnchorY?: number;
|
||||
circlesRing?: number;
|
||||
circlesAnchorAngle?: number;
|
||||
community?: number; // Community index from Leiden algorithm
|
||||
communityColor?: string; // Color assigned by community
|
||||
}
|
||||
@@ -28,6 +37,7 @@ export interface SigmaEdgeAttributes {
|
||||
type?: string;
|
||||
curvature?: number;
|
||||
zIndex?: number;
|
||||
isHierarchyEdge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,18 +101,21 @@ export const knowledgeGraphToGraphology = (
|
||||
// Build parent-child map from hierarchy relationships
|
||||
// CONTAINS: Folder -> File
|
||||
// DEFINES: File -> Function/Class/Interface/Method
|
||||
// IMPORTS: File -> Import
|
||||
// parent -> children
|
||||
// parent -> children (used only for initial spatial seeding before FA2 runs)
|
||||
const parentToChildren = new Map<string, string[]>();
|
||||
// child -> parent
|
||||
const childToParent = new Map<string, string>();
|
||||
|
||||
const hierarchyRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
// IMPORTS is not a true structural hierarchy, but treating it as a spatial
|
||||
// seed helps FA2 converge for import-heavy codebases: files that import each
|
||||
// other start near each other, so the simulation doesn't have to close many
|
||||
// long cross-package springs from scratch.
|
||||
const spatialSeedRelations = new Set(['CONTAINS', 'DEFINES', 'IMPORTS']);
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
// These relationships represent parent-child hierarchy for positioning
|
||||
if (hierarchyRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target, so source is parent
|
||||
// These relationships determine initial node positions (not graph semantics)
|
||||
if (spatialSeedRelations.has(rel.type)) {
|
||||
// source CONTAINS/DEFINES/IMPORTS target → source acts as spatial parent
|
||||
if (!parentToChildren.has(rel.sourceId)) {
|
||||
parentToChildren.set(rel.sourceId, []);
|
||||
}
|
||||
@@ -295,23 +308,202 @@ export const knowledgeGraphToGraphology = (
|
||||
// TYPE RELATIONSHIPS - Warm colors (OOP)
|
||||
EXTENDS: { color: '#c2410c', sizeMultiplier: 1.0 }, // Orange - extension
|
||||
IMPLEMENTS: { color: '#be185d', sizeMultiplier: 0.9 }, // Pink - interface implementation
|
||||
|
||||
// KOTLIN/JAVA HIERARCHY — same hues as their logical equivalents so force
|
||||
// mode renders these consistently with tree/circles view.
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.4 }, // Class→Method (≈ DEFINES)
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.35 }, // Class→Property (≈ CONTAINS)
|
||||
};
|
||||
|
||||
// Two-pass insertion so hierarchy/DEFINES edges are drawn first (behind)
|
||||
// and cross-edges (CALLS, IMPORTS, EXTENDS) are drawn on top.
|
||||
const BACKGROUND_EDGE_TYPES = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']);
|
||||
|
||||
const addEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (graph.hasEdge(rel.sourceId, rel.targetId)) return;
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature,
|
||||
});
|
||||
};
|
||||
|
||||
// Pass 1: background (hierarchy) edges — rendered behind
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
// Pass 2: foreground (cross) edges — rendered on top
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (!BACKGROUND_EDGE_TYPES.has(rel.type)) addEdge(rel);
|
||||
});
|
||||
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToTreeGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
const positions = calculateTreeLayout(knowledgeGraph);
|
||||
|
||||
// Add nodes with tree positions
|
||||
for (const node of knowledgeGraph.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
const finalSize = Math.max(2, pos.size * (scaledSize / baseSize));
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: finalSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: 1, // No force layout in tree view
|
||||
treeAnchorX: pos.x,
|
||||
treeAnchorY: pos.y,
|
||||
treeLayer: pos.depth,
|
||||
});
|
||||
}
|
||||
|
||||
// Add edges with tree-specific styling
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
const HIERARCHY_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 },
|
||||
DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 }, // Kotlin Class→Method hierarchy
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 }, // Kotlin Class→Property hierarchy
|
||||
};
|
||||
|
||||
const CROSS_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 },
|
||||
CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 },
|
||||
EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 },
|
||||
IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 },
|
||||
};
|
||||
|
||||
// Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top.
|
||||
// Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive.
|
||||
const addedTreeRelIds = new Set<string>();
|
||||
const addTreeEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (addedTreeRelIds.has(rel.id)) return;
|
||||
addedTreeRelIds.add(rel.id);
|
||||
const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined;
|
||||
const style = isHierarchy
|
||||
? HIERARCHY_EDGE_STYLES[rel.type]
|
||||
: CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: 0.1 + Math.random() * 0.1,
|
||||
isHierarchyEdge: isHierarchy,
|
||||
});
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (graph.hasNode(rel.sourceId) && graph.hasNode(rel.targetId)) {
|
||||
if (!graph.hasEdge(rel.sourceId, rel.targetId)) {
|
||||
const style = EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
const curvature = 0.12 + Math.random() * 0.08;
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addTreeEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addTreeEdge(rel);
|
||||
});
|
||||
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: curvature,
|
||||
});
|
||||
}
|
||||
}
|
||||
return graph;
|
||||
};
|
||||
|
||||
export const knowledgeGraphToCirclesGraphology = (
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
): Graph<SigmaNodeAttributes, SigmaEdgeAttributes> => {
|
||||
const graph = new MultiGraph<SigmaNodeAttributes, SigmaEdgeAttributes>();
|
||||
const nodeCount = knowledgeGraph.nodes.length;
|
||||
const positions = calculateCirclesLayout(knowledgeGraph);
|
||||
|
||||
for (const node of knowledgeGraph.nodes) {
|
||||
const pos = positions.get(node.id);
|
||||
if (!pos) continue;
|
||||
|
||||
const baseSize = NODE_SIZES[node.label] || 8;
|
||||
const scaledSize = getScaledNodeSize(baseSize, nodeCount);
|
||||
const finalSize = Math.max(2, pos.size * (scaledSize / baseSize));
|
||||
|
||||
graph.addNode(node.id, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: finalSize,
|
||||
color: NODE_COLORS[node.label] || '#9ca3af',
|
||||
label: node.properties.name,
|
||||
nodeType: node.label,
|
||||
filePath: node.properties.filePath,
|
||||
startLine: node.properties.startLine,
|
||||
endLine: node.properties.endLine,
|
||||
hidden: false,
|
||||
mass: 1,
|
||||
circlesAnchorX: pos.x,
|
||||
circlesAnchorY: pos.y,
|
||||
circlesRing: pos.ring,
|
||||
circlesAnchorAngle: pos.angle,
|
||||
});
|
||||
}
|
||||
|
||||
const edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 1.0;
|
||||
|
||||
// Reuse the same edge style maps as tree view
|
||||
const HIERARCHY_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
CONTAINS: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.3 },
|
||||
DEFINES: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_METHOD: { color: EDGE_INFO.DEFINES.color, sizeMultiplier: 0.3 },
|
||||
HAS_PROPERTY: { color: EDGE_INFO.CONTAINS.color, sizeMultiplier: 0.25 },
|
||||
};
|
||||
|
||||
const CROSS_EDGE_STYLES: Record<string, { color: string; sizeMultiplier: number }> = {
|
||||
IMPORTS: { color: EDGE_INFO.IMPORTS.color, sizeMultiplier: 0.6 },
|
||||
CALLS: { color: EDGE_INFO.CALLS.color, sizeMultiplier: 0.8 },
|
||||
EXTENDS: { color: EDGE_INFO.EXTENDS.color, sizeMultiplier: 1.0 },
|
||||
IMPLEMENTS: { color: EDGE_INFO.IMPLEMENTS.color, sizeMultiplier: 0.9 },
|
||||
};
|
||||
|
||||
// Two-pass insertion: hierarchy edges first (rendered behind), cross-edges on top.
|
||||
// Dedup by relationship ID so CONTAINS + CALLS between the same pair both survive.
|
||||
const addedCirclesRelIds = new Set<string>();
|
||||
const addCirclesEdge = (rel: (typeof knowledgeGraph.relationships)[number]) => {
|
||||
if (!graph.hasNode(rel.sourceId) || !graph.hasNode(rel.targetId)) return;
|
||||
if (addedCirclesRelIds.has(rel.id)) return;
|
||||
addedCirclesRelIds.add(rel.id);
|
||||
const isHierarchy = HIERARCHY_EDGE_STYLES[rel.type] !== undefined;
|
||||
const style = isHierarchy
|
||||
? HIERARCHY_EDGE_STYLES[rel.type]
|
||||
: CROSS_EDGE_STYLES[rel.type] || { color: '#4a4a5a', sizeMultiplier: 0.5 };
|
||||
graph.addEdge(rel.sourceId, rel.targetId, {
|
||||
size: edgeBaseSize * style.sizeMultiplier,
|
||||
color: style.color,
|
||||
relationType: rel.type,
|
||||
type: 'curved',
|
||||
curvature: 0.1 + Math.random() * 0.1,
|
||||
isHierarchyEdge: isHierarchy,
|
||||
});
|
||||
};
|
||||
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] !== undefined) addCirclesEdge(rel);
|
||||
});
|
||||
knowledgeGraph.relationships.forEach((rel) => {
|
||||
if (HIERARCHY_EDGE_STYLES[rel.type] === undefined) addCirclesEdge(rel);
|
||||
});
|
||||
|
||||
return graph;
|
||||
|
||||
@@ -79,6 +79,7 @@ export {
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MousePointerClick,
|
||||
Network,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
@@ -123,6 +124,67 @@ export {
|
||||
* defaults to `currentColor`, so Tailwind `text-*` utilities work the same as
|
||||
* with any other icon in this module.
|
||||
*/
|
||||
/**
|
||||
* GitLab tanuki mark — SVG path data from simple-icons (CC0-1.0).
|
||||
*
|
||||
* GitLab's logo (the tanuki/fox-head) is a registered trademark of GitLab Inc.
|
||||
* We use it here only to indicate GitLab source-repo integration.
|
||||
*
|
||||
* API-compatible with `lucide-react` icons (`LucideProps`).
|
||||
*/
|
||||
export const Gitlab = forwardRef<SVGSVGElement, LucideProps>(function Gitlab(
|
||||
{
|
||||
size = 24,
|
||||
color = 'currentColor',
|
||||
className,
|
||||
strokeWidth: _strokeWidth,
|
||||
absoluteStrokeWidth: _absoluteStrokeWidth,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const numericSize = typeof size === 'string' ? Number.parseFloat(size) : size;
|
||||
const useSmallVariant = Number.isFinite(numericSize) && (numericSize as number) <= 16;
|
||||
|
||||
if (useSmallVariant) {
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill={color}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
|
||||
<path d="M8 15.282L6.145 9.565H2.333L8 15.282z" />
|
||||
<path d="M2.333 9.565l-.944-2.942c-.09-.267.067-.553.333-.553h3.153L2.333 9.565z" />
|
||||
<path d="M4.875 6.07L6.145 9.565H2.333l2.542-3.495z" />
|
||||
<path d="M13.667 9.565l.944-2.942c.09-.267-.067-.553-.333-.553h-3.153l2.542 3.495z" />
|
||||
<path d="M11.125 6.07L9.855 9.565h3.812l-2.542-3.495z" />
|
||||
<path d="M8 15.282l1.855-5.717H6.145L8 15.282z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={ref}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path d="m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z" />
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
export const Github = forwardRef<SVGSVGElement, LucideProps>(function Github(
|
||||
{
|
||||
size = 24,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateTreeLayout } from './tree-layout';
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode } from 'gitnexus-shared';
|
||||
|
||||
function makeNode(id: string, label: string, name: string): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label: label as any,
|
||||
properties: { name, filePath: '', startLine: 1, endLine: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('calculateTreeLayout', () => {
|
||||
it('should place different types in correct layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('f1', 'Folder', 'src'),
|
||||
makeNode('file1', 'File', 'main.ts'),
|
||||
makeNode('cls1', 'Class', 'MyClass'),
|
||||
makeNode('fn1', 'Function', 'myFunc'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
const folderY = positions.get('f1')!.y;
|
||||
const fileY = positions.get('file1')!.y;
|
||||
const classY = positions.get('cls1')!.y;
|
||||
const funcY = positions.get('fn1')!.y;
|
||||
|
||||
// Layer ordering is visually inverted in tree view:
|
||||
// Function < Class < File < Folder
|
||||
expect(funcY).toBeLessThan(classY);
|
||||
expect(classY).toBeLessThan(fileY);
|
||||
expect(fileY).toBeLessThan(folderY);
|
||||
});
|
||||
|
||||
it('should arrange many same-type nodes in a grid within a layer', () => {
|
||||
const nodes: GraphNode[] = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
nodes.push(makeNode(`fn${i}`, 'Function', `func${i}`));
|
||||
}
|
||||
|
||||
const graph: KnowledgeGraph = { nodes, relationships: [] };
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
const xValues = nodes.map((n) => positions.get(n.id)!.x);
|
||||
const yValues = nodes.map((n) => positions.get(n.id)!.y);
|
||||
|
||||
// Should have multiple columns (spread horizontally)
|
||||
const uniqueX = [...new Set(xValues)].sort((a, b) => a - b);
|
||||
expect(uniqueX.length).toBeGreaterThan(3);
|
||||
|
||||
// Should have multiple rows (spread vertically within layer)
|
||||
const uniqueY = [...new Set(yValues)].sort((a, b) => a - b);
|
||||
expect(uniqueY.length).toBeGreaterThan(1);
|
||||
|
||||
// Overall width should be significant
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
expect(maxX - minX).toBeGreaterThan(500);
|
||||
|
||||
// Height spread within layer should be moderate (not a single line)
|
||||
const minY = Math.min(...yValues);
|
||||
const maxY = Math.max(...yValues);
|
||||
expect(maxY - minY).toBeGreaterThan(50);
|
||||
expect(maxY - minY).toBeLessThan(250); // But not too tall
|
||||
});
|
||||
|
||||
it('should sort nodes alphabetically within layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('z', 'Function', 'zFn'),
|
||||
makeNode('a', 'Function', 'aFn'),
|
||||
makeNode('m', 'Function', 'mFn'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
// In grid layout, 'a' should appear before 'm' and 'z' in reading order
|
||||
// (left-to-right, top-to-bottom)
|
||||
const aPos = positions.get('a')!;
|
||||
const mPos = positions.get('m')!;
|
||||
const zPos = positions.get('z')!;
|
||||
|
||||
// Reading order: a comes before m, which comes before z
|
||||
const aIndex = aPos.y * 10000 + aPos.x;
|
||||
const mIndex = mPos.y * 10000 + mPos.x;
|
||||
const zIndex = zPos.y * 10000 + zPos.x;
|
||||
|
||||
expect(aIndex).toBeLessThan(mIndex);
|
||||
expect(mIndex).toBeLessThan(zIndex);
|
||||
});
|
||||
|
||||
it('should place multiple node types in correct layers', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('iface', 'Interface', 'MyInterface'),
|
||||
makeNode('enum', 'Enum', 'MyEnum'),
|
||||
makeNode('method', 'Method', 'myMethod'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
// Folder now appears below files/types/methods in the inverted tree view
|
||||
expect(positions.get('file')!.y).toBeLessThan(positions.get('folder')!.y);
|
||||
|
||||
// File (layer 1) should be below Class/Interface/Enum (layer 2)
|
||||
expect(positions.get('iface')!.y).toBeLessThan(positions.get('file')!.y);
|
||||
expect(positions.get('enum')!.y).toBeLessThan(positions.get('file')!.y);
|
||||
|
||||
// Interface/Enum (layer 2) should be below Method (layer 3)
|
||||
expect(positions.get('method')!.y).toBeLessThan(positions.get('iface')!.y);
|
||||
expect(positions.get('method')!.y).toBeLessThan(positions.get('enum')!.y);
|
||||
});
|
||||
|
||||
it('should keep node sizes reasonable', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'src'),
|
||||
makeNode('file', 'File', 'main.ts'),
|
||||
makeNode('fn', 'Function', 'myFunc'),
|
||||
],
|
||||
relationships: [],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
|
||||
for (const id of ['folder', 'file', 'fn']) {
|
||||
expect(positions.get(id)!.size).toBeGreaterThan(2);
|
||||
expect(positions.get(id)!.size).toBeLessThan(25);
|
||||
}
|
||||
});
|
||||
|
||||
it('should spread sibling branches under their structural parent in auto mode', () => {
|
||||
const graph: KnowledgeGraph = {
|
||||
nodes: [
|
||||
makeNode('folder', 'Folder', 'apps'),
|
||||
makeNode('fileA', 'File', 'a.ts'),
|
||||
makeNode('fileB', 'File', 'b.ts'),
|
||||
makeNode('fileC', 'File', 'c.ts'),
|
||||
makeNode('fnA', 'Function', 'fnA'),
|
||||
makeNode('fnB', 'Function', 'fnB'),
|
||||
makeNode('fnC', 'Function', 'fnC'),
|
||||
],
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileA' },
|
||||
{ id: 'r2', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileB' },
|
||||
{ id: 'r3', type: 'CONTAINS', sourceId: 'folder', targetId: 'fileC' },
|
||||
{ id: 'r4', type: 'DEFINES', sourceId: 'fileA', targetId: 'fnA' },
|
||||
{ id: 'r5', type: 'DEFINES', sourceId: 'fileB', targetId: 'fnB' },
|
||||
{ id: 'r6', type: 'DEFINES', sourceId: 'fileC', targetId: 'fnC' },
|
||||
],
|
||||
};
|
||||
|
||||
const positions = calculateTreeLayout(graph);
|
||||
const fileXs = ['fileA', 'fileB', 'fileC'].map((id) => positions.get(id)!.x);
|
||||
const fnXs = ['fnA', 'fnB', 'fnC'].map((id) => positions.get(id)!.x);
|
||||
|
||||
expect(Math.max(...fileXs) - Math.min(...fileXs)).toBeGreaterThan(120);
|
||||
expect(Math.max(...fnXs) - Math.min(...fnXs)).toBeGreaterThan(120);
|
||||
expect(Math.abs(positions.get('fileA')!.x - positions.get('fnA')!.x)).toBeLessThan(120);
|
||||
expect(Math.abs(positions.get('fileB')!.x - positions.get('fnB')!.x)).toBeLessThan(120);
|
||||
expect(Math.abs(positions.get('fileC')!.x - positions.get('fnC')!.x)).toBeLessThan(120);
|
||||
});
|
||||
|
||||
it('should let long edges pull connected nodes closer without breaking their layer', () => {
|
||||
const nodes = Array.from({ length: 10 }, (_, i) => makeNode(`fn${i}`, 'Function', `fn${i}`));
|
||||
|
||||
const baseline = calculateTreeLayout({ nodes, relationships: [] });
|
||||
const relaxed = calculateTreeLayout({
|
||||
nodes,
|
||||
relationships: [
|
||||
{ id: 'r1', type: 'CALLS', sourceId: 'fn0', targetId: 'fn9' },
|
||||
{ id: 'r2', type: 'CALLS', sourceId: 'fn1', targetId: 'fn8' },
|
||||
],
|
||||
});
|
||||
|
||||
const baselineDistance = Math.abs(baseline.get('fn0')!.x - baseline.get('fn9')!.x);
|
||||
const relaxedDistance = Math.abs(relaxed.get('fn0')!.x - relaxed.get('fn9')!.x);
|
||||
expect(relaxedDistance).toBeLessThan(baselineDistance);
|
||||
|
||||
const relaxedYValues = nodes.map((node) => relaxed.get(node.id)!.y);
|
||||
const minY = Math.min(...relaxedYValues);
|
||||
const maxY = Math.max(...relaxedYValues);
|
||||
expect(maxY - minY).toBeGreaterThan(50);
|
||||
expect(maxY - minY).toBeLessThan(250);
|
||||
});
|
||||
|
||||
it('should preserve layer spread under heavy edge attraction', () => {
|
||||
const nodes: GraphNode[] = [makeNode('file', 'File', 'hub.ts')];
|
||||
for (let i = 0; i < 18; i++) {
|
||||
nodes.push(makeNode(`fn${i}`, 'Function', `fn${i}`));
|
||||
}
|
||||
|
||||
const relationships = Array.from({ length: 18 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
type: 'CALLS',
|
||||
sourceId: `fn${i}`,
|
||||
targetId: 'file',
|
||||
}));
|
||||
|
||||
const positions = calculateTreeLayout({ nodes, relationships });
|
||||
const functionXs = Array.from({ length: 18 }, (_, i) => positions.get(`fn${i}`)!.x);
|
||||
|
||||
expect(Math.max(...functionXs) - Math.min(...functionXs)).toBeGreaterThan(280);
|
||||
expect(positions.get('file')!.y).toBeGreaterThan(positions.get('fn0')!.y);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
import type { KnowledgeGraph } from '../core/graph/types';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import { NODE_SIZES } from './constants';
|
||||
|
||||
export interface TreeNodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps node types to display layers in the tree view.
|
||||
* Layer 0 = top (containers), Layer 3 = bottom (functions/methods).
|
||||
*/
|
||||
const TYPE_TO_LAYER: Record<string, number> = {
|
||||
// Layer 0: Structural containers
|
||||
Project: 0,
|
||||
Package: 0,
|
||||
Module: 0,
|
||||
Folder: 0,
|
||||
Namespace: 0,
|
||||
|
||||
// Layer 1: Files
|
||||
File: 1,
|
||||
Section: 1,
|
||||
Import: 1,
|
||||
Route: 1,
|
||||
Tool: 1,
|
||||
|
||||
// Layer 2: Type definitions
|
||||
Class: 2,
|
||||
Interface: 2,
|
||||
Enum: 2,
|
||||
Type: 2,
|
||||
Struct: 2,
|
||||
Trait: 2,
|
||||
Union: 2,
|
||||
Record: 2,
|
||||
Typedef: 2,
|
||||
Template: 2,
|
||||
TypeAlias: 2,
|
||||
|
||||
// Layer 3: Functions / Methods
|
||||
Function: 3,
|
||||
Method: 3,
|
||||
Impl: 3,
|
||||
Delegate: 3,
|
||||
Constructor: 3,
|
||||
Variable: 3,
|
||||
Const: 3,
|
||||
Static: 3,
|
||||
Property: 3,
|
||||
Decorator: 3,
|
||||
Annotation: 3,
|
||||
Macro: 3,
|
||||
CodeElement: 3,
|
||||
};
|
||||
|
||||
/** Fallback layer for unmapped types. */
|
||||
const DEFAULT_LAYER = 1;
|
||||
|
||||
/** Virtual canvas size for layout calculation. */
|
||||
const CANVAS_WIDTH = 1200;
|
||||
const CANVAS_HEIGHT = 800;
|
||||
const LAYER_COUNT = 4;
|
||||
const LAYER_HEIGHT = CANVAS_HEIGHT / LAYER_COUNT; // 200
|
||||
const PADDING_X = 60;
|
||||
const PADDING_Y = 15;
|
||||
const MIN_NODE_GAP = 45;
|
||||
const MAX_LAYER_ROW_SPREAD = 132;
|
||||
// HAS_METHOD and HAS_PROPERTY are Kotlin/Java-style hierarchy edges
|
||||
// (Class→Method, Class→Property). Treat them like DEFINES for layout purposes
|
||||
// so Methods/Properties cluster beneath their parent Class horizontally.
|
||||
const HIERARCHY_RELATIONS = new Set(['CONTAINS', 'DEFINES', 'HAS_METHOD', 'HAS_PROPERTY']);
|
||||
const MAX_X = (CANVAS_WIDTH - PADDING_X * 2) / 2;
|
||||
|
||||
const RELATION_SPRING_WEIGHTS: Record<string, number> = {
|
||||
CONTAINS: 0.12,
|
||||
DEFINES: 0.16,
|
||||
HAS_METHOD: 0.16, // Same as DEFINES — keeps methods near their class
|
||||
HAS_PROPERTY: 0.14, // Slightly weaker — properties can spread more
|
||||
IMPORTS: 0.2,
|
||||
CALLS: 0.24,
|
||||
EXTENDS: 0.18,
|
||||
IMPLEMENTS: 0.18,
|
||||
};
|
||||
|
||||
function calculateNodeSize(layer: number, nodeType: NodeLabel): number {
|
||||
const baseSize = NODE_SIZES[nodeType] || 6;
|
||||
const layerMultiplier = Math.max(0.6, 1 - layer * 0.12);
|
||||
return baseSize * layerMultiplier;
|
||||
}
|
||||
|
||||
function deterministicHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return (Math.abs(hash) % 10000) / 10000;
|
||||
}
|
||||
|
||||
function getNodeLayer(node: GraphNode): number {
|
||||
return TYPE_TO_LAYER[node.label] ?? DEFAULT_LAYER;
|
||||
}
|
||||
|
||||
function buildHierarchyMaps(graph: KnowledgeGraph) {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
const parentsByChild = new Map<string, string[]>();
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
if (!HIERARCHY_RELATIONS.has(rel.type)) continue;
|
||||
|
||||
if (!childrenByParent.has(rel.sourceId)) {
|
||||
childrenByParent.set(rel.sourceId, []);
|
||||
}
|
||||
childrenByParent.get(rel.sourceId)!.push(rel.targetId);
|
||||
|
||||
if (!parentsByChild.has(rel.targetId)) {
|
||||
parentsByChild.set(rel.targetId, []);
|
||||
}
|
||||
parentsByChild.get(rel.targetId)!.push(rel.sourceId);
|
||||
}
|
||||
|
||||
return { childrenByParent, parentsByChild };
|
||||
}
|
||||
|
||||
function buildLayerNodeIds(graph: KnowledgeGraph): string[][] {
|
||||
const nodeIdsByLayer: string[][] = Array.from({ length: LAYER_COUNT }, () => []);
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
const layer = getNodeLayer(node);
|
||||
if (layer >= 0 && layer < LAYER_COUNT) {
|
||||
nodeIdsByLayer[layer].push(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
return nodeIdsByLayer;
|
||||
}
|
||||
|
||||
function getRestEdgeLength(
|
||||
relationType: string,
|
||||
source: TreeNodePosition,
|
||||
target: TreeNodePosition,
|
||||
) {
|
||||
const depthGap = Math.abs(source.depth - target.depth);
|
||||
const baseLength = HIERARCHY_RELATIONS.has(relationType) ? 60 : 85;
|
||||
return baseLength + depthGap * 40;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getLayerRowOffsets(nodeCount: number): number[] {
|
||||
if (nodeCount <= 4) return [0];
|
||||
|
||||
const rowCount = nodeCount <= 16 ? 2 : 3;
|
||||
const totalSpread = rowCount === 2 ? 72 : MAX_LAYER_ROW_SPREAD;
|
||||
const rowGap = totalSpread / (rowCount - 1);
|
||||
|
||||
return Array.from({ length: rowCount }, (_, rowIndex) => -totalSpread / 2 + rowIndex * rowGap);
|
||||
}
|
||||
|
||||
function placeNodesInSlice(
|
||||
positions: Map<string, TreeNodePosition>,
|
||||
nodes: GraphNode[],
|
||||
startX: number,
|
||||
slotWidth: number,
|
||||
layerY: number,
|
||||
layer: number,
|
||||
) {
|
||||
const rowOffsets = getLayerRowOffsets(nodes.length);
|
||||
const rowCount = rowOffsets.length;
|
||||
const baseNodesPerRow = Math.floor(nodes.length / rowCount);
|
||||
const remainder = nodes.length % rowCount;
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
|
||||
const nodesInRow = baseNodesPerRow + (rowIndex < remainder ? 1 : 0);
|
||||
if (nodesInRow === 0) continue;
|
||||
|
||||
const rowSpacing = slotWidth / nodesInRow;
|
||||
for (let i = 0; i < nodesInRow; i++) {
|
||||
const node = nodes[cursor++];
|
||||
positions.set(node.id, {
|
||||
x: startX + (i + 0.5) * rowSpacing,
|
||||
y: layerY + rowOffsets[rowIndex],
|
||||
size: calculateNodeSize(layer, node.label),
|
||||
depth: layer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceLayerSpacing(
|
||||
layerNodeIds: string[],
|
||||
positions: Map<string, TreeNodePosition>,
|
||||
anchorXByNode: Map<string, number>,
|
||||
) {
|
||||
if (layerNodeIds.length < 2) return;
|
||||
|
||||
const sortedIds = [...layerNodeIds].sort((a, b) => positions.get(a)!.x - positions.get(b)!.x);
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
for (let i = 1; i < sortedIds.length; i++) {
|
||||
const prev = positions.get(sortedIds[i - 1])!;
|
||||
const curr = positions.get(sortedIds[i])!;
|
||||
const minGap = Math.max(MIN_NODE_GAP * 0.65, (prev.size + curr.size) * 1.7);
|
||||
const gap = curr.x - prev.x;
|
||||
|
||||
if (gap < minGap) {
|
||||
const push = (minGap - gap) / 2;
|
||||
prev.x -= push;
|
||||
curr.x += push;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = sortedIds.length - 2; i >= 0; i--) {
|
||||
const curr = positions.get(sortedIds[i])!;
|
||||
const next = positions.get(sortedIds[i + 1])!;
|
||||
const minGap = Math.max(MIN_NODE_GAP * 0.65, (curr.size + next.size) * 1.7);
|
||||
const gap = next.x - curr.x;
|
||||
|
||||
if (gap < minGap) {
|
||||
const push = (minGap - gap) / 2;
|
||||
curr.x -= push;
|
||||
next.x += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const anchorCenter =
|
||||
sortedIds.reduce((sum, nodeId) => sum + (anchorXByNode.get(nodeId) ?? 0), 0) / sortedIds.length;
|
||||
const currentCenter =
|
||||
sortedIds.reduce((sum, nodeId) => sum + positions.get(nodeId)!.x, 0) / sortedIds.length;
|
||||
const recenterDelta = currentCenter - anchorCenter;
|
||||
|
||||
for (const nodeId of sortedIds) {
|
||||
const pos = positions.get(nodeId)!;
|
||||
pos.x = clamp(pos.x - recenterDelta, -MAX_X, MAX_X);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize positions using proportional X allocation.
|
||||
*
|
||||
* Each parent in layer N is allocated a horizontal slice proportional to how
|
||||
* many direct hierarchy children it has in layer N+1. Children are then placed
|
||||
* evenly within their parent's slice. Orphan nodes (no placed hierarchy parent)
|
||||
* fill a proportional slice at the far right.
|
||||
*
|
||||
* Why this is better than uniform distribution:
|
||||
* 1. Dense parents (many children) get more canvas space → no artificial
|
||||
* crowding in the centre even before the physics simulation runs.
|
||||
* 2. Each child starts within its parent's X slice → parent-child edges are
|
||||
* short by construction, so the spring system converges quickly.
|
||||
* 3. Orphan nodes land at the right end; their spring connections pull them
|
||||
* toward better positions at runtime without fighting a spread force.
|
||||
*/
|
||||
function initProportionalPositions(
|
||||
graph: KnowledgeGraph,
|
||||
parentsByChild: Map<string, string[]>,
|
||||
): Map<string, TreeNodePosition> {
|
||||
const positions = new Map<string, TreeNodePosition>();
|
||||
|
||||
// Group nodes by layer and build a fast layer-lookup map.
|
||||
const nodesByLayer: GraphNode[][] = Array.from({ length: LAYER_COUNT }, () => []);
|
||||
const nodeLayerMap = new Map<string, number>();
|
||||
for (const node of graph.nodes) {
|
||||
const layer = getNodeLayer(node);
|
||||
if (layer >= 0 && layer < LAYER_COUNT) {
|
||||
nodesByLayer[layer].push(node);
|
||||
nodeLayerMap.set(node.id, layer);
|
||||
}
|
||||
}
|
||||
|
||||
const availableWidth = CANVAS_WIDTH - PADDING_X * 2;
|
||||
const halfWidth = availableWidth / 2;
|
||||
const availableHeight = LAYER_HEIGHT - PADDING_Y * 2;
|
||||
|
||||
// Y centre for a given logical layer (layer 0 = top).
|
||||
const getLayerY = (layer: number): number => {
|
||||
const visualLayer = LAYER_COUNT - 1 - layer;
|
||||
return visualLayer * LAYER_HEIGHT + PADDING_Y + availableHeight / 2;
|
||||
};
|
||||
|
||||
// --- Layer 0: sorted alphabetically, evenly spaced ---
|
||||
const layer0Nodes = [...nodesByLayer[0]].sort((a, b) =>
|
||||
a.properties.name.localeCompare(b.properties.name),
|
||||
);
|
||||
if (layer0Nodes.length > 0) {
|
||||
const spacing = availableWidth / layer0Nodes.length;
|
||||
for (let i = 0; i < layer0Nodes.length; i++) {
|
||||
const node = layer0Nodes[i];
|
||||
positions.set(node.id, {
|
||||
x: -halfWidth + (i + 0.5) * spacing,
|
||||
y: getLayerY(0),
|
||||
size: calculateNodeSize(0, node.label),
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Layers 1-3: proportional allocation from their parents ---
|
||||
for (let layer = 1; layer < LAYER_COUNT; layer++) {
|
||||
const layerNodes = nodesByLayer[layer];
|
||||
if (layerNodes.length === 0) continue;
|
||||
|
||||
const layerY = getLayerY(layer);
|
||||
|
||||
// For each node, find its "primary parent": the already-placed hierarchy
|
||||
// parent with the highest layer index (= closest ancestor in the tree).
|
||||
// Walking all parents and picking the deepest-placed one means a Method
|
||||
// prefers its Class over a distant Package, for example.
|
||||
const assignedParent = new Map<string, string>();
|
||||
for (const node of layerNodes) {
|
||||
const parents = parentsByChild.get(node.id) ?? [];
|
||||
let bestParent: string | null = null;
|
||||
let bestParentLayer = -1;
|
||||
for (const p of parents) {
|
||||
if (!positions.has(p)) continue; // not yet placed
|
||||
const pLayer = nodeLayerMap.get(p) ?? -1;
|
||||
if (pLayer > bestParentLayer) {
|
||||
bestParentLayer = pLayer;
|
||||
bestParent = p;
|
||||
}
|
||||
}
|
||||
if (bestParent) assignedParent.set(node.id, bestParent);
|
||||
}
|
||||
|
||||
// Bucket nodes into parent groups or orphans.
|
||||
const childrenOfParent = new Map<string, GraphNode[]>();
|
||||
const orphans: GraphNode[] = [];
|
||||
for (const node of layerNodes) {
|
||||
const p = assignedParent.get(node.id);
|
||||
if (!p) {
|
||||
orphans.push(node);
|
||||
} else {
|
||||
if (!childrenOfParent.has(p)) childrenOfParent.set(p, []);
|
||||
childrenOfParent.get(p)!.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort within each parent's group and orphans alphabetically.
|
||||
for (const children of childrenOfParent.values()) {
|
||||
children.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
}
|
||||
orphans.sort((a, b) => a.properties.name.localeCompare(b.properties.name));
|
||||
|
||||
// Sort active parents left-to-right by their placed X position.
|
||||
const activeParents = [...childrenOfParent.keys()].sort(
|
||||
(a, b) => (positions.get(a)?.x ?? 0) - (positions.get(b)?.x ?? 0),
|
||||
);
|
||||
|
||||
const totalParented = layerNodes.length - orphans.length;
|
||||
|
||||
// Divide the full canvas width:
|
||||
// • parented children → (totalParented / total) fraction of width
|
||||
// • orphans → remaining fraction at the right
|
||||
const parentedWidth =
|
||||
totalParented > 0 ? availableWidth * (totalParented / layerNodes.length) : 0;
|
||||
const orphanWidth = availableWidth - parentedWidth;
|
||||
|
||||
let curX = -halfWidth;
|
||||
|
||||
// Place each parent's children in a sub-slice proportional to child count.
|
||||
for (const parentId of activeParents) {
|
||||
const children = childrenOfParent.get(parentId) ?? [];
|
||||
if (children.length === 0) continue;
|
||||
|
||||
const slotWidth = (children.length / totalParented) * parentedWidth;
|
||||
placeNodesInSlice(positions, children, curX, slotWidth, layerY, layer);
|
||||
curX += slotWidth;
|
||||
}
|
||||
|
||||
// Orphans fill the rightmost slice.
|
||||
if (orphans.length > 0 && orphanWidth > 0) {
|
||||
placeNodesInSlice(positions, orphans, curX, orphanWidth, layerY, layer);
|
||||
}
|
||||
}
|
||||
|
||||
// Shift Y so the layout is centred at y = 0.
|
||||
const centerY = CANVAS_HEIGHT / 2;
|
||||
for (const pos of positions.values()) {
|
||||
pos.y -= centerY;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree view layout: type-layered grid with organic jitter and
|
||||
* structure-aware horizontal branch shaping.
|
||||
*/
|
||||
export function calculateTreeLayout(graph: KnowledgeGraph): Map<string, TreeNodePosition> {
|
||||
// Build hierarchy maps before initial placement so initProportionalPositions
|
||||
// can assign each node to its closest placed ancestor's X slice.
|
||||
const nodeIdsByLayer = buildLayerNodeIds(graph);
|
||||
const { childrenByParent, parentsByChild } = buildHierarchyMaps(graph);
|
||||
|
||||
// 1. Start with proportional X allocation: each parent gets a canvas slice
|
||||
// proportional to its child count, so dense subtrees never crowd the centre.
|
||||
const positions = initProportionalPositions(graph, parentsByChild);
|
||||
|
||||
// 2. Add subtle Y jitter only — X jitter would scramble the hierarchy ordering
|
||||
// that initProportionalPositions established (especially bad when node spacing < jitter).
|
||||
for (const [nodeId, pos] of positions) {
|
||||
pos.y += (deterministicHash(nodeId + 'y') - 0.5) * 20;
|
||||
}
|
||||
|
||||
// 3. Use structural edges to create a tree-like horizontal ordering while
|
||||
// preserving the type-based vertical layers.
|
||||
const STRUCTURE_ITERATIONS = 6;
|
||||
for (let iter = 0; iter < STRUCTURE_ITERATIONS; iter++) {
|
||||
const childTargets = new Map<string, { sum: number; count: number }>();
|
||||
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parentPos = positions.get(parentId);
|
||||
if (!parentPos || children.length === 0) continue;
|
||||
|
||||
const childPositions = children
|
||||
.map((childId) => ({ childId, pos: positions.get(childId) }))
|
||||
.filter(
|
||||
(entry): entry is { childId: string; pos: TreeNodePosition } => entry.pos !== undefined,
|
||||
)
|
||||
.sort((a, b) => a.pos.x - b.pos.x);
|
||||
|
||||
if (childPositions.length === 0) continue;
|
||||
|
||||
const currentCenter =
|
||||
childPositions.reduce((sum, entry) => sum + entry.pos.x, 0) / childPositions.length;
|
||||
const shift = parentPos.x - currentCenter;
|
||||
|
||||
for (const entry of childPositions) {
|
||||
const existing = childTargets.get(entry.childId) || { sum: 0, count: 0 };
|
||||
existing.sum += entry.pos.x + shift;
|
||||
existing.count += 1;
|
||||
childTargets.set(entry.childId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [nodeId, target] of childTargets) {
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos) continue;
|
||||
const avgTargetX = target.sum / target.count;
|
||||
pos.x = pos.x * 0.45 + avgTargetX * 0.55;
|
||||
}
|
||||
|
||||
const parentTargets = new Map<string, { sum: number; count: number }>();
|
||||
for (const [parentId, children] of childrenByParent) {
|
||||
const parentPos = positions.get(parentId);
|
||||
if (!parentPos || children.length === 0) continue;
|
||||
|
||||
const childXs = children
|
||||
.map((childId) => positions.get(childId)?.x)
|
||||
.filter((value): value is number => value !== undefined);
|
||||
|
||||
if (childXs.length === 0) continue;
|
||||
|
||||
const avgChildX = childXs.reduce((sum, value) => sum + value, 0) / childXs.length;
|
||||
const existing = parentTargets.get(parentId) || { sum: 0, count: 0 };
|
||||
existing.sum += avgChildX;
|
||||
existing.count += 1;
|
||||
parentTargets.set(parentId, existing);
|
||||
}
|
||||
|
||||
for (const [nodeId, target] of parentTargets) {
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos) continue;
|
||||
const avgTargetX = target.sum / target.count;
|
||||
pos.x = pos.x * 0.65 + avgTargetX * 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Pull childless nodes slightly toward their hierarchy parents when the
|
||||
// graph has enough structure information to form branches.
|
||||
for (const [nodeId, parents] of parentsByChild) {
|
||||
if (childrenByParent.has(nodeId)) continue;
|
||||
const pos = positions.get(nodeId);
|
||||
if (!pos || parents.length === 0) continue;
|
||||
|
||||
const parentXs = parents
|
||||
.map((parentId) => positions.get(parentId)?.x)
|
||||
.filter((value): value is number => value !== undefined);
|
||||
|
||||
if (parentXs.length === 0) continue;
|
||||
|
||||
const avgParentX = parentXs.reduce((sum, value) => sum + value, 0) / parentXs.length;
|
||||
pos.x = pos.x * 0.7 + avgParentX * 0.3;
|
||||
}
|
||||
|
||||
// 5. Keep a per-node horizontal anchor so long edges can pull nodes closer
|
||||
// without destroying each layer's original spread.
|
||||
const anchorXByNode = new Map<string, number>();
|
||||
for (const [nodeId, pos] of positions) {
|
||||
anchorXByNode.set(nodeId, pos.x);
|
||||
}
|
||||
|
||||
// 6. Relax the graph like a constrained spring system. Only X is allowed
|
||||
// to move, so node types stay on their original Y layers.
|
||||
// For large graphs the spring phase is O(N×E×iterations) and would freeze
|
||||
// the main thread — scale it down proportionally so the initial proportional
|
||||
// layout (already good at large N) is kept without expensive refinement.
|
||||
const nodeCount = graph.nodes.length;
|
||||
const SPRING_ITERATIONS = nodeCount > 10000 ? 0 : nodeCount > 3000 ? 4 : 14;
|
||||
for (let iter = 0; iter < SPRING_ITERATIONS; iter++) {
|
||||
const deltaXByNode = new Map<string, number>();
|
||||
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const anchorX = anchorXByNode.get(nodeId) ?? pos.x;
|
||||
const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X);
|
||||
const anchorStrength = 0.05 + normalizedDistance * normalizedDistance * 0.1;
|
||||
deltaXByNode.set(nodeId, (anchorX - pos.x) * anchorStrength);
|
||||
}
|
||||
|
||||
for (const rel of graph.relationships) {
|
||||
const sourcePos = positions.get(rel.sourceId);
|
||||
const targetPos = positions.get(rel.targetId);
|
||||
if (!sourcePos || !targetPos) continue;
|
||||
|
||||
const dx = targetPos.x - sourcePos.x;
|
||||
const dy = targetPos.y - sourcePos.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const restLength = getRestEdgeLength(rel.type, sourcePos, targetPos);
|
||||
const stretch = distance - restLength;
|
||||
|
||||
if (stretch <= 0) continue;
|
||||
|
||||
const springWeight = RELATION_SPRING_WEIGHTS[rel.type] ?? 0.14;
|
||||
const pull = stretch * springWeight * 0.08;
|
||||
const forceX = (dx / distance) * pull;
|
||||
|
||||
deltaXByNode.set(rel.sourceId, (deltaXByNode.get(rel.sourceId) ?? 0) + forceX);
|
||||
deltaXByNode.set(rel.targetId, (deltaXByNode.get(rel.targetId) ?? 0) - forceX);
|
||||
}
|
||||
|
||||
for (const [nodeId, pos] of positions) {
|
||||
const deltaX = deltaXByNode.get(nodeId) ?? 0;
|
||||
const normalizedDistance = Math.min(1, Math.abs(pos.x) / MAX_X);
|
||||
const edgeResistance = 1 + normalizedDistance * normalizedDistance * 4.5;
|
||||
const maxStep = 18 - normalizedDistance * 6;
|
||||
const step = clamp(deltaX / edgeResistance, -maxStep, maxStep);
|
||||
pos.x = clamp(pos.x + step, -MAX_X, MAX_X);
|
||||
}
|
||||
|
||||
for (const layerNodeIds of nodeIdsByLayer) {
|
||||
enforceLayerSpacing(layerNodeIds, positions, anchorXByNode);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Recenter and softly clamp X so the layout keeps its breadth without
|
||||
// drifting too far off-canvas.
|
||||
const xValues = Array.from(positions.values()).map((pos) => pos.x);
|
||||
if (xValues.length > 0) {
|
||||
const minX = Math.min(...xValues);
|
||||
const maxX = Math.max(...xValues);
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const halfSpan = Math.max(1, (maxX - minX) / 2);
|
||||
const scale = halfSpan > MAX_X ? MAX_X / halfSpan : 1;
|
||||
|
||||
for (const pos of positions.values()) {
|
||||
pos.x = (pos.x - centerX) * scale;
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"tabs": {
|
||||
"chat": "Nexus AI",
|
||||
"processes": "Processes"
|
||||
},
|
||||
"suggestions": {
|
||||
"architecture": "Explain the project architecture",
|
||||
"whatDoes": "What does this project do?",
|
||||
"importantFiles": "Show me the most important files",
|
||||
"apiHandlers": "Find all API handlers"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ask me anything",
|
||||
"description": "I can help you understand the architecture, find functions, or explain connections."
|
||||
},
|
||||
"input": {
|
||||
"placeholder": "Ask about the codebase...",
|
||||
"initializing": "Initializing AI agent...",
|
||||
"configureProvider": "Configure an LLM provider to enable chat."
|
||||
},
|
||||
"actions": {
|
||||
"closePanel": "Close Panel",
|
||||
"scrollBottom": "Scroll to bottom",
|
||||
"clearChat": "Clear chat",
|
||||
"stopResponse": "Stop response"
|
||||
},
|
||||
"stopped": "Stopped by user",
|
||||
"badges": {
|
||||
"configureAI": "Configure AI",
|
||||
"connecting": "Connecting"
|
||||
},
|
||||
"roles": {
|
||||
"you": "You",
|
||||
"assistant": "Nexus AI"
|
||||
},
|
||||
"newBadge": "NEW"
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "GitNexus",
|
||||
"nexusAI": "Nexus AI"
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"dismiss": "Dismiss",
|
||||
"tryAgain": "Try again",
|
||||
"hide": "Hide",
|
||||
"retry": "Retry",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"run": "Run",
|
||||
"clear": "Clear",
|
||||
"remove": "Remove",
|
||||
"focusInGraph": "Focus in graph",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"chat": {
|
||||
"viewNodeInCodePanel": "View {{inner}} in Code panel",
|
||||
"openInCodePanel": "Open in Code panel • {{inner}}",
|
||||
"waitForVectorIndex": "Wait a moment, vector index is being created."
|
||||
},
|
||||
"counts": {
|
||||
"files_one": "{{count}} file",
|
||||
"files_other": "{{count}} files",
|
||||
"nodes_one": "{{count}} node",
|
||||
"nodes_other": "{{count}} nodes",
|
||||
"edges_one": "{{count}} edge",
|
||||
"edges_other": "{{count}} edges",
|
||||
"symbols_one": "{{count}} symbol",
|
||||
"symbols_other": "{{count}} symbols",
|
||||
"flows_one": "{{count}} flow",
|
||||
"flows_other": "{{count}} flows"
|
||||
},
|
||||
"progress": {
|
||||
"connecting": "Connecting to server...",
|
||||
"connectingShort": "Connecting...",
|
||||
"validatingServer": "Validating server",
|
||||
"validatingServerEllipsis": "Validating server...",
|
||||
"downloadingGraph": "Downloading graph...",
|
||||
"downloadedMb": "{{mb}} MB downloaded",
|
||||
"downloadingWithPercent": "Downloading graph... {{percent}}%",
|
||||
"downloadingMb": "Downloading... {{mb}} MB",
|
||||
"processing": "Processing...",
|
||||
"processingGraph": "Processing graph...",
|
||||
"extractingFileContents": "Extracting file contents",
|
||||
"loadingGraph": "Loading graph...",
|
||||
"starting": "Starting...",
|
||||
"executing": "Executing...",
|
||||
"truncated": "... (truncated)",
|
||||
"ready": "Ready",
|
||||
"switchingRepository": "Switching repository...",
|
||||
"loadingRepository": "Loading {{repo}}",
|
||||
"validating": "Validating",
|
||||
"failedSwitchRepository": "Failed to switch repository",
|
||||
"unknownError": "Unknown error"
|
||||
},
|
||||
"analyzePhases": {
|
||||
"queued": "Queued",
|
||||
"cloning": "Cloning repository",
|
||||
"pulling": "Pulling latest",
|
||||
"extracting": "Scanning files",
|
||||
"structure": "Building structure",
|
||||
"parsing": "Parsing code",
|
||||
"imports": "Resolving imports",
|
||||
"calls": "Tracing calls",
|
||||
"heritage": "Extracting inheritance",
|
||||
"scopeResolution": "Resolving types",
|
||||
"communities": "Detecting communities",
|
||||
"processes": "Detecting processes",
|
||||
"complete": "Pipeline complete",
|
||||
"lbug": "Loading into database",
|
||||
"fts": "Creating search indexes",
|
||||
"embeddings": "Generating embeddings",
|
||||
"done": "Done",
|
||||
"retrying": "Retrying after crash"
|
||||
},
|
||||
"units": {
|
||||
"elapsedSeconds": "{{seconds}}s",
|
||||
"elapsedMinutesSeconds": "{{minutes}}m {{seconds}}s"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user