Merge branch 'main' of https://github.com/prajapatisparsh/GitNexus into feat/Desktop-app

This commit is contained in:
Sparsh 2026-05-23 18:43:56 +05:30
commit 7335125357
118 changed files with 6835 additions and 3850 deletions

4
.gitignore vendored
View file

@ -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

View file

@ -48,6 +48,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
| 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. |

View file

@ -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 |

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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)

View file

@ -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`

View 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

View file

@ -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).

View file

@ -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.

View file

@ -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

View file

@ -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>"
```

View file

@ -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).

View file

@ -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');
});
});

View file

@ -26,6 +26,8 @@
"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",
@ -34,6 +36,7 @@
"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.1",
"react-zoom-pan-pinch": "^4.0.3",
@ -437,9 +440,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -5266,6 +5269,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -5290,6 +5302,43 @@
"node": ">= 14"
}
},
"node_modules/i18next": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -7725,6 +7774,33 @@
"react": "^19.2.6"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@ -8464,7 +8540,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -8625,6 +8701,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
@ -8840,6 +8925,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",

View file

@ -36,6 +36,8 @@
"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,6 +46,7 @@
"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.1",
"react-zoom-pan-pinch": "^4.0.3",

View file

@ -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&hellip;
{t('errors:backend.reconnecting')}
</div>
)}

View file

@ -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 &middot; Cloned locally by the server &middot; No data leaves your machine
{t('analyzeFirst.footer')}
</p>
</div>
);

View file

@ -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>

View file

@ -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="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>

View file

@ -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;

View file

@ -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}
</>

View file

@ -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';
@ -211,6 +212,7 @@ interface FileTreePanelProps {
}
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
const { t } = useTranslation(['common', 'graph']);
const {
graph,
visibleLabels,
@ -303,7 +305,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 +316,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 +326,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 +347,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 +357,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 +377,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 +388,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
{/* File tree */}
<div className="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 +413,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<div className="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 +451,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 +488,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 +515,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 +558,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>
)}

View file

@ -21,12 +21,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,
@ -268,7 +270,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 +280,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 +307,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 +318,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 +335,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 +345,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 +363,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 ? (

View file

@ -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 &ldquo;{searchQuery}&rdquo;
{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>

View file

@ -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>

View file

@ -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>
);
};

View file

@ -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>
)}

View file

@ -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>
)}

View file

@ -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>
)}

View file

@ -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>
);

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -24,6 +24,7 @@ import {
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
@ -44,8 +45,14 @@ function isValidGitlabUrl(value: string): boolean {
// ── 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'}
@ -57,7 +64,7 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
} `}
>
<Github className="h-3 w-3" />
GitHub URL
{t('repoAnalyzer.githubUrl')}
</button>
<button
role="tab"
@ -70,7 +77,7 @@ function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode
} `}
>
<Gitlab className="h-3 w-3" />
GitLab URL
{t('repoAnalyzer.gitlabUrl')}
</button>
<button
role="tab"
@ -83,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>
);
@ -102,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 (
@ -115,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>
);
@ -124,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"
@ -134,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>
);
}
@ -153,6 +163,7 @@ 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');
@ -164,7 +175,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const [progress, setProgress] = useState<JobProgress>({
phase: 'queued',
percent: 0,
message: 'Queued',
message: t('common:analyzePhases.queued'),
});
const [completedRepoName, setCompletedRepoName] = useState('');
@ -202,7 +213,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
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)) {
@ -210,7 +221,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
return;
}
if (mode === 'local' && localPath.trim().length < 2) {
setValidationError('Please enter a folder path.');
setValidationError(t('errors:missingFolderPath'));
return;
}
@ -239,7 +250,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
(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;
@ -249,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');
}
};
@ -270,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';
@ -289,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 ${
@ -341,7 +354,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
GitLab Repository URL
{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 ${
@ -383,9 +396,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
</div>
<p className="text-xs text-text-muted">
Supports GitLab.com and self-hosted GitLab instances.
</p>
<p className="text-xs text-text-muted">{t('onboarding:repoAnalyzer.gitlabSupported')}</p>
</div>
)}
@ -396,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 ${
@ -459,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>
)}
@ -502,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>
@ -521,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>

View file

@ -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 &amp; private repos &middot; Cloned locally by the server &middot; No data leaves
your machine
{t('landing.footer')}
</p>
</div>
);

View file

@ -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="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>
)}

View file

@ -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>

View file

@ -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>

View file

@ -15,6 +15,8 @@ import {
AlertCircle,
} 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 +27,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 +36,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;
@ -88,24 +90,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 +134,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 +151,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 +163,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 +179,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>

View file

@ -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>

View file

@ -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"

View file

@ -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,65 @@ 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;
}
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,12 +428,10 @@ 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 }, {
@ -364,6 +450,9 @@ 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) {
// Events come as [streamMode, data] tuples when using multiple modes
@ -482,6 +571,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++) {
@ -539,7 +631,14 @@ export async function* streamAgentResponse(
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) {
const message = error instanceof Error ? error.message : String(error);
// DEBUG: Stream error
@ -561,10 +660,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 });

View file

@ -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;
});

View file

@ -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 [];
}

View file

@ -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 */
@ -238,6 +257,34 @@ export interface ToolCallInfo {
status: 'pending' | 'running' | 'completed' | 'error';
}
/**
* Minimal tool-call payload needed to reconstruct prior assistant turns.
*/
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
* Now supports step-based streaming where each step is a distinct message
@ -248,6 +295,8 @@ export interface AgentStreamChunk {
reasoning?: string;
/** Final answer content (streamed token by token) */
content?: string;
/** Hidden raw transcript for reconstructing future agent turns */
historyMessages?: AgentHistoryMessage[];
/** Tool call information */
toolCall?: ToolCallInfo;
/** Error message */

View file

@ -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';
@ -529,6 +542,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setEmbeddingStatus('idle');
return;
}
if (!shouldAutoStartEmbeddings()) {
setEmbeddingStatus('idle');
return;
}
startEmbeddings().catch((err) => {
console.warn('Embeddings auto-start failed:', err);
});
@ -623,6 +640,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
const sendChatMessage = useCallback(
async (message: string): Promise<void> => {
if (isChatLoading) 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 +668,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]);
@ -662,11 +681,23 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
setIsChatLoading(true);
setCurrentToolCalls([]);
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 +706,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 +723,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
id: assistantMessageId,
role: 'assistant' as const,
content,
historyMessages: assistantHistoryMessages,
steps: [...stepsForMessage],
toolCalls: [...toolCallsForMessage],
timestamp: existing?.timestamp ?? Date.now(),
@ -973,6 +1006,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,10 +1020,11 @@ 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,
})) {
onChunk(chunk);
}
onChunk({ type: 'done' });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentError(message);
@ -1007,6 +1044,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
clearAIToolHighlights,
graph,
embeddingStatus,
isChatLoading,
],
);
@ -1032,8 +1070,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 +1099,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 +1108,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 +1148,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;

View file

@ -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');
}

View file

@ -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;

View file

@ -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]
);
}

View file

@ -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',
};

View file

@ -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();

View file

@ -0,0 +1,36 @@
{
"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"
},
"badges": {
"configureAI": "Configure AI",
"connecting": "Connecting"
},
"roles": {
"you": "You",
"assistant": "Nexus AI"
},
"newBadge": "NEW"
}

View file

@ -0,0 +1,85 @@
{
"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",
"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"
}
}

View file

@ -0,0 +1,19 @@
{
"unknown": "Unknown error",
"connectFailed": "Failed to connect to server",
"loadGraphFailed": "Failed to load graph",
"failedToConnect": "Failed to connect",
"analysisFailed": "Analysis failed. Check server logs.",
"startAnalysisFailed": "Failed to start analysis",
"invalidGithubUrl": "Please enter a valid GitHub repository URL.",
"missingFolderPath": "Please enter a folder path.",
"backend": {
"reconnecting": "Server connection lost. Reconnecting…",
"network": "Unable to reach the GitNexus server. Make sure `gitnexus serve` is running.",
"timeout": "The server took too long to respond. Try again in a moment.",
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
"notFound": "The requested repository or resource was not found.",
"client": "Request failed: {{message}}",
"server": "Server error: {{message}}"
}
}

View file

@ -0,0 +1,174 @@
{
"statusBar": {
"sponsor": "Sponsor",
"sponsorHint": "need to buy some API credits to run SWE-bench 😅"
},
"loading": {
"filesProgress": "{{processed}} / {{total}} files"
},
"toolCall": {
"status": {
"running": "running",
"completed": "completed",
"error": "error"
},
"tools": {
"search": "🔍 Search Code",
"cypher": "🔗 Cypher Query",
"grep": "🔎 Pattern Search",
"read": "📄 Read File",
"overview": "🗺️ Codebase Overview",
"explore": "🔬 Deep Dive",
"impact": "💥 Impact Analysis"
},
"query": "Query",
"input": "Input",
"result": "Result",
"searchPrefix": "Search: \"{{query}}\""
},
"embedding": {
"generateTitle": "Generate embeddings for semantic search",
"enable": "Enable Semantic Search",
"loadingModel": "Loading AI model...",
"embeddingNodes": "Embedding {{processed}}/{{total}} nodes",
"creatingIndex": "Creating vector index...",
"readyTitle": "Semantic search is ready! Use natural language in the AI chat.",
"ready": "Semantic Ready",
"errorTitle": "Embedding failed. Click to retry.",
"failedRetry": "Failed - Retry",
"fallback": {
"title": "WebGPU said \"nope\"",
"subtitle": "Your browser doesn't support GPU acceleration",
"description": "Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as smart. The graph still works fine though!",
"options": "Your options:",
"useCpu": "Use CPU",
"useCpuDescriptionSmall": "Works but a bit slower",
"useCpuDescriptionLarge": "Works but way slower",
"estimated": "(~{{minutes}} min for {{count}} nodes)",
"skipIt": "Skip it",
"skipDescription": "Graph works, just no AI semantic search",
"smallCodebase": "Small codebase detected! CPU should be fine.",
"tip": "💡 Tip: Try Chrome or Edge for WebGPU support",
"skipEmbeddings": "Skip Embeddings",
"useCpuRecommended": "Use CPU (Recommended)",
"useCpuSlow": "Use CPU (Slow)"
}
},
"queryFab": {
"query": "Query",
"cypherQuery": "Cypher Query",
"examples": "Examples",
"run": "Run",
"noProject": "No project loaded. Load a project first.",
"dbNotReady": "Database not ready. Please wait for loading to complete.",
"executionFailed": "Query execution failed",
"exampleLabels": {
"functions": "All Functions",
"classes": "All Classes",
"interfaces": "All Interfaces",
"calls": "Function Calls",
"imports": "Import Dependencies"
},
"clear": "Clear",
"rows": "rows",
"highlighted": "highlighted",
"showingRows": "Showing 50 of {{count}} rows"
},
"fileTree": {
"expandPanel": "Expand Panel",
"fileExplorer": "File Explorer",
"filters": "Filters",
"collapsePanel": "Collapse Panel",
"searchFiles": "Search files...",
"noFilesLoaded": "No files loaded",
"all": "All",
"selectNodeDepth": "Select a node to apply depth filter",
"explorer": "Explorer",
"nodeTypes": "Node Types",
"nodeTypesDesc": "Toggle visibility of node types in the graph",
"edgeTypes": "Edge Types",
"edgeTypesDesc": "Toggle visibility of relationship types",
"focusDepth": "Focus Depth",
"focusDepthDesc": "Show nodes within N hops of selection",
"hops_one": "{{count}} hop",
"hops_other": "{{count}} hops",
"colorLegend": "Color Legend"
},
"codePanel": {
"expand": "Expand Code Panel",
"dragResize": "Drag to resize",
"title": "Code Inspector",
"clearCitations": "Clear AI citations",
"clearSelection": "Clear selection",
"loadingSource": "Loading source...",
"selectFile": "Select a file node to preview its contents.",
"code": "Code",
"selected": "Selected",
"aiCitations": "AI Citations",
"references_one": "{{count}} reference",
"references_other": "{{count}} references",
"lines_one": "{{count}} line",
"lines_other": "{{count}} lines",
"codeNotAvailable": "Code not available in memory for {{path}}"
},
"canvas": {
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"fit": "Fit to Screen",
"focusSelected": "Focus on Selected Node",
"clearSelection": "Clear Selection",
"clear": "Clear",
"stopLayout": "Stop Layout",
"runLayout": "Run Layout Again",
"layoutOptimizing": "Layout optimizing...",
"turnOffHighlights": "Turn off all highlights",
"turnOnHighlights": "Turn on AI highlights"
},
"processes": {
"unknownStep": "Unknown",
"allProcessesLabel_one": "All Processes ({{count}} combined)",
"allProcessesLabel_other": "All Processes ({{count}} combined)",
"emptyTitle": "No Processes Detected",
"emptyDescription": "Processes are execution flows traced from entry points. Load a codebase to see detected processes.",
"filterPlaceholder": "Filter processes...",
"detected_one": "{{count}} process detected",
"detected_other": "{{count}} processes detected",
"fullMap": "Full Process Map",
"viewCombined_one": "View combined map of {{count}} process",
"viewCombined_other": "View combined map of {{count}} processes",
"crossCommunity": "Cross-Community",
"intraCommunity": "Intra-Community",
"steps_one": "{{count}} step",
"steps_other": "{{count}} steps",
"clusters_one": "{{count}} cluster",
"clusters_other": "{{count}} clusters",
"highlightTitle": "Click to highlight in graph",
"removeHighlightTitle": "Click to remove highlight from graph",
"loading": "Loading...",
"viewing": "Viewing",
"view": "View"
},
"processFlow": {
"title": "Process: {{label}}",
"diagramTooLarge": "📊 Diagram Too Large",
"renderError": "⚠️ Render Error",
"tooComplex_one": "This diagram has {{count}} step and is too complex to render. Try viewing individual processes instead of \"All Processes\".",
"tooComplex_other": "This diagram has {{count}} steps and is too complex to render. Try viewing individual processes instead of \"All Processes\".",
"unableToRender_one": "Unable to render diagram. Steps: {{count}}",
"unableToRender_other": "Unable to render diagram. Steps: {{count}}",
"zoomOutTitle": "Zoom out (-)",
"zoomInTitle": "Zoom in (+)",
"resetTitle": "Reset zoom and pan",
"resetView": "Reset View",
"toggleFocus": "Toggle Focus",
"copyMermaid": "Copy Mermaid"
},
"diagram": {
"aiGenerated": "AI Generated Diagram",
"error": "Diagram Error",
"showSource": "Show source",
"label": "Diagram",
"expandTitle": "Expand",
"loading": "Loading diagram…"
}
}

View file

@ -0,0 +1,16 @@
{
"repositories": "Repositories",
"active": "active",
"reanalyzing": "Re-analyzing...",
"reanalyzeRepo": "Re-analyze {{repoName}}",
"deleteRepo": "Delete {{repoName}}",
"reanalyzingRepo": "Re-analyzing {{repoName}}: {{message}}",
"analyzeNew": "Analyze a new repository...",
"searchNodes": "Search nodes...",
"noNodesFound": "No nodes found for \"{{query}}\"",
"starIfCool": "Star if cool",
"aiSettings": "AI Settings",
"help": "Help",
"language": "Language",
"selectLanguage": "Select language"
}

View file

@ -0,0 +1,96 @@
{
"tabs": {
"overview": "Overview",
"ai": "Nexus AI",
"shortcuts": "Shortcuts",
"status": "Status bar",
"graph": "Graph & nodes",
"search": "Search & filter"
},
"shortcuts": {
"searchNodes": "Search nodes",
"deselectClose": "Deselect / close",
"columns": {
"action": "Action",
"mac": "Mac",
"windows": "Windows"
}
},
"nodeTypes": {
"function": "Function",
"functionDesc": "Function declarations",
"file": "File",
"fileDesc": "Source files",
"class": "Class",
"classDesc": "Class declarations",
"method": "Method",
"methodDesc": "Class methods",
"interface": "Interface",
"interfaceDesc": "TypeScript interfaces",
"folder": "Folder",
"folderDesc": "Directory nodes"
},
"status": {
"ready": "Ready",
"readyDesc": "Graph is fully loaded and interactive",
"nodesCount": "Nodes count",
"nodesCountDesc": "Total files and symbols in the graph",
"edgesCount": "Edges count",
"edgesCountDesc": "Import / dependency connections",
"aiIndexStatus": "AI index status",
"aiIndexStatusDesc": "Repo is fully indexed for AI queries",
"semanticReadyBadge": "Semantic Ready",
"explained": "Status bar explained"
},
"tryAsking": "Try asking:",
"footer": "GitNexus — graph explorer",
"title": "Help & Reference",
"footerLong": "GitNexus — open source codebase graph explorer",
"docsGithub": "Docs & GitHub ↗",
"overview": {
"gettingStarted": "Getting started",
"whatIsTitle": "What is GitNexus?",
"whatIsDescription": "An interactive graph explorer for your codebase. Every file, function, and import becomes a node you can explore, query, and navigate visually.",
"currentRepoTitle": "Your current repo",
"loadedCounts": "Loaded: {{nodeCount}} nodes · {{edgeCount}} edges",
"threeWaysTitle": "Three ways to explore",
"wayInspect": "Click nodes to inspect",
"waySearch": "Search by name or type",
"wayAsk": "Ask Nexus AI a natural language question",
"navigationTitle": "Navigation",
"navZoom": "Scroll to zoom",
"navPan": "Click and drag to pan",
"navFocus": "Double-click a node to focus its subgraph"
},
"graph": {
"nodeColorLegend": "Node color legend",
"nodeLabel": "{{label}} nodes",
"sizeDescription": "Node size reflects connection count — larger nodes are depended on by more files. Edges point from importer → imported.",
"detailDescription": "Click any node to open its detail panel — showing imports, exports, and reverse dependencies."
},
"search": {
"title": "Search & filter",
"searchNodes": "Search nodes",
"searchDescription": "Search by filename, function name, or import path. Matching nodes are highlighted live in the graph.",
"filterPanel": "Filter panel",
"filterDescription": "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.",
"syntax": "Search syntax",
"hints": {
"nameFragment": "match by name fragment",
"pathPrefix": "match by path prefix",
"nodeType": "filter by node type"
}
},
"ai": {
"title": "Nexus AI",
"semanticReady": "✓ Semantic Ready",
"description": "Your repo is indexed and ready for semantic queries. Nexus AI understands code structure and relationships, not just file names.",
"questions": {
"dependencies": "\"Which files depend on the auth module?\"",
"circular": "\"Find circular dependencies in this repo\"",
"connected": "\"What are the most connected components?\"",
"imports": "\"Show me all files that import useEffect\""
},
"openPrompt": "Open the prompt via the Nexus AI button (top-right)."
}
}

View file

@ -0,0 +1,67 @@
{
"success": {
"title": "Server Connected",
"description": "Preparing your code knowledge graph..."
},
"loading": {
"largeRepoHint": "This may take a moment for large repositories"
},
"guide": {
"copyAria": "Copy to clipboard",
"copiedAria": "Copied!",
"startServer": "Start your local server",
"devDescription": "Fire up the Express backend in a separate terminal to unlock the full graph.",
"prodDescription": "One command is all it takes. The browser connects automatically.",
"copyCommand": "Copy the command",
"copyCommandDescription": "Click the icon in the terminal to copy.",
"done": "done",
"orInstallGlobally": "or install globally",
"globalInstall": "Global install",
"startBackend": "Start backend",
"terminal": "Terminal",
"waitingForServer": "Waiting for server to start",
"pasteAndRun": "Paste and run in your terminal",
"pasteAndRunDescription": "Open a terminal at the project root, paste, and hit Enter.",
"listeningForServer": "Listening for server",
"willAutoConnect": "Will auto-connect when detected",
"autoConnects": "Auto-connects and opens the graph",
"autoConnectsDescription": "No refresh needed — the page detects the server automatically.",
"requires": "Requires",
"port": "Port 4747"
},
"analyzeFirst": {
"title": "Analyze your first repository",
"description": "Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live knowledge graph — right in your browser.",
"footer": "Public repos only · Cloned locally by the server · No data leaves your machine"
},
"landing": {
"chooseRepository": "Choose a repository",
"description": "Select an indexed repository to explore, or analyze a new one.",
"indexed": "Indexed {{time}}",
"orAnalyzeNew": "or analyze new",
"footer": "Public & private repos · Cloned locally by the server · No data leaves your machine",
"time": {
"justNow": "just now",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago"
}
},
"repoAnalyzer": {
"inputType": "Input type",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
"localFolder": "Local Folder",
"starting": "Starting analysis...",
"analyzeRepository": "Analyze Repository",
"complete": "Analysis complete",
"loadingGraph": "Loading graph...",
"defaultRepoName": "repository",
"githubRepositoryUrl": "GitHub Repository URL",
"gitlabRepositoryUrl": "GitLab Repository URL",
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
"localFolderPath": "Local Folder Path",
"browseForFolder": "Browse for folder",
"hideBackground": "Hide (analysis continues in background)"
}
}

View file

@ -0,0 +1,93 @@
{
"title": "AI Settings",
"subtitle": "Configure your LLM provider",
"localServer": "Local Server",
"backendUrl": "Backend URL",
"connected": "Connected",
"notConnected": "Not connected",
"runServeHint": "Run `gitnexus serve` to connect the web UI to a local backend.",
"provider": "Provider",
"apiKey": "API Key",
"learnMore": "Learn more",
"model": "Model",
"searchModelPlaceholder": "Search or type model ID...",
"selectModelPlaceholder": "Select or type a model...",
"customModelHint": "Type a model ID or press Enter",
"customModelExample": "e.g. openai/gpt-4o",
"pressEnterCustom": "Press Enter to use as custom ID",
"baseUrl": "Base URL",
"optional": "optional",
"deploymentName": "Deployment Name",
"apiVersion": "API Version",
"checkConnection": "Check connection",
"privacyLabel": "Privacy:",
"privacyText": "Your API keys are stored locally in this browser.",
"providers": {
"openai": {
"description": "Use OpenAI models for chat and code reasoning.",
"apiKeyPlaceholder": "Enter your OpenAI API key",
"helperText": "Get your API key from",
"helperLinkLabel": "OpenAI Platform",
"modelPlaceholder": "e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo",
"baseUrlPlaceholder": "https://api.openai.com/v1 (default)",
"baseUrlHint": "Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs."
},
"gemini": {
"description": "Use Google Gemini models.",
"apiKeyPlaceholder": "Enter your Google AI API key",
"helperText": "Get your API key from",
"helperLinkLabel": "Google AI Studio",
"modelPlaceholder": "e.g., gemini-2.0-flash, gemini-1.5-pro"
},
"anthropic": {
"description": "Use Anthropic Claude models.",
"apiKeyPlaceholder": "Enter your Anthropic API key",
"helperText": "Get your API key from",
"helperLinkLabel": "Anthropic Console",
"modelPlaceholder": "e.g., claude-sonnet-4-20250514, claude-3-opus"
},
"azure": {
"apiKeyPlaceholder": "Enter your Azure OpenAI API key",
"deploymentNamePlaceholder": "e.g., gpt-4o-deployment"
},
"ollama": {
"quickStart": "📋 Quick Start:",
"installFrom": "Install Ollama from",
"thenRun": ", then run:",
"modelPlaceholder": "e.g., llama3.2, mistral, codellama"
},
"openrouter": {
"apiKeyPlaceholder": "Enter your OpenRouter API key",
"helperText": "Get your API key from",
"helperLinkLabel": "OpenRouter Keys"
},
"minimax": {
"apiKeyPlaceholder": "Enter your MiniMax API key",
"helperText": "Get your API key from",
"helperLinkLabel": "MiniMax Platform",
"modelPlaceholder": "e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed",
"helperModel": "Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)"
},
"glm": {
"apiKeyPlaceholder": "Enter your Z.AI API key"
}
},
"loadingModels": "Loading models...",
"noModelsMatch": "No models match \"{{searchTerm}}\"",
"moreModels": "+{{count}} more • Refine your search",
"apiKeySession": "API keys are stored in session storage and will be cleared when you close this tab.",
"startLocalServer": "start the local server",
"settingsSaved": "Settings saved",
"failedToSave": "Failed to save",
"saveSettings": "Save Settings",
"endpoint": "Endpoint",
"azurePortal": "Azure Portal",
"azureHint": "Configure your Azure OpenAI service in the",
"defaultPort": "Default port is",
"pullModel": "Pull a model with",
"browseModels": "Browse all models at",
"openRouterModels": "OpenRouter Models",
"zaiPlatform": "Z.AI Platform",
"glmCodingApi": "Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API.",
"privacyFull": "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."
}

View file

@ -0,0 +1,36 @@
{
"tabs": {
"chat": "Nexus AI",
"processes": "流程"
},
"suggestions": {
"architecture": "解释项目架构",
"whatDoes": "这个项目是做什么的?",
"importantFiles": "显示最重要的文件",
"apiHandlers": "查找所有 API 处理器"
},
"empty": {
"title": "可以问我任何问题",
"description": "我可以帮你理解架构、查找函数或解释连接关系。"
},
"input": {
"placeholder": "询问这个代码库...",
"initializing": "正在初始化 AI Agent...",
"configureProvider": "配置 LLM 提供商以启用聊天。"
},
"actions": {
"closePanel": "关闭面板",
"scrollBottom": "滚动到底部",
"clearChat": "清空聊天",
"stopResponse": "停止响应"
},
"badges": {
"configureAI": "配置 AI",
"connecting": "连接中"
},
"roles": {
"you": "你",
"assistant": "Nexus AI"
},
"newBadge": "新"
}

View file

@ -0,0 +1,85 @@
{
"app": {
"name": "GitNexus",
"nexusAI": "Nexus AI"
},
"actions": {
"cancel": "取消",
"dismiss": "关闭",
"tryAgain": "重试",
"hide": "隐藏",
"retry": "重试",
"copy": "复制",
"copied": "已复制",
"close": "关闭",
"run": "运行",
"clear": "清空",
"remove": "移除",
"focusInGraph": "在图中聚焦",
"expand": "展开",
"collapse": "折叠"
},
"chat": {
"viewNodeInCodePanel": "在代码面板中查看 {{inner}}",
"openInCodePanel": "在代码面板中打开 • {{inner}}",
"waitForVectorIndex": "请稍候,正在创建向量索引。"
},
"counts": {
"files_one": "{{count}} 个文件",
"files_other": "{{count}} 个文件",
"nodes_one": "{{count}} 个节点",
"nodes_other": "{{count}} 个节点",
"edges_one": "{{count}} 条边",
"edges_other": "{{count}} 条边",
"symbols_one": "{{count}} 个符号",
"symbols_other": "{{count}} 个符号",
"flows_one": "{{count}} 条流程",
"flows_other": "{{count}} 条流程"
},
"progress": {
"connecting": "正在连接服务器...",
"connectingShort": "正在连接...",
"validatingServer": "正在验证服务器",
"validatingServerEllipsis": "正在验证服务器...",
"downloadingGraph": "正在下载图数据...",
"downloadedMb": "已下载 {{mb}} MB",
"downloadingWithPercent": "正在下载图数据... {{percent}}%",
"downloadingMb": "正在下载... {{mb}} MB",
"processing": "正在处理...",
"processingGraph": "正在处理图数据...",
"extractingFileContents": "正在提取文件内容",
"loadingGraph": "正在加载图数据...",
"starting": "正在启动...",
"executing": "正在执行...",
"truncated": "...(已截断)",
"ready": "就绪",
"switchingRepository": "正在切换仓库...",
"loadingRepository": "正在加载 {{repo}}",
"validating": "正在验证",
"failedSwitchRepository": "切换仓库失败",
"unknownError": "未知错误"
},
"analyzePhases": {
"queued": "已排队",
"cloning": "正在克隆仓库",
"pulling": "正在拉取最新代码",
"extracting": "正在扫描文件",
"structure": "正在构建结构",
"parsing": "正在解析代码",
"imports": "正在解析导入",
"calls": "正在追踪调用",
"heritage": "正在提取继承关系",
"communities": "正在检测社区",
"processes": "正在检测流程",
"complete": "流水线完成",
"lbug": "正在加载数据库",
"fts": "正在创建搜索索引",
"embeddings": "正在生成嵌入向量",
"done": "完成",
"retrying": "崩溃后正在重试"
},
"units": {
"elapsedSeconds": "{{seconds}} 秒",
"elapsedMinutesSeconds": "{{minutes}} 分 {{seconds}} 秒"
}
}

View file

@ -0,0 +1,19 @@
{
"unknown": "未知错误",
"connectFailed": "连接服务器失败",
"loadGraphFailed": "加载图数据失败",
"failedToConnect": "连接失败",
"analysisFailed": "分析失败,请检查服务器日志。",
"startAnalysisFailed": "启动分析失败",
"invalidGithubUrl": "请输入有效的 GitHub 仓库 URL。",
"missingFolderPath": "请输入文件夹路径。",
"backend": {
"reconnecting": "服务器连接已断开,正在重连…",
"network": "无法连接 GitNexus 服务器,请确认 `gitnexus serve` 正在运行。",
"timeout": "服务器响应超时,请稍后重试。",
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
"notFound": "未找到请求的仓库或资源。",
"client": "请求失败:{{message}}",
"server": "服务器错误:{{message}}"
}
}

View file

@ -0,0 +1,174 @@
{
"statusBar": {
"sponsor": "赞助",
"sponsorHint": "需要买点 API 额度跑 SWE-bench 😅"
},
"loading": {
"filesProgress": "{{processed}} / {{total}} 个文件"
},
"toolCall": {
"status": {
"running": "运行中",
"completed": "已完成",
"error": "错误"
},
"tools": {
"search": "🔍 搜索代码",
"cypher": "🔗 Cypher 查询",
"grep": "🔎 模式搜索",
"read": "📄 读取文件",
"overview": "🗺️ 代码库概览",
"explore": "🔬 深入分析",
"impact": "💥 影响分析"
},
"query": "查询",
"input": "输入",
"result": "结果",
"searchPrefix": "搜索:\"{{query}}\""
},
"embedding": {
"generateTitle": "为语义搜索生成嵌入向量",
"enable": "启用语义搜索",
"loadingModel": "正在加载 AI 模型...",
"embeddingNodes": "正在嵌入 {{processed}}/{{total}} 个节点",
"creatingIndex": "正在创建向量索引...",
"readyTitle": "语义搜索已就绪!可在 AI 聊天中使用自然语言。",
"ready": "语义就绪",
"errorTitle": "嵌入失败,点击重试。",
"failedRetry": "失败 - 重试",
"fallback": {
"title": "WebGPU 拒绝了请求",
"subtitle": "你的浏览器不支持 GPU 加速",
"description": "无法用 WebGPU 创建嵌入向量因此语义搜索Graph RAG不会那么智能但图谱仍可正常使用。",
"options": "可选方案:",
"useCpu": "使用 CPU",
"useCpuDescriptionSmall": "可用,但会稍慢",
"useCpuDescriptionLarge": "可用,但会慢很多",
"estimated": "(约 {{minutes}} 分钟,{{count}} 个节点)",
"skipIt": "跳过",
"skipDescription": "图谱可用,但没有 AI 语义搜索",
"smallCodebase": "检测到小型代码库CPU 应该没问题。",
"tip": "💡 提示:可尝试 Chrome 或 Edge 以获得 WebGPU 支持",
"skipEmbeddings": "跳过嵌入",
"useCpuRecommended": "使用 CPU推荐",
"useCpuSlow": "使用 CPU较慢"
}
},
"queryFab": {
"query": "查询",
"cypherQuery": "Cypher 查询",
"examples": "示例",
"run": "运行",
"noProject": "未加载项目,请先加载项目。",
"dbNotReady": "数据库尚未就绪,请等待加载完成。",
"executionFailed": "查询执行失败",
"exampleLabels": {
"functions": "所有函数",
"classes": "所有类",
"interfaces": "所有接口",
"calls": "函数调用",
"imports": "导入依赖"
},
"clear": "清除",
"rows": "行",
"highlighted": "已高亮",
"showingRows": "显示 {{count}} 行中的前 50 行"
},
"fileTree": {
"expandPanel": "展开面板",
"fileExplorer": "文件浏览器",
"filters": "过滤器",
"collapsePanel": "折叠面板",
"searchFiles": "搜索文件...",
"noFilesLoaded": "未加载文件",
"all": "全部",
"selectNodeDepth": "选择节点后才能应用深度过滤",
"explorer": "浏览器",
"nodeTypes": "节点类型",
"nodeTypesDesc": "切换图中节点类型的可见性",
"edgeTypes": "边类型",
"edgeTypesDesc": "切换关系类型的可见性",
"focusDepth": "聚焦深度",
"focusDepthDesc": "显示所选节点 N 跳内的节点",
"hops_one": "{{count}} 跳",
"hops_other": "{{count}} 跳",
"colorLegend": "颜色图例"
},
"codePanel": {
"expand": "展开代码面板",
"dragResize": "拖动调整大小",
"title": "代码检查器",
"clearCitations": "清除 AI 引用",
"clearSelection": "清除选择",
"loadingSource": "正在加载源码...",
"selectFile": "选择文件节点以预览其内容。",
"code": "代码",
"selected": "已选择",
"aiCitations": "AI 引用",
"references_one": "{{count}} 条引用",
"references_other": "{{count}} 条引用",
"lines_one": "{{count}} 行",
"lines_other": "{{count}} 行",
"codeNotAvailable": "内存中没有 {{path}} 的代码内容"
},
"canvas": {
"zoomIn": "放大",
"zoomOut": "缩小",
"fit": "适应屏幕",
"focusSelected": "聚焦所选节点",
"clearSelection": "清除选择",
"clear": "清除",
"stopLayout": "停止布局",
"runLayout": "重新运行布局",
"layoutOptimizing": "正在优化布局...",
"turnOffHighlights": "关闭全部高亮",
"turnOnHighlights": "开启 AI 高亮"
},
"processes": {
"unknownStep": "未知",
"allProcessesLabel_one": "全部流程(合并 {{count}} 个)",
"allProcessesLabel_other": "全部流程(合并 {{count}} 个)",
"emptyTitle": "未检测到流程",
"emptyDescription": "流程是从入口点追踪出的执行链路。加载代码库后即可查看检测到的流程。",
"filterPlaceholder": "过滤流程...",
"detected_one": "检测到 {{count}} 个流程",
"detected_other": "检测到 {{count}} 个流程",
"fullMap": "完整流程图",
"viewCombined_one": "查看 {{count}} 个流程的合并图",
"viewCombined_other": "查看 {{count}} 个流程的合并图",
"crossCommunity": "跨社区",
"intraCommunity": "社区内",
"steps_one": "{{count}} 步",
"steps_other": "{{count}} 步",
"clusters_one": "{{count}} 个聚类",
"clusters_other": "{{count}} 个聚类",
"highlightTitle": "在图谱中高亮",
"removeHighlightTitle": "移除图谱高亮",
"loading": "加载中...",
"viewing": "查看中",
"view": "查看"
},
"processFlow": {
"title": "流程:{{label}}",
"diagramTooLarge": "📊 图表过大",
"renderError": "⚠️ 渲染错误",
"tooComplex_one": "该图表包含 {{count}} 个步骤,复杂度过高无法渲染。请查看单个流程,而不是“全部流程”。",
"tooComplex_other": "该图表包含 {{count}} 个步骤,复杂度过高无法渲染。请查看单个流程,而不是“全部流程”。",
"unableToRender_one": "无法渲染图表。步骤数:{{count}}",
"unableToRender_other": "无法渲染图表。步骤数:{{count}}",
"zoomOutTitle": "缩小 (-)",
"zoomInTitle": "放大 (+)",
"resetTitle": "重置缩放和平移",
"resetView": "重置视图",
"toggleFocus": "切换聚焦",
"copyMermaid": "复制 Mermaid"
},
"diagram": {
"aiGenerated": "AI 生成图表",
"error": "图表错误",
"showSource": "显示源码",
"label": "图表",
"expandTitle": "展开",
"loading": "正在加载图表…"
}
}

View file

@ -0,0 +1,16 @@
{
"repositories": "仓库",
"active": "当前",
"reanalyzing": "正在重新分析...",
"reanalyzeRepo": "重新分析 {{repoName}}",
"deleteRepo": "删除 {{repoName}}",
"reanalyzingRepo": "正在重新分析 {{repoName}}{{message}}",
"analyzeNew": "分析新仓库...",
"searchNodes": "搜索节点...",
"noNodesFound": "未找到“{{query}}”相关节点",
"starIfCool": "觉得不错就点星",
"aiSettings": "AI 设置",
"help": "帮助",
"language": "语言",
"selectLanguage": "选择语言"
}

View file

@ -0,0 +1,96 @@
{
"tabs": {
"overview": "概览",
"ai": "Nexus AI",
"shortcuts": "快捷键",
"status": "状态栏",
"graph": "图谱与节点",
"search": "搜索与过滤"
},
"shortcuts": {
"searchNodes": "搜索节点",
"deselectClose": "取消选择 / 关闭",
"columns": {
"action": "操作",
"mac": "Mac",
"windows": "Windows"
}
},
"nodeTypes": {
"function": "函数",
"functionDesc": "函数声明",
"file": "文件",
"fileDesc": "源码文件",
"class": "类",
"classDesc": "类声明",
"method": "方法",
"methodDesc": "类方法",
"interface": "接口",
"interfaceDesc": "TypeScript 接口",
"folder": "文件夹",
"folderDesc": "目录节点"
},
"status": {
"ready": "就绪",
"readyDesc": "图谱已完全加载并可交互",
"nodesCount": "节点数",
"nodesCountDesc": "图谱中的文件与符号总数",
"edgesCount": "边数",
"edgesCountDesc": "导入 / 依赖连接",
"aiIndexStatus": "AI 索引状态",
"aiIndexStatusDesc": "仓库已完成 AI 查询索引",
"semanticReadyBadge": "语义就绪",
"explained": "状态栏说明"
},
"tryAsking": "可以尝试问:",
"footer": "GitNexus — 图谱浏览器",
"title": "帮助与参考",
"footerLong": "GitNexus — 开源代码库图谱浏览器",
"docsGithub": "文档与 GitHub ↗",
"overview": {
"gettingStarted": "开始使用",
"whatIsTitle": "GitNexus 是什么?",
"whatIsDescription": "GitNexus 是代码库的交互式图谱浏览器。每个文件、函数和导入关系都会变成可探索、可查询、可视化导航的节点。",
"currentRepoTitle": "当前仓库",
"loadedCounts": "已加载:{{nodeCount}} 个节点 · {{edgeCount}} 条边",
"threeWaysTitle": "三种探索方式",
"wayInspect": "点击节点查看详情",
"waySearch": "按名称或类型搜索",
"wayAsk": "向 Nexus AI 提出自然语言问题",
"navigationTitle": "导航",
"navZoom": "滚动缩放",
"navPan": "点击并拖动进行平移",
"navFocus": "双击节点聚焦其子图"
},
"graph": {
"nodeColorLegend": "节点颜色图例",
"nodeLabel": "{{label}}节点",
"sizeDescription": "节点大小反映连接数量——越大的节点被越多文件依赖。边的方向表示从导入方 → 被导入方。",
"detailDescription": "点击任意节点可打开详情面板,查看导入、导出和反向依赖。"
},
"search": {
"title": "搜索与过滤",
"searchNodes": "搜索节点",
"searchDescription": "可按文件名、函数名或导入路径搜索,匹配的节点会在图谱中实时高亮。",
"filterPanel": "过滤面板",
"filterDescription": "使用左侧栏的过滤图标隔离特定节点类型、隐藏叶子节点,或以选中节点为根聚焦指定深度范围。",
"syntax": "搜索语法",
"hints": {
"nameFragment": "按名称片段匹配",
"pathPrefix": "按路径前缀匹配",
"nodeType": "按节点类型过滤"
}
},
"ai": {
"title": "Nexus AI",
"semanticReady": "✓ 语义索引就绪",
"description": "仓库已完成索引可进行语义查询。Nexus AI 理解代码结构和关系,而不只是文件名。",
"questions": {
"dependencies": "“哪些文件依赖 auth 模块?”",
"circular": "“找出这个仓库中的循环依赖”",
"connected": "“哪些组件连接最密集?”",
"imports": "“显示所有导入 useEffect 的文件”"
},
"openPrompt": "点击右上角的 Nexus AI 按钮打开提问面板。"
}
}

View file

@ -0,0 +1,67 @@
{
"success": {
"title": "服务器已连接",
"description": "正在准备代码知识图谱..."
},
"loading": {
"largeRepoHint": "大型仓库可能需要一些时间"
},
"guide": {
"copyAria": "复制到剪贴板",
"copiedAria": "已复制!",
"startServer": "启动本地服务器",
"devDescription": "在另一个终端启动 Express 后端,即可启用完整图谱。",
"prodDescription": "只需一条命令,浏览器会自动连接。",
"copyCommand": "复制命令",
"copyCommandDescription": "点击终端右侧图标复制。",
"done": "完成",
"orInstallGlobally": "或全局安装",
"globalInstall": "全局安装",
"startBackend": "启动后端",
"terminal": "终端",
"waitingForServer": "等待服务器启动",
"pasteAndRun": "粘贴并在终端运行",
"pasteAndRunDescription": "在项目根目录打开终端,粘贴命令并回车。",
"listeningForServer": "正在监听服务器",
"willAutoConnect": "检测到后会自动连接",
"autoConnects": "自动连接并打开图谱",
"autoConnectsDescription": "无需刷新,页面会自动检测服务器。",
"requires": "需要",
"port": "端口 4747"
},
"analyzeFirst": {
"title": "分析你的第一个仓库",
"description": "粘贴 GitHub URLGitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。",
"footer": "仅支持公开仓库 · 服务器本地克隆 · 数据不会离开你的机器"
},
"landing": {
"chooseRepository": "选择仓库",
"description": "选择一个已索引仓库开始探索,或分析一个新仓库。",
"indexed": "索引于 {{time}}",
"orAnalyzeNew": "或分析新仓库",
"footer": "支持公开与私有仓库 · 服务器本地克隆 · 数据不会离开你的机器",
"time": {
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前"
}
},
"repoAnalyzer": {
"inputType": "输入类型",
"githubUrl": "GitHub URL",
"gitlabUrl": "GitLab URL",
"localFolder": "本地文件夹",
"starting": "正在启动分析...",
"analyzeRepository": "分析仓库",
"complete": "分析完成",
"loadingGraph": "正在加载图数据...",
"defaultRepoName": "仓库",
"githubRepositoryUrl": "GitHub 仓库 URL",
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
"localFolderPath": "本地文件夹路径",
"browseForFolder": "浏览文件夹",
"hideBackground": "隐藏(分析继续在后台进行)"
}
}

View file

@ -0,0 +1,93 @@
{
"title": "AI 设置",
"subtitle": "配置你的 LLM 提供商",
"localServer": "本地服务器",
"backendUrl": "后端 URL",
"connected": "已连接",
"notConnected": "未连接",
"runServeHint": "运行 `gitnexus serve` 将 Web UI 连接到本地后端。",
"provider": "提供商",
"apiKey": "API Key",
"learnMore": "了解更多",
"model": "模型",
"searchModelPlaceholder": "搜索或输入模型 ID...",
"selectModelPlaceholder": "选择或输入模型...",
"customModelHint": "输入模型 ID 或按 Enter",
"customModelExample": "例如 openai/gpt-4o",
"pressEnterCustom": "按 Enter 使用自定义 ID",
"baseUrl": "Base URL",
"optional": "可选",
"deploymentName": "部署名称",
"apiVersion": "API 版本",
"checkConnection": "检查连接",
"privacyLabel": "隐私:",
"privacyText": "你的 API Key 仅保存在此浏览器本地。",
"providers": {
"openai": {
"description": "使用 OpenAI 模型进行聊天和代码推理。",
"apiKeyPlaceholder": "输入 OpenAI API Key",
"helperText": "从这里获取 API Key",
"helperLinkLabel": "OpenAI Platform",
"modelPlaceholder": "例如gpt-4o、gpt-4-turbo、gpt-3.5-turbo",
"baseUrlPlaceholder": "https://api.openai.com/v1默认",
"baseUrlHint": "留空则使用默认 OpenAI API。可为代理或兼容 API 设置自定义 URL。"
},
"gemini": {
"description": "使用 Google Gemini 模型。",
"apiKeyPlaceholder": "输入 Google AI API Key",
"helperText": "从这里获取 API Key",
"helperLinkLabel": "Google AI Studio",
"modelPlaceholder": "例如gemini-2.0-flash、gemini-1.5-pro"
},
"anthropic": {
"description": "使用 Anthropic Claude 模型。",
"apiKeyPlaceholder": "输入 Anthropic API Key",
"helperText": "从这里获取 API Key",
"helperLinkLabel": "Anthropic Console",
"modelPlaceholder": "例如claude-sonnet-4-20250514、claude-3-opus"
},
"azure": {
"apiKeyPlaceholder": "输入 Azure OpenAI API Key",
"deploymentNamePlaceholder": "例如gpt-4o-deployment"
},
"ollama": {
"quickStart": "📋 快速开始:",
"installFrom": "从这里安装 Ollama",
"thenRun": ",然后运行:",
"modelPlaceholder": "例如llama3.2、mistral、codellama"
},
"openrouter": {
"apiKeyPlaceholder": "输入 OpenRouter API Key",
"helperText": "从这里获取 API Key",
"helperLinkLabel": "OpenRouter Keys"
},
"minimax": {
"apiKeyPlaceholder": "输入 MiniMax API Key",
"helperText": "从这里获取 API Key",
"helperLinkLabel": "MiniMax Platform",
"modelPlaceholder": "例如MiniMax-M2.5、MiniMax-M2.5-highspeed",
"helperModel": "可用MiniMax-M2.5默认、MiniMax-M2.5-highspeed更快"
},
"glm": {
"apiKeyPlaceholder": "输入 Z.AI API Key"
}
},
"loadingModels": "正在加载模型...",
"noModelsMatch": "没有匹配“{{searchTerm}}”的模型",
"moreModels": "+{{count}} 个更多模型 • 缩小搜索范围",
"apiKeySession": "API Key 保存在会话存储中,关闭此标签页后会被清除。",
"startLocalServer": "启动本地服务器",
"settingsSaved": "设置已保存",
"failedToSave": "保存失败",
"saveSettings": "保存设置",
"endpoint": "端点",
"azurePortal": "Azure Portal",
"azureHint": "在这里配置 Azure OpenAI 服务:",
"defaultPort": "默认端口为",
"pullModel": "使用以下命令拉取模型",
"browseModels": "在这里浏览全部模型:",
"openRouterModels": "OpenRouter Models",
"zaiPlatform": "Z.AI Platform",
"glmCodingApi": "Coding API默认。通用 API 请使用 https://api.z.ai/api/paas/v4。",
"privacyFull": "你的 API Key 只保存在此浏览器的会话存储中,关闭标签页后会清除。聊天时会直接发送给 LLM 提供商,你的代码不会离开本机。"
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './i18n';
import './index.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(

View file

@ -1,8 +1,41 @@
import { beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
const I18N_LANGUAGE_STORAGE_KEY = 'gitnexus.lng';
function ensureStorage(name: 'localStorage' | 'sessionStorage') {
const current = globalThis[name];
if (
current &&
typeof current.getItem === 'function' &&
typeof current.removeItem === 'function'
) {
return;
}
const store = new Map<string, string>();
Object.defineProperty(globalThis, name, {
configurable: true,
value: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
},
},
});
}
ensureStorage('localStorage');
ensureStorage('sessionStorage');
localStorage.removeItem(I18N_LANGUAGE_STORAGE_KEY);
// Reset storage between tests
beforeEach(() => {
sessionStorage.removeItem('gitnexus-llm-settings');
localStorage.removeItem('gitnexus-llm-settings'); // legacy key (migration)
localStorage.removeItem(I18N_LANGUAGE_STORAGE_KEY);
});

View file

@ -0,0 +1,396 @@
import { describe, expect, it } from 'vitest';
import {
buildLangChainMessages,
createChatModel,
serializeAgentHistoryMessages,
type AgentMessage,
} from '../../src/core/llm/agent';
import {
buildDeepSeekRequestMessages,
DeepSeekChatOpenAI,
DeepSeekChatOpenAICompletions,
} from '../../src/core/llm/deepseek-chat-model';
describe('buildLangChainMessages', () => {
it('reconstructs assistant tool-call turns for replay', () => {
const messages: AgentMessage[] = [
{ role: 'user', content: 'Check the weather' },
{
role: 'assistant',
content: 'Let me check that.',
reasoningContent: '',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
];
const langChainMessages = buildLangChainMessages(messages);
expect(langChainMessages).toHaveLength(3);
expect((langChainMessages[1] as any).additional_kwargs.reasoning_content).toBe('');
expect((langChainMessages[1] as any).tool_calls).toEqual([
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
]);
expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather');
});
});
describe('serializeAgentHistoryMessages', () => {
it('captures assistant and tool messages from a completed turn', () => {
const serialized = serializeAgentHistoryMessages(
[
{ _getType: () => 'human', content: 'old prompt' },
{
_getType: () => 'ai',
content: 'Let me check that.',
additional_kwargs: { reasoning_content: 'Need weather tool.' },
tool_calls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
_getType: () => 'tool',
content: 'Cloudy 7~13°C',
tool_call_id: 'call_weather',
name: 'get_weather',
},
{
_getType: () => 'ai',
content: 'Tomorrow will be cloudy.',
additional_kwargs: { reasoning_content: 'Result received.' },
},
],
1,
);
expect(serialized).toEqual([
{
role: 'assistant',
content: 'Let me check that.',
reasoningContent: 'Need weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
{
role: 'assistant',
content: 'Tomorrow will be cloudy.',
},
]);
});
});
describe('buildDeepSeekRequestMessages', () => {
it('preserves reasoning_content on assistant tool-call messages', () => {
const requestMessages = buildDeepSeekRequestMessages(
buildLangChainMessages([
{ role: 'user', content: '如何支持Gitlab Repo' },
{
role: 'assistant',
content: '',
reasoningContent: 'I should inspect the repository support flow first.',
toolCalls: [
{
id: 'call_1',
name: 'search',
args: { query: 'Gitlab repo support' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'No matches',
toolCallId: 'call_1',
name: 'search',
},
]),
);
expect(requestMessages).toEqual([
{ role: 'user', content: '如何支持Gitlab Repo' },
{
role: 'assistant',
content: '',
reasoning_content: 'I should inspect the repository support flow first.',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: {
name: 'search',
arguments: '{"query":"Gitlab repo support"}',
},
},
],
},
{
role: 'tool',
content: 'No matches',
name: 'search',
tool_call_id: 'call_1',
},
]);
});
});
it('drops reasoning_content from assistant messages without tool calls', () => {
const messages = buildLangChainMessages([
{ role: 'user', content: 'Hello' },
{
role: 'assistant',
content: 'Hi there',
reasoningContent: 'I should greet the user.',
},
]);
const requestMessages = buildDeepSeekRequestMessages(messages);
expect(requestMessages).toEqual([
{ role: 'user', content: 'Hello' },
{ role: 'assistant', content: 'Hi there' },
]);
});
it('drops reasoningContent from serialized assistant messages without tool calls', () => {
const serialized = serializeAgentHistoryMessages(
[
{
_getType: () => 'ai',
content: 'Simple answer.',
additional_kwargs: { reasoning_content: 'Thinking about it.' },
},
],
0,
);
expect(serialized).toEqual([
{
role: 'assistant',
content: 'Simple answer.',
},
]);
});
describe('createChatModel', () => {
it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
expect(model).toBeInstanceOf(DeepSeekChatOpenAI);
expect(model.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
const clonedModel = model.withConfig({ tools: [] }) as any;
expect(clonedModel).toBeInstanceOf(DeepSeekChatOpenAI);
expect(clonedModel.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
});
it('uses DeepSeek serialization on withConfig clones', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
const clonedModel = model.withConfig({ tools: [] }) as any;
clonedModel.completions.streaming = false;
let capturedRequest: any;
clonedModel.completions.client = {
chat: {
completions: {
create: async (request: any) => {
capturedRequest = request;
return {
choices: [
{
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
},
],
};
},
},
},
};
await clonedModel.completions._generate(
buildLangChainMessages([
{ role: 'user', content: 'Check the weather' },
{
role: 'assistant',
content: '',
reasoningContent: 'Need the weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
{
role: 'tool',
content: 'Cloudy 7~13°C',
toolCallId: 'call_weather',
name: 'get_weather',
},
]),
{ stream: false },
);
expect(capturedRequest.messages[1].reasoning_content).toBe('Need the weather tool.');
expect(capturedRequest.messages[1].tool_calls[0].function.arguments).toBe(
'{"location":"Hangzhou"}',
);
expect(capturedRequest.messages[2].tool_call_id).toBe('call_weather');
});
it('preserves reasoning_content through the streaming path used by DeepSeek tool calls', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
model.completions.streaming = true;
async function* mockStream() {
yield {
id: 'chatcmpl-1',
model: 'deepseek-v4-flash',
choices: [
{
index: 0,
delta: {
role: 'assistant',
reasoning_content: 'Need the weather tool.',
},
},
],
};
yield {
id: 'chatcmpl-1',
model: 'deepseek-v4-flash',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: 'call_weather',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"location":"Hangzhou"}',
},
},
],
},
finish_reason: 'tool_calls',
},
],
};
}
model.completions.client = {
chat: {
completions: {
create: async () => mockStream(),
},
},
};
let streamedMessage: any;
for await (const chunk of model.completions._streamResponseChunks(
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
{},
)) {
streamedMessage = streamedMessage ? streamedMessage.concat(chunk.message) : chunk.message;
}
expect(streamedMessage.additional_kwargs.reasoning_content).toBe('Need the weather tool.');
expect(streamedMessage.tool_calls).toEqual([
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
]);
expect(serializeAgentHistoryMessages([streamedMessage], 0)).toEqual([
{
role: 'assistant',
content: '',
reasoningContent: 'Need the weather tool.',
toolCalls: [
{
id: 'call_weather',
name: 'get_weather',
args: { location: 'Hangzhou' },
type: 'tool_call',
},
],
},
]);
});
it('rejects overlapping DeepSeek requests before reusing active messages', async () => {
const model = createChatModel({
provider: 'deepseek',
apiKey: 'test-key',
model: 'deepseek-v4-flash',
temperature: 0.1,
} as any) as any;
model.completions.activeMessages = buildLangChainMessages([{ role: 'user', content: 'busy' }]);
await expect(
model.completions._generate(
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
{ stream: false },
),
).rejects.toThrow('DeepSeekChatOpenAICompletions does not support overlapping requests');
});
});

View file

@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
AUTO_START_EMBEDDINGS_STORAGE_KEY,
shouldAutoStartEmbeddings,
} from '../../src/hooks/useAppState';
describe('embedding auto-start gate', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('defaults to disabled so connecting a repo remains read-only', () => {
expect(shouldAutoStartEmbeddings()).toBe(false);
});
it('allows opt-in through localStorage', () => {
window.localStorage.setItem(AUTO_START_EMBEDDINGS_STORAGE_KEY, 'true');
expect(shouldAutoStartEmbeddings()).toBe(true);
});
it('treats any non-true value as disabled', () => {
window.localStorage.setItem(AUTO_START_EMBEDDINGS_STORAGE_KEY, 'false');
expect(shouldAutoStartEmbeddings()).toBe(false);
});
});

View file

@ -0,0 +1,98 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it } from 'vitest';
import i18n, { LANGUAGE_STORAGE_KEY, i18nReady } from '../../src/i18n';
import { normalizeSupportedLanguage } from '../../src/i18n/languages';
import { namespaceList, resources } from '../../src/i18n/resources';
import { LanguageSwitcher } from '../../src/components/LanguageSwitcher';
function flattenKeys(value: unknown, prefix = ''): string[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) return [prefix];
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
flattenKeys(nested, prefix ? `${prefix}.${key}` : key),
);
}
describe('web i18n', () => {
afterEach(async () => {
delete (i18n.getResourceBundle('en', 'settings') as Record<string, unknown> | undefined)
?.crossNamespaceProbe;
delete (i18n.getResourceBundle('zh-CN', 'graph') as Record<string, unknown> | undefined)
?.crossNamespaceProbe;
i18n.removeResourceBundle('en', 'fallback-test');
await i18n.changeLanguage('en');
window.localStorage.clear();
});
it('loads matching namespace and key sets for English and Simplified Chinese', () => {
expect(namespaceList).toContain('common');
expect(Object.keys(resources.en ?? {}).sort()).toEqual(
Object.keys(resources['zh-CN'] ?? {}).sort(),
);
for (const namespace of namespaceList) {
const enKeys = flattenKeys(resources.en?.[namespace]).sort();
const zhKeys = flattenKeys(resources['zh-CN']?.[namespace]).sort();
expect(zhKeys, namespace).toEqual(enKeys);
}
});
it('switches to zh-CN, updates html lang, and returns Chinese translations', async () => {
await i18nReady;
await i18n.changeLanguage('zh-CN');
expect(i18n.t('common:progress.connecting')).toBe('正在连接服务器...');
expect(document.documentElement.lang).toBe('zh-CN');
expect(document.documentElement.dir).toBe('ltr');
});
it('falls back to English when the active language misses a key', async () => {
await i18nReady;
i18n.addResourceBundle('en', 'fallback-test', { only: 'Fallback only' });
await i18n.changeLanguage('zh-CN');
expect(i18n.t('fallback-test:only')).toBe('Fallback only');
});
it('does not fall back across unrelated namespaces', async () => {
await i18nReady;
i18n.addResource('en', 'settings', 'crossNamespaceProbe', 'English settings fallback');
i18n.addResource('zh-CN', 'graph', 'crossNamespaceProbe', 'Wrong graph fallback');
await i18n.changeLanguage('zh-CN');
expect(i18n.t('settings:crossNamespaceProbe')).toBe('English settings fallback');
});
it('normalizes supported detector aliases and rejects unsupported languages', () => {
expect(normalizeSupportedLanguage('zh')).toBe('zh-CN');
expect(normalizeSupportedLanguage('zh-cn')).toBe('zh-CN');
expect(normalizeSupportedLanguage('zh_CN.UTF-8')).toBe('zh-CN');
expect(normalizeSupportedLanguage('en-US')).toBe('en');
expect(normalizeSupportedLanguage('fr')).toBeNull();
expect(normalizeSupportedLanguage('zh-TW')).toBeNull();
});
it('does not cache unsupported language codes', async () => {
await i18nReady;
window.localStorage.clear();
await i18n.changeLanguage('fr');
await waitFor(() => expect(document.documentElement.lang).toBe('en'));
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).not.toBe('fr');
expect((i18n.options.detection as { caches?: string[] }).caches).toEqual([]);
});
it('persists language changes from the header switcher', async () => {
await i18nReady;
const user = userEvent.setup();
render(<LanguageSwitcher />);
await user.selectOptions(screen.getByLabelText('Select language'), 'zh-CN');
await waitFor(() => expect(document.documentElement.lang).toBe('zh-CN'));
expect(i18n.t('common:progress.connecting')).toBe('正在连接服务器...');
expect(window.localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe('zh-CN');
expect(document.documentElement.lang).toBe('zh-CN');
});
});

View file

@ -8,6 +8,7 @@ import {
clearSettings,
getProviderDisplayName,
getAvailableModels,
getProviderCapabilities,
} from '../../src/core/llm/settings-service';
describe('loadSettings', () => {
@ -104,6 +105,17 @@ describe('getActiveProviderConfig', () => {
expect(config!.provider).toBe('openai');
});
it('returns config for deepseek when API key is set', () => {
const settings = loadSettings();
settings.activeProvider = 'deepseek';
settings.deepseek = { ...settings.deepseek, apiKey: 'sk-deepseek-123' };
saveSettings(settings);
const config = getActiveProviderConfig();
expect(config).not.toBeNull();
expect(config!.provider).toBe('deepseek');
});
it('returns null for openrouter with empty API key', () => {
const settings = loadSettings();
settings.activeProvider = 'openrouter';
@ -139,6 +151,7 @@ describe('getProviderDisplayName', () => {
expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
expect(getProviderDisplayName('deepseek')).toBe('DeepSeek');
});
});
@ -147,9 +160,18 @@ describe('getAvailableModels', () => {
expect(getAvailableModels('openai').length).toBeGreaterThan(0);
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash');
});
it('returns empty array for unknown provider', () => {
expect(getAvailableModels('unknown' as any)).toEqual([]);
});
});
describe('getProviderCapabilities', () => {
it('enables transcript replay only for providers that require it', () => {
expect(getProviderCapabilities('deepseek').preserveAssistantTranscript).toBe(true);
expect(getProviderCapabilities('openai').preserveAssistantTranscript).toBe(false);
expect(getProviderCapabilities('anthropic').preserveAssistantTranscript).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
describe.sequential('test setup storage isolation', () => {
it('can seed a persisted i18n language inside one test', () => {
localStorage.setItem('gitnexus.lng', 'zh-CN');
expect(localStorage.getItem('gitnexus.lng')).toBe('zh-CN');
});
it('clears the persisted i18n language before the next test', () => {
expect(localStorage.getItem('gitnexus.lng')).toBeNull();
});
});

View file

@ -4203,9 +4203,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@ -5044,9 +5044,9 @@
"optional": true
},
"node_modules/tsx": {
"version": "4.22.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -6,6 +6,7 @@
*/
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../core/logger.js';
import {
findRepo,
@ -14,21 +15,64 @@ import {
assertSafeStoragePath,
UnsafeStoragePathError,
} from '../storage/repo-manager.js';
import {
cleanQuarantinedMissingShadowWals,
inspectLbugSidecars,
listQuarantinedMissingShadowWals,
} from '../core/lbug/sidecar-recovery.js';
import { t } from './i18n/index.js';
export const cleanCommand = async (options?: {
force?: boolean;
all?: boolean;
lbugSidecars?: boolean;
}) => {
if (options?.lbugSidecars) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const lbugPath = path.join(repo.storagePath, 'lbug');
const state = await inspectLbugSidecars(lbugPath);
const quarantined = await listQuarantinedMissingShadowWals(lbugPath);
console.log(t('clean.lbugSidecars.state', { state: state.kind }));
if (quarantined.length === 0) {
console.log(t('clean.lbugSidecars.none'));
return;
}
if (!options.force) {
console.log(t('clean.lbugSidecars.preview', { count: quarantined.length }));
for (const file of quarantined) {
console.log(` - ${file}`);
}
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
const deleted = await cleanQuarantinedMissingShadowWals(lbugPath);
console.log(t('clean.lbugSidecars.deleted', { count: deleted.length }));
return;
}
export const cleanCommand = async (options?: { force?: boolean; all?: boolean }) => {
// --all flag: clean all indexed repos
if (options?.all) {
if (!options?.force) {
const entries = await listRegisteredRepos();
if (entries.length === 0) {
console.log('No indexed repositories found.');
console.log(t('common.notIndexed'));
return;
}
console.log(`This will delete GitNexus indexes for ${entries.length} repo(s):`);
console.log(t('clean.deleteAll', { count: entries.length }));
for (const entry of entries) {
console.log(` - ${entry.name} (${entry.path})`);
}
console.log('\nRun with --force to confirm deletion.');
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
@ -55,7 +99,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
try {
await fs.rm(entry.storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
console.log(`Deleted: ${entry.name} (${entry.storagePath})`);
console.log(t('clean.deletedRepo', { name: entry.name, storagePath: entry.storagePath }));
} catch (err) {
logger.error({ err }, `Failed to delete ${entry.name}:`);
}
@ -68,23 +112,23 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
const repo = await findRepo(cwd);
if (!repo) {
console.log('No indexed repository found in this directory.');
console.log(t('clean.notFoundHere'));
return;
}
const repoName = repo.repoPath.split(/[/\\]/).pop() || repo.repoPath;
if (!options?.force) {
console.log(`This will delete the GitNexus index for: ${repoName}`);
console.log(` Path: ${repo.storagePath}`);
console.log('\nRun with --force to confirm deletion.');
console.log(t('clean.deleteCurrent', { repoName }));
console.log(` ${t('common.path')}: ${repo.storagePath}`);
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
try {
await fs.rm(repo.storagePath, { recursive: true, force: true });
await unregisterRepo(repo.repoPath);
console.log(`Deleted: ${repo.storagePath}`);
console.log(t('common.deleted', { target: repo.storagePath }));
} catch (err) {
logger.error({ err }, 'Failed to delete:');
}

View file

@ -28,6 +28,7 @@
* stdout would corrupt that pipeline.
*/
import { logger } from '../core/logger.js';
import { t, type CliMessageKey, type CliMessageVars } from './i18n/index.js';
/**
* String-literal union of all `recoveryHint` tags emitted by the CLI.
@ -78,6 +79,18 @@ export function cliInfo(msg: string, fields?: CliMessageFields): void {
logger.info(fields ?? {}, msg);
}
/**
* Key-based informational message. Keeps the legacy string API intact while
* allowing commands to opt into localized user-facing stderr output.
*/
export function cliInfoKey(
key: CliMessageKey,
vars?: CliMessageVars,
fields?: Record<string, unknown>,
): void {
cliInfo(t(key, vars), fields);
}
/**
* User-facing warning. Operator-actionable but non-fatal `cliWarn`
* indicates the command can still proceed in some form.
@ -87,6 +100,14 @@ export function cliWarn(msg: string, fields?: CliMessageFields): void {
logger.warn(fields ?? {}, msg);
}
export function cliWarnKey(
key: CliMessageKey,
vars?: CliMessageVars,
fields?: Record<string, unknown>,
): void {
cliWarn(t(key, vars), fields);
}
/**
* User-facing error. Indicates the command cannot proceed; usually
* paired with a non-zero exit code at the call site.
@ -95,3 +116,11 @@ export function cliError(msg: string, fields?: CliMessageFields): void {
writeStderr(msg);
logger.error(fields ?? {}, msg);
}
export function cliErrorKey(
key: CliMessageKey,
vars?: CliMessageVars,
fields?: Record<string, unknown>,
): void {
cliError(t(key, vars), fields);
}

View file

@ -0,0 +1,86 @@
import { t } from './i18n/index.js';
type DetectChangesSummary = {
changed_files?: number;
changed_count?: number;
affected_count?: number;
risk_level?: string;
};
type ChangedSymbol = {
type?: string;
name?: string;
filePath?: string;
};
type ChangedStep = {
symbol?: string;
};
type AffectedProcess = {
name?: string;
step_count?: number;
changed_steps?: ChangedStep[];
};
type DetectChangesResult = {
error?: unknown;
summary?: DetectChangesSummary;
changed_symbols?: ChangedSymbol[];
affected_processes?: AffectedProcess[];
};
export function formatDetectChangesResult(result: unknown): string {
const payload = (result ?? {}) as DetectChangesResult;
if (payload.error) return t('common.error', { message: String(payload.error) });
const summary = payload.summary ?? {};
if ((summary.changed_count ?? 0) === 0) {
return t('tool.detectChanges.noChanges');
}
const lines: string[] = [];
lines.push(
t('tool.detectChanges.changesSummary', {
files: summary.changed_files ?? 0,
symbols: summary.changed_count ?? 0,
}),
);
lines.push(t('tool.detectChanges.affectedProcesses', { count: summary.affected_count ?? 0 }));
lines.push(
t('tool.detectChanges.riskLevel', {
risk: summary.risk_level || t('tool.detectChanges.unknownRisk'),
}),
);
lines.push('');
const changed = Array.isArray(payload.changed_symbols) ? payload.changed_symbols : [];
if (changed.length > 0) {
lines.push(t('tool.detectChanges.changedSymbols'));
for (const symbol of changed.slice(0, 15)) {
lines.push(` ${symbol.type ?? 'Symbol'} ${symbol.name ?? '?'}${symbol.filePath ?? '?'}`);
}
if (changed.length > 15) {
lines.push(t('tool.detectChanges.overflowMore', { count: changed.length - 15 }));
}
lines.push('');
}
const affected = Array.isArray(payload.affected_processes) ? payload.affected_processes : [];
if (affected.length > 0) {
lines.push(t('tool.detectChanges.affectedExecutionFlows'));
for (const processInfo of affected.slice(0, 10)) {
const changedSteps = Array.isArray(processInfo.changed_steps)
? processInfo.changed_steps
: [];
const steps = changedSteps.map((step) => step.symbol ?? '?').join(', ');
lines.push(
`${processInfo.name ?? '?'} (${t('tool.detectChanges.steps', {
count: processInfo.step_count ?? 0,
})}) ${t('tool.detectChanges.changedSteps', { steps })}`,
);
}
}
return lines.join('\n').trim();
}

View file

@ -1,32 +1,85 @@
import { getRuntimeCapabilities, getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { resolveEmbeddingConfig } from '../core/embeddings/config.js';
import { isHttpMode } from '../core/embeddings/http-client.js';
import { t } from './i18n/index.js';
function isCombiningMark(codePoint: number): boolean {
return (
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f)
);
}
function isWideCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1f300 && codePoint <= 0x1f64f) ||
(codePoint >= 0x1f900 && codePoint <= 0x1f9ff) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
);
}
export function displayWidth(value: string): number {
let width = 0;
for (const char of value) {
const codePoint = char.codePointAt(0);
if (codePoint === undefined || codePoint === 0) continue;
if (isCombiningMark(codePoint)) continue;
width += isWideCodePoint(codePoint) ? 2 : 1;
}
return width;
}
export function padDisplayEnd(value: string, columns: number): string {
return value + ' '.repeat(Math.max(0, columns - displayWidth(value)));
}
const label = (key: Parameters<typeof t>[0], width: number): string => padDisplayEnd(t(key), width);
export const doctorCommand = async () => {
const fingerprint = getRuntimeFingerprint();
const capabilities = getRuntimeCapabilities();
const embeddingConfig = resolveEmbeddingConfig();
console.log('GitNexus Doctor\n');
console.log('Runtime');
console.log(` OS: ${fingerprint.platform}/${fingerprint.arch}`);
console.log(` Node: ${fingerprint.node}`);
console.log(` GitNexus: ${fingerprint.gitnexus}`);
console.log(` LadybugDB: ${fingerprint.ladybugdb ?? 'unknown'}`);
console.log(` ONNX: ${fingerprint.onnxruntime ?? 'unknown'}`);
console.log(t('doctor.title') + '\n');
console.log(t('doctor.runtime'));
console.log(` ${label('doctor.labels.os', 10)}${fingerprint.platform}/${fingerprint.arch}`);
console.log(` ${label('doctor.labels.node', 10)}${fingerprint.node}`);
console.log(` ${label('doctor.labels.gitnexus', 10)}${fingerprint.gitnexus}`);
console.log(` ${label('doctor.labels.ladybugdb', 10)}${fingerprint.ladybugdb ?? 'unknown'}`);
console.log(` ${label('doctor.labels.onnx', 10)}${fingerprint.onnxruntime ?? 'unknown'}`);
console.log('');
console.log('Capabilities');
console.log(` Graph store: ${capabilities.graph}`);
console.log(` Full-text search:${capabilities.fts.padStart(10)}`);
console.log(` VECTOR index: ${capabilities.vector}`);
console.log(` Semantic mode: ${capabilities.semanticMode}`);
console.log(` Exact scan limit:${String(capabilities.exactScanLimit).padStart(9)} chunks`);
if (capabilities.reason) console.log(` Note: ${capabilities.reason}`);
console.log(t('doctor.capabilities'));
console.log(` ${label('doctor.labels.graphStore', 18)}${capabilities.graph}`);
console.log(` ${label('doctor.labels.fullTextSearch', 18)}${capabilities.fts}`);
console.log(` ${label('doctor.labels.vectorIndex', 18)}${capabilities.vector}`);
console.log(` ${label('doctor.labels.semanticMode', 18)}${capabilities.semanticMode}`);
console.log(
` ${label('doctor.labels.exactScanLimit', 18)}${t('doctor.chunks', { count: capabilities.exactScanLimit })}`,
);
if (capabilities.reason)
console.log(` ${label('doctor.labels.note', 18)}${capabilities.reason}`);
console.log('');
console.log('Embeddings');
console.log(` Backend: ${isHttpMode() ? 'http' : 'local'}`);
console.log(` Device: ${embeddingConfig.device}`);
console.log(` Threads: ${embeddingConfig.threads}`);
console.log(` Batch: ${embeddingConfig.batchSize} nodes`);
console.log(` Sub-batch: ${embeddingConfig.subBatchSize} chunks`);
console.log(t('doctor.embeddings'));
console.log(` ${label('doctor.labels.backend', 12)}${isHttpMode() ? 'http' : 'local'}`);
console.log(` ${label('doctor.labels.device', 12)}${embeddingConfig.device}`);
console.log(` ${label('doctor.labels.threads', 12)}${embeddingConfig.threads}`);
console.log(
` ${label('doctor.labels.batch', 12)}${t('doctor.nodes', { count: embeddingConfig.batchSize })}`,
);
console.log(
` ${label('doctor.labels.subBatch', 12)}${t('doctor.chunks', { count: embeddingConfig.subBatchSize })}`,
);
};

View file

@ -35,6 +35,9 @@ import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn, cliError } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
export { formatDetectChangesResult } from './detect-changes-format.js';
export interface EvalServerOptions {
port?: string;
@ -242,42 +245,6 @@ export function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
export function formatDetectChangesResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const summary = result.summary || {};
const lines: string[] = [];
if (summary.changed_count === 0) {
return 'No changes detected.';
}
lines.push(`Changes: ${summary.changed_files || 0} files, ${summary.changed_count || 0} symbols`);
lines.push(`Affected processes: ${summary.affected_count || 0}`);
lines.push(`Risk level: ${summary.risk_level || 'unknown'}\n`);
const changed = result.changed_symbols || [];
if (changed.length > 0) {
lines.push(`Changed symbols:`);
for (const s of changed.slice(0, 15)) {
lines.push(` ${s.type} ${s.name}${s.filePath}`);
}
if (changed.length > 15) lines.push(` ... and ${changed.length - 15} more`);
lines.push('');
}
const affected = result.affected_processes || [];
if (affected.length > 0) {
lines.push(`Affected execution flows:`);
for (const p of affected.slice(0, 10)) {
const steps = (p.changed_steps || []).map((s: any) => s.symbol).join(', ');
lines.push(`${p.name} (${p.step_count} steps) — changed: ${steps}`);
}
}
return lines.join('\n').trim();
}
export function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';

View file

@ -0,0 +1,222 @@
import type { Command, Option } from 'commander';
import { t, type CliMessageKey } from './i18n/index.js';
const TITLE_KEYS = {
'Usage:': 'help.title.usage',
'Arguments:': 'help.title.arguments',
'Options:': 'help.title.options',
'Global Options:': 'help.title.globalOptions',
'Commands:': 'help.title.commands',
} satisfies Record<string, CliMessageKey>;
const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',
mcp: 'help.command.mcp.description',
list: 'help.command.list.description',
status: 'help.command.status.description',
doctor: 'help.command.doctor.description',
clean: 'help.command.clean.description',
remove: 'help.command.remove.description',
wiki: 'help.command.wiki.description',
augment: 'help.command.augment.description',
publish: 'help.command.publish.description',
query: 'help.command.query.description',
context: 'help.command.context.description',
impact: 'help.command.impact.description',
cypher: 'help.command.cypher.description',
'detect-changes': 'help.command.detectChanges.description',
'eval-server': 'help.command.evalServer.description',
group: 'help.command.group.description',
'group create': 'help.command.group.create.description',
'group add': 'help.command.group.add.description',
'group remove': 'help.command.group.remove.description',
'group list': 'help.command.group.list.description',
'group status': 'help.command.group.status.description',
'group sync': 'help.command.group.sync.description',
'group impact': 'help.command.group.impact.description',
'group query': 'help.command.group.query.description',
'group contracts': 'help.command.group.contracts.description',
} satisfies Record<string, CliMessageKey>;
const OPTION_DESCRIPTION_KEYS = {
'|-V, --version': 'help.option.version',
'analyze|-f, --force': 'help.option.analyze.force',
'analyze|--repair-fts': 'help.option.analyze.repairFts',
'analyze|--embeddings [limit]': 'help.option.analyze.embeddings',
'analyze|--drop-embeddings': 'help.option.analyze.dropEmbeddings',
'analyze|--skills': 'help.option.analyze.skills',
'analyze|--skip-agents-md': 'help.option.analyze.skipAgentsMd',
'analyze|--no-stats': 'help.option.analyze.noStats',
'analyze|--skip-skills': 'help.option.analyze.skipSkills',
'analyze|--index-only': 'help.option.analyze.indexOnly',
'analyze|--skip-git': 'help.option.skipGit',
'analyze|--name <alias>': 'help.option.analyze.name',
'analyze|--allow-duplicate-name': 'help.option.analyze.allowDuplicateName',
'analyze|-v, --verbose': 'help.option.verbose',
'analyze|--max-file-size <kb>': 'help.option.analyze.maxFileSize',
'analyze|--worker-timeout <seconds>': 'help.option.analyze.workerTimeout',
'analyze|--wal-checkpoint-threshold <bytes>': 'help.option.analyze.walCheckpointThreshold',
'analyze|--workers <n>': 'help.option.analyze.workers',
'analyze|--embedding-threads <n>': 'help.option.analyze.embeddingThreads',
'analyze|--embedding-batch-size <n>': 'help.option.analyze.embeddingBatchSize',
'analyze|--embedding-sub-batch-size <n>': 'help.option.analyze.embeddingSubBatchSize',
'analyze|--embedding-device <device>': 'help.option.analyze.embeddingDevice',
'index|-f, --force': 'help.option.index.force',
'index|--allow-non-git': 'help.option.index.allowNonGit',
'serve|-p, --port <port>': 'help.option.port',
'serve|--host <host>': 'help.option.serve.host',
'clean|-f, --force': 'help.option.force.confirmation',
'clean|--all': 'help.option.clean.all',
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
'remove|-f, --force': 'help.option.force.confirmation',
'wiki|-f, --force': 'help.option.wiki.force',
'wiki|--provider <provider>': 'help.option.wiki.provider',
'wiki|--model <model>': 'help.option.wiki.model',
'wiki|--base-url <url>': 'help.option.wiki.baseUrl',
'wiki|--api-key <key>': 'help.option.wiki.apiKey',
'wiki|--api-version <version>': 'help.option.wiki.apiVersion',
'wiki|--reasoning-model': 'help.option.wiki.reasoningModel',
'wiki|--no-reasoning-model': 'help.option.wiki.noReasoningModel',
'wiki|--concurrency <n>': 'help.option.wiki.concurrency',
'wiki|--timeout <seconds>': 'help.option.wiki.timeout',
'wiki|--retries <n>': 'help.option.wiki.retries',
'wiki|--gist': 'help.option.wiki.gist',
'wiki|-v, --verbose': 'help.option.verbose',
'wiki|--review': 'help.option.wiki.review',
'wiki|--lang <lang>': 'help.option.wiki.lang',
'publish|--id <owner/repo>': 'help.option.publish.id',
'publish|--skip-git': 'help.option.skipGit',
'query|-r, --repo <name>': 'help.option.repo.targetOmitOne',
'query|-c, --context <text>': 'help.option.query.context',
'query|-g, --goal <text>': 'help.option.query.goal',
'query|-l, --limit <n>': 'help.option.query.limit',
'query|--content': 'help.option.content',
'context|-r, --repo <name>': 'help.option.repo.target',
'context|-u, --uid <uid>': 'help.option.context.uid',
'context|-f, --file <path>': 'help.option.context.file',
'context|--content': 'help.option.content',
'impact|-d, --direction <dir>': 'help.option.impact.direction',
'impact|-r, --repo <name>': 'help.option.repo.target',
'impact|--depth <n>': 'help.option.impact.depth',
'impact|--include-tests': 'help.option.impact.includeTests',
'cypher|-r, --repo <name>': 'help.option.repo.target',
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
'eval-server|-p, --port <port>': 'help.option.port',
'eval-server|--host <host>': 'help.option.evalServer.host',
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
'group create|--force': 'help.option.group.create.force',
'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings',
'group sync|--exact-only': 'help.option.group.sync.exactOnly',
'group sync|--allow-stale': 'help.option.group.sync.allowStale',
'group sync|--verbose': 'help.option.group.sync.verbose',
'group sync|--json': 'help.option.json',
'group impact|--target <symbol>': 'help.option.group.impact.target',
'group impact|--repo <groupPath>': 'help.option.group.impact.repo',
'group impact|--direction <dir>': 'help.option.impact.direction',
'group impact|--service <path>': 'help.option.group.impact.service',
'group impact|--subgroup <path>': 'help.option.group.impact.subgroup',
'group impact|--max-depth <n>': 'help.option.impact.depth',
'group impact|--cross-depth <n>': 'help.option.group.impact.crossDepth',
'group impact|--min-confidence <n>': 'help.option.group.impact.minConfidence',
'group impact|--include-tests': 'help.option.impact.includeTests',
'group impact|--timeout-ms <n>': 'help.option.group.impact.timeoutMs',
'group impact|--json': 'help.option.json',
'group query|--subgroup <path>': 'help.option.group.query.subgroup',
'group query|--limit <n>': 'help.option.group.query.limit',
'group query|--json': 'help.option.json',
'group contracts|--type <type>': 'help.option.group.contracts.type',
'group contracts|--repo <repo>': 'help.option.group.contracts.repo',
'group contracts|--unmatched': 'help.option.group.contracts.unmatched',
'group contracts|--json': 'help.option.json',
} satisfies Record<string, CliMessageKey>;
function localizeTitle(title: string): string {
const key = TITLE_KEYS[title as keyof typeof TITLE_KEYS];
return key ? t(key) : title;
}
function localizeOptionDescription(option: Option): string {
const extraInfo = [];
if (option.argChoices) {
const label = t('help.optionMeta.choices');
extraInfo.push(
`${label}: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(', ')}`,
);
}
if (option.defaultValue !== undefined) {
const showDefault =
option.required ||
option.optional ||
(option.isBoolean() && typeof option.defaultValue === 'boolean');
if (showDefault) {
const label = t('help.optionMeta.default');
extraInfo.push(
`${label}: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}`,
);
}
}
if (option.presetArg !== undefined && option.optional) {
const label = t('help.optionMeta.preset');
extraInfo.push(`${label}: ${JSON.stringify(option.presetArg)}`);
}
if (option.envVar !== undefined) {
const label = t('help.optionMeta.env');
extraInfo.push(`${label}: ${option.envVar}`);
}
if (extraInfo.length > 0) {
const extraDescription = `(${extraInfo.join(', ')})`;
if (option.description) return `${option.description} ${extraDescription}`;
return extraDescription;
}
return option.description;
}
function pathFor(commandPath: string, command: Command): string {
if (!command.parent) return '';
return commandPath ? `${commandPath} ${command.name()}` : command.name();
}
function applyHelpI18n(command: Command, commandPath = ''): void {
const descriptionKey =
COMMAND_DESCRIPTION_KEYS[commandPath as keyof typeof COMMAND_DESCRIPTION_KEYS];
if (descriptionKey) command.description(t(descriptionKey));
command.helpOption('-h, --help', t('help.option.help'));
command.configureHelp({
styleTitle: localizeTitle,
optionDescription: localizeOptionDescription,
});
if (command.commands.length > 0) {
command.helpCommand('help [command]', t('help.command.help.description'));
}
for (const option of command.options) {
const optionKey =
OPTION_DESCRIPTION_KEYS[
`${commandPath}|${option.flags}` as keyof typeof OPTION_DESCRIPTION_KEYS
];
if (optionKey) option.description = t(optionKey);
}
for (const subcommand of command.commands) {
applyHelpI18n(subcommand, pathFor(commandPath, subcommand));
}
}
export function localizeCliHelp(program: Command): Command {
applyHelpI18n(program);
return program;
}

245
gitnexus/src/cli/i18n/en.ts Normal file
View file

@ -0,0 +1,245 @@
export const en = {
'common.notIndexed': 'No indexed repositories found.',
'common.runAnalyze': 'Run `gitnexus analyze` in a git repo to index it.',
'common.runAnalyzeShort': 'Run: gitnexus analyze',
'common.runForceConfirm': 'Run with --force to confirm deletion.',
'common.path': 'Path',
'common.storage': 'Storage',
'common.deleted': 'Deleted: {{target}}',
'common.error': 'Error: {{message}}',
'list.title': 'Indexed Repositories ({{count}})',
'list.indexed': 'Indexed',
'list.commit': 'Commit',
'list.stats': 'Stats',
'list.statsValue': '{{files}} files, {{symbols}} symbols, {{edges}} edges',
'list.clusters': 'Clusters',
'list.processes': 'Processes',
'list.unknown': 'unknown',
'status.notGitRepo': 'Not a git repository.',
'status.staleKuzu': 'Repository has a stale KuzuDB index from a previous version.',
'status.rebuildLadybug': 'Run: gitnexus analyze (rebuilds the index with LadybugDB)',
'status.repoNotIndexed': 'Repository not indexed.',
'status.repository': 'Repository',
'status.indexed': 'Indexed',
'status.indexedCommit': 'Indexed commit',
'status.currentCommit': 'Current commit',
'status.status': 'Status',
'status.upToDate': '✅ up-to-date',
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
'clean.deleteAll': 'This will delete GitNexus indexes for {{count}} repo(s):',
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
'clean.notFoundHere': 'No indexed repository found in this directory.',
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
'clean.lbugSidecars.none': 'No quarantined LadybugDB missing-shadow WAL sidecars found.',
'clean.lbugSidecars.preview':
'This will delete {{count}} quarantined LadybugDB missing-shadow WAL sidecar(s):',
'clean.lbugSidecars.deleted':
'Deleted {{count}} quarantined LadybugDB missing-shadow WAL sidecar(s).',
'remove.nothingToRemove': 'Nothing to remove: {{message}}',
'remove.deleteTarget': 'This will delete the GitNexus index for: {{name}}',
'remove.removed': 'Removed: {{name}}',
'remove.failed': 'Failed to remove {{name}}: {{message}}',
'tool.noIndexed': 'GitNexus: No indexed repositories found. Run: gitnexus analyze',
'tool.usage.query': 'Usage: gitnexus query <search_query>',
'tool.usage.context': 'Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]',
'tool.usage.impact': 'Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]',
'tool.usage.cypher': 'Usage: gitnexus cypher <cypher_query>',
'tool.detectChanges.noChanges': 'No changes detected.',
'tool.detectChanges.changesSummary': 'Changes: {{files}} files, {{symbols}} symbols',
'tool.detectChanges.affectedProcesses': 'Affected processes: {{count}}',
'tool.detectChanges.riskLevel': 'Risk level: {{risk}}',
'tool.detectChanges.unknownRisk': 'unknown',
'tool.detectChanges.changedSymbols': 'Changed symbols:',
'tool.detectChanges.overflowMore': '... and {{count}} more',
'tool.detectChanges.affectedExecutionFlows': 'Affected execution flows:',
'tool.detectChanges.steps': '{{count}} steps',
'tool.detectChanges.steps_one': '{{count}} step',
'tool.detectChanges.steps_other': '{{count}} steps',
'tool.detectChanges.changedSteps': 'changed: {{steps}}',
'serve.walCorruption':
'\nGitNexus server could not start: the index has a corrupted WAL file.\n {{suggestion}}\n',
'serve.portInUse':
'\nFailed to start GitNexus server:\n {{message}}\n\n Port {{port}} is already in use. Either:\n 1. Stop the other process using port {{port}}\n 2. Use a different port: gitnexus serve --port 4748\n',
'serve.startFailed': '\nFailed to start GitNexus server:\n {{message}}\n',
'doctor.title': 'GitNexus Doctor',
'doctor.runtime': 'Runtime',
'doctor.capabilities': 'Capabilities',
'doctor.embeddings': 'Embeddings',
'doctor.labels.os': 'OS:',
'doctor.labels.node': 'Node:',
'doctor.labels.gitnexus': 'GitNexus:',
'doctor.labels.ladybugdb': 'LadybugDB:',
'doctor.labels.onnx': 'ONNX:',
'doctor.labels.graphStore': 'Graph store:',
'doctor.labels.fullTextSearch': 'Full-text search:',
'doctor.labels.vectorIndex': 'VECTOR index:',
'doctor.labels.semanticMode': 'Semantic mode:',
'doctor.labels.exactScanLimit': 'Exact scan limit:',
'doctor.labels.note': 'Note:',
'doctor.labels.backend': 'Backend:',
'doctor.labels.device': 'Device:',
'doctor.labels.threads': 'Threads:',
'doctor.labels.batch': 'Batch:',
'doctor.labels.subBatch': 'Sub-batch:',
'doctor.nodes': '{{count}} nodes',
'doctor.nodes_one': '{{count}} node',
'doctor.nodes_other': '{{count}} nodes',
'doctor.chunks': '{{count}} chunks',
'doctor.chunks_one': '{{count}} chunk',
'doctor.chunks_other': '{{count}} chunks',
'help.title.usage': 'Usage:',
'help.title.arguments': 'Arguments:',
'help.title.options': 'Options:',
'help.title.globalOptions': 'Global Options:',
'help.title.commands': 'Commands:',
'help.optionMeta.choices': 'choices',
'help.optionMeta.default': 'default',
'help.optionMeta.preset': 'preset',
'help.optionMeta.env': 'env',
'help.description.root': 'GitNexus local CLI and MCP server',
'help.command.help.description': 'display help for command',
'help.option.help': 'display help for command',
'help.option.version': 'output the version number',
'help.command.setup.description':
'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
'help.command.serve.description': 'Start local HTTP server for web UI connection',
'help.command.mcp.description': 'Start MCP server (stdio) — serves all indexed repos',
'help.command.list.description': 'List all indexed repositories',
'help.command.status.description': 'Show index status for current repo',
'help.command.doctor.description':
'Show runtime platform capabilities and embedding configuration',
'help.command.clean.description': 'Delete GitNexus index for current repo',
'help.command.remove.description':
'Delete the GitNexus index for a registered repo (by alias, name, or absolute path). Unlike `clean`, does not require being inside the repo. Idempotent on unknown targets.',
'help.command.wiki.description': 'Generate repository wiki from knowledge graph',
'help.command.augment.description':
'Augment a search pattern with knowledge graph context (used by hooks)',
'help.command.publish.description':
'Notify the understand-quickly registry that this repo has a fresh GitNexus index. Opt-in: requires UNDERSTAND_QUICKLY_TOKEN (fine-grained PAT with `Repository dispatches: write` on looptech-ai/understand-quickly). No-op without the token. See https://github.com/looptech-ai/understand-quickly.',
'help.command.query.description':
'Search the knowledge graph for execution flows related to a concept',
'help.command.context.description':
'360-degree view of a code symbol: callers, callees, processes',
'help.command.impact.description': 'Blast radius analysis: what breaks if you change a symbol',
'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
'help.command.detectChanges.description':
'Map git diff hunks to indexed symbols and affected execution flows',
'help.command.evalServer.description':
'Start lightweight HTTP server for fast tool calls during evaluation',
'help.command.group.description': 'Manage repository groups for cross-index impact analysis',
'help.command.group.create.description': 'Create a new group with template group.yaml',
'help.command.group.add.description':
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = name from registry',
'help.command.group.remove.description': 'Remove a repo from a group',
'help.command.group.list.description': 'List all groups or details of one',
'help.command.group.status.description': 'Check staleness of group and repos',
'help.command.group.sync.description':
'Sync Contract Registry — extract contracts and build cross-links',
'help.command.group.impact.description':
'Cross-repo impact for a symbol in one member repo of a group',
'help.command.group.query.description': 'Search execution flows across all repos in a group',
'help.command.group.contracts.description': 'Inspect Contract Registry',
'help.option.analyze.force': 'Force full re-index even if up to date',
'help.option.analyze.repairFts': 'Repair/rebuild search FTS indexes without full re-analysis',
'help.option.analyze.embeddings':
'Enable embedding generation for semantic search (off by default). Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
'help.option.analyze.dropEmbeddings':
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves any embeddings already present in the index.',
'help.option.analyze.skills':
'Generate repo-specific skill files from detected communities (no-op when --index-only is also set).',
'help.option.analyze.skipAgentsMd':
'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md',
'help.option.analyze.noStats': 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md',
'help.option.analyze.skipSkills':
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. Does not suppress community skills from --skills (those use .claude/skills/generated/). Use --index-only to skip all AI-context file injection.',
'help.option.analyze.indexOnly':
'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)',
'help.option.skipGit':
'Treat the provided path/cwd as the index root and skip parent git-root discovery',
'help.option.analyze.name':
'Register this repo under a custom name in ~/.gitnexus/registry.json (disambiguates repos whose paths share a basename, e.g. two different .../app folders)',
'help.option.analyze.allowDuplicateName':
'Register this repo even if another path already uses the same --name alias. Leaves `-r <name>` ambiguous for the two paths; use -r <path> to disambiguate.',
'help.option.verbose': 'Enable verbose output',
'help.option.analyze.maxFileSize':
'Skip files larger than this (KB). Default: 512. Hard cap: 32768 (tree-sitter limit).',
'help.option.analyze.workerTimeout':
'Worker sub-batch idle timeout before retry/fallback. Default: 30.',
'help.option.analyze.walCheckpointThreshold':
'LadybugDB WAL auto-checkpoint threshold in bytes during analyze (integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).',
'help.option.analyze.workers':
'Parse worker pool size. Default: cores-1 capped at 16. Pass 0 to disable workers (sequential).',
'help.option.analyze.embeddingThreads': 'Limit local ONNX embedding CPU threads',
'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch',
'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call',
'help.option.analyze.embeddingDevice': 'Embedding device: auto, cpu, dml, cuda, or wasm',
'help.option.index.force': 'Register even if meta.json is missing (stats will be empty)',
'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories',
'help.option.port': 'Port number',
'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.clean.all': 'Clean all indexed repos',
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider': 'LLM provider: openai or cursor (default: openai)',
'help.option.wiki.model': 'LLM model or Azure deployment name (default: minimax/minimax-m2.5)',
'help.option.wiki.baseUrl':
'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1',
'help.option.wiki.apiKey': 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)',
'help.option.wiki.apiVersion':
'Azure api-version query param, e.g. 2024-10-21 (legacy Azure API only)',
'help.option.wiki.reasoningModel':
'Mark deployment as reasoning model (o1/o3/o4-mini) — strips temperature, uses max_completion_tokens',
'help.option.wiki.noReasoningModel': 'Disable reasoning model mode (overrides saved config)',
'help.option.wiki.concurrency': 'Parallel LLM calls (default: 3)',
'help.option.wiki.timeout': 'LLM request timeout in seconds (default: disabled)',
'help.option.wiki.retries': 'Max LLM retry attempts per request (default: 3)',
'help.option.wiki.gist': 'Publish wiki as a public GitHub Gist after generation',
'help.option.wiki.review':
'Stop after grouping to review module structure before generating pages',
'help.option.wiki.lang':
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
'help.option.publish.id': 'Override the registry id (defaults to the origin remote)',
'help.option.repo.targetOmitOne': 'Target repository (omit if only one indexed)',
'help.option.query.context': 'Task context to improve ranking',
'help.option.query.goal': 'What you want to find',
'help.option.query.limit': 'Max processes to return (default: 5)',
'help.option.content': 'Include full symbol source code',
'help.option.repo.target': 'Target repository',
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
'help.option.context.file': 'File path to disambiguate common names',
'help.option.impact.direction': 'upstream (dependants) or downstream (dependencies)',
'help.option.impact.depth': 'Max relationship depth (default: 3)',
'help.option.impact.includeTests': 'Include test files in results',
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
'help.option.evalServer.host':
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
'help.option.group.create.force': 'Overwrite existing group',
'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)',
'help.option.group.sync.exactOnly': 'Exact match only',
'help.option.group.sync.allowStale': 'Skip stale index warnings',
'help.option.group.sync.verbose': 'Show each cross-link detail',
'help.option.json': 'JSON output',
'help.option.group.impact.target': 'Symbol or file name to analyze',
'help.option.group.impact.repo':
'Member path from group.yaml (e.g. app/backend), not the indexed repo name',
'help.option.group.impact.service': 'Optional monorepo service directory prefix (path filter)',
'help.option.group.impact.subgroup':
'Optional prefix limiting which group repos participate in cross fan-out',
'help.option.group.impact.crossDepth': 'Cross-repository hop depth',
'help.option.group.impact.minConfidence': 'Minimum relation confidence (01)',
'help.option.group.impact.timeoutMs': 'Phase-1 local impact wall time in milliseconds',
'help.option.group.query.subgroup': 'Limit search scope',
'help.option.group.query.limit': 'Max merged results',
'help.option.group.contracts.type': 'Filter by contract type',
'help.option.group.contracts.repo': 'Filter by repo',
'help.option.group.contracts.unmatched': 'Show only unmatched contracts',
'help.analyze.environment':
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
} as const;

View file

@ -0,0 +1,57 @@
import { cliResources } from './resources.js';
import { en } from './en.js';
export type SupportedCliLanguage = keyof typeof cliResources;
export type CliMessageKey = keyof typeof en;
export type CliMessageVars = Record<string, string | number | boolean | undefined | null>;
let overrideLanguage: SupportedCliLanguage | null = null;
function normalizeCliLanguage(raw: string): SupportedCliLanguage {
const normalized = raw.trim().split('.')[0]?.replace(/_/g, '-').toLowerCase() ?? '';
if (!normalized) return 'en';
// GitNexus currently ships Simplified Chinese only. Do not map Traditional
// Chinese locales (zh-TW/zh-HK/zh-Hant) to zh-CN just because they start
// with "zh".
if (
normalized === 'zh' ||
normalized === 'zh-cn' ||
normalized.startsWith('zh-cn-') ||
normalized === 'zh-hans' ||
normalized.startsWith('zh-hans-')
) {
return 'zh-CN';
}
return 'en';
}
export function detectCliLanguage(env: NodeJS.ProcessEnv = process.env): SupportedCliLanguage {
const raw = env.GITNEXUS_LANG || env.LC_ALL || env.LC_MESSAGES || env.LANG || '';
return normalizeCliLanguage(raw);
}
export function setCliLanguage(language: SupportedCliLanguage | null): void {
overrideLanguage = language;
}
export function getCliLanguage(): SupportedCliLanguage {
return overrideLanguage ?? detectCliLanguage();
}
export function t(key: CliMessageKey, vars: CliMessageVars = {}): string {
const language = getCliLanguage();
const count = typeof vars.count === 'number' && Number.isFinite(vars.count) ? vars.count : null;
const pluralKey =
count === null ? null : (`${String(key)}_${count === 1 ? 'one' : 'other'}` as CliMessageKey);
const template =
(pluralKey ? (cliResources[language][pluralKey] ?? cliResources.en[pluralKey]) : undefined) ??
cliResources[language][key] ??
cliResources.en[key] ??
key;
return template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_match, name: string) => {
const value = vars[name];
return value === undefined || value === null ? '' : String(value);
});
}

View file

@ -0,0 +1,7 @@
import { en } from './en.js';
import { zhCN } from './zh-CN.js';
export const cliResources = {
en,
'zh-CN': zhCN,
} as const;

View file

@ -0,0 +1,228 @@
import { en } from './en.js';
type EnglishMessages = Record<keyof typeof en, string>;
export const zhCN = {
'common.notIndexed': '未找到已索引的仓库。',
'common.runAnalyze': '请在 git 仓库中运行 `gitnexus analyze` 进行索引。',
'common.runAnalyzeShort': '运行gitnexus analyze',
'common.runForceConfirm': '添加 --force 以确认删除。',
'common.path': '路径',
'common.storage': '存储',
'common.deleted': '已删除:{{target}}',
'common.error': '错误:{{message}}',
'list.title': '已索引仓库({{count}}',
'list.indexed': '索引时间',
'list.commit': '提交',
'list.stats': '统计',
'list.statsValue': '{{files}} 个文件,{{symbols}} 个符号,{{edges}} 条边',
'list.clusters': '聚类',
'list.processes': '流程',
'list.unknown': 'unknown',
'status.notGitRepo': '当前目录不是 git 仓库。',
'status.staleKuzu': '仓库包含旧版本遗留的 KuzuDB 索引。',
'status.rebuildLadybug': '运行gitnexus analyze (使用 LadybugDB 重建索引)',
'status.repoNotIndexed': '仓库尚未索引。',
'status.repository': '仓库',
'status.indexed': '索引时间',
'status.indexedCommit': '索引提交',
'status.currentCommit': '当前提交',
'status.status': '状态',
'status.upToDate': '✅ 已是最新',
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze',
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
'clean.deletedRepo': '已删除:{{name}}{{storagePath}}',
'clean.notFoundHere': '当前目录未找到已索引仓库。',
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
'clean.lbugSidecars.none': '未找到已隔离的 LadybugDB missing-shadow WAL sidecar。',
'clean.lbugSidecars.preview':
'将删除 {{count}} 个已隔离的 LadybugDB missing-shadow WAL sidecar',
'clean.lbugSidecars.deleted':
'已删除 {{count}} 个已隔离的 LadybugDB missing-shadow WAL sidecar。',
'remove.nothingToRemove': '无需移除:{{message}}',
'remove.deleteTarget': '将删除该仓库的 GitNexus 索引:{{name}}',
'remove.removed': '已移除:{{name}}',
'remove.failed': '移除 {{name}} 失败:{{message}}',
'tool.noIndexed': 'GitNexus未找到已索引仓库。请运行gitnexus analyze',
'tool.usage.query': '用法gitnexus query <搜索词>',
'tool.usage.context': '用法gitnexus context <符号名> [--uid <uid>] [--file <路径>]',
'tool.usage.impact': '用法gitnexus impact <符号名> [--direction upstream|downstream]',
'tool.usage.cypher': '用法gitnexus cypher <Cypher 查询>',
'tool.detectChanges.noChanges': '未检测到变更。',
'tool.detectChanges.changesSummary': '变更:{{files}} 个文件,{{symbols}} 个符号',
'tool.detectChanges.affectedProcesses': '受影响流程:{{count}}',
'tool.detectChanges.riskLevel': '风险等级:{{risk}}',
'tool.detectChanges.unknownRisk': '未知',
'tool.detectChanges.changedSymbols': '已变更符号:',
'tool.detectChanges.overflowMore': '... 以及另外 {{count}} 个',
'tool.detectChanges.affectedExecutionFlows': '受影响执行流程:',
'tool.detectChanges.steps': '{{count}} 步',
'tool.detectChanges.steps_one': '{{count}} 步',
'tool.detectChanges.steps_other': '{{count}} 步',
'tool.detectChanges.changedSteps': '已变更:{{steps}}',
'serve.walCorruption': '\nGitNexus 服务器无法启动:索引 WAL 文件已损坏。\n {{suggestion}}\n',
'serve.portInUse':
'\nGitNexus 服务器启动失败:\n {{message}}\n\n 端口 {{port}} 已被占用。可选择:\n 1. 停止占用端口 {{port}} 的其他进程\n 2. 使用其他端口gitnexus serve --port 4748\n',
'serve.startFailed': '\nGitNexus 服务器启动失败:\n {{message}}\n',
'doctor.title': 'GitNexus 诊断',
'doctor.runtime': '运行时',
'doctor.capabilities': '能力',
'doctor.embeddings': '嵌入',
'doctor.labels.os': '系统:',
'doctor.labels.node': 'Node',
'doctor.labels.gitnexus': 'GitNexus',
'doctor.labels.ladybugdb': 'LadybugDB',
'doctor.labels.onnx': 'ONNX',
'doctor.labels.graphStore': '图存储:',
'doctor.labels.fullTextSearch': '全文搜索:',
'doctor.labels.vectorIndex': '向量索引:',
'doctor.labels.semanticMode': '语义模式:',
'doctor.labels.exactScanLimit': '精确扫描上限:',
'doctor.labels.note': '说明:',
'doctor.labels.backend': '后端:',
'doctor.labels.device': '设备:',
'doctor.labels.threads': '线程:',
'doctor.labels.batch': '批次:',
'doctor.labels.subBatch': '子批次:',
'doctor.nodes': '{{count}} 个节点',
'doctor.nodes_one': '{{count}} 个节点',
'doctor.nodes_other': '{{count}} 个节点',
'doctor.chunks': '{{count}} 个分块',
'doctor.chunks_one': '{{count}} 个分块',
'doctor.chunks_other': '{{count}} 个分块',
'help.title.usage': '用法:',
'help.title.arguments': '参数:',
'help.title.options': '选项:',
'help.title.globalOptions': '全局选项:',
'help.title.commands': '命令:',
'help.optionMeta.choices': '可选值',
'help.optionMeta.default': '默认',
'help.optionMeta.preset': '预设',
'help.optionMeta.env': '环境变量',
'help.description.root': 'GitNexus 本地 CLI 和 MCP 服务器',
'help.command.help.description': '显示命令帮助',
'help.option.help': '显示命令帮助',
'help.option.version': '输出版本号',
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
'help.command.mcp.description': '启动 MCP 服务器stdio— 提供所有已索引仓库',
'help.command.list.description': '列出所有已索引仓库',
'help.command.status.description': '显示当前仓库的索引状态',
'help.command.doctor.description': '显示运行平台能力和嵌入配置',
'help.command.clean.description': '删除当前仓库的 GitNexus 索引',
'help.command.remove.description':
'删除已注册仓库的 GitNexus 索引(按别名、名称或绝对路径)。与 `clean` 不同,不要求位于仓库内;未知目标会幂等处理。',
'help.command.wiki.description': '从知识图谱生成仓库 Wiki',
'help.command.augment.description': '使用知识图谱上下文增强搜索模式(供 hooks 使用)',
'help.command.publish.description':
'通知 understand-quickly 注册表该仓库已有新的 GitNexus 索引。需显式启用:要求 UNDERSTAND_QUICKLY_TOKEN对 looptech-ai/understand-quickly 具备 `Repository dispatches: write` 的细粒度 PAT。无 token 时不执行。参见 https://github.com/looptech-ai/understand-quickly。',
'help.command.query.description': '搜索知识图谱中与概念相关的执行流程',
'help.command.context.description': '查看代码符号的 360 度视图:调用者、被调用者、流程',
'help.command.impact.description': '影响面分析:修改符号会影响什么',
'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用',
'help.command.group.description': '管理仓库组,用于跨索引影响分析',
'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组',
'help.command.group.add.description':
'向仓库组添加仓库。<groupPath> = 层级路径(如 hr/hiring/backend<registryName> = 注册表中的名称',
'help.command.group.remove.description': '从仓库组移除仓库',
'help.command.group.list.description': '列出所有仓库组或查看某个仓库组详情',
'help.command.group.status.description': '检查仓库组和仓库是否过期',
'help.command.group.sync.description': '同步 Contract Registry — 提取契约并构建跨仓库链接',
'help.command.group.impact.description': '分析仓库组中某个成员仓库符号的跨仓库影响',
'help.command.group.query.description': '跨仓库组所有仓库搜索执行流程',
'help.command.group.contracts.description': '查看 Contract Registry',
'help.option.analyze.force': '即使已是最新也强制完整重建索引',
'help.option.analyze.repairFts': '修复/重建搜索 FTS 索引,不执行完整重新分析',
'help.option.analyze.embeddings':
'启用语义搜索的嵌入生成(默认关闭)。可选 [limit] 覆盖 50,000 节点安全上限;传 0 可完全禁用上限。',
'help.option.analyze.dropEmbeddings':
'重建时删除现有嵌入。默认情况下,未传 `--embeddings` 的 `analyze` 会保留索引中已有嵌入。',
'help.option.analyze.skills':
'根据检测到的社区生成仓库专属 skill 文件(同时设置 --index-only 时无效)。',
'help.option.analyze.skipAgentsMd': '跳过更新 AGENTS.md 和 CLAUDE.md 中的 gitnexus 区块',
'help.option.analyze.noStats': '从 AGENTS.md 和 CLAUDE.md 中省略易变的文件/符号计数',
'help.option.analyze.skipSkills':
'跳过安装 .claude/skills/gitnexus/ 下的标准 GitNexus skill 文件。不抑制 --skills 生成的社区 skill位于 .claude/skills/generated/)。使用 --index-only 可跳过所有 AI 上下文文件注入。',
'help.option.analyze.indexOnly': '纯索引模式跳过所有文件注入AGENTS.md、CLAUDE.md、skills',
'help.option.skipGit': '将提供的路径/cwd 视为索引根目录,并跳过向上查找 git 根目录',
'help.option.analyze.name':
'在 ~/.gitnexus/registry.json 中使用自定义名称注册该仓库(用于区分路径 basename 相同的仓库,例如两个不同的 .../app 目录)',
'help.option.analyze.allowDuplicateName':
'即使已有其他路径使用相同 --name 别名,也注册该仓库。会使两个路径的 `-r <name>` 产生歧义;请用 -r <path> 消除歧义。',
'help.option.verbose': '启用详细输出',
'help.option.analyze.maxFileSize':
'跳过大于该值的文件KB。默认512。硬上限32768tree-sitter 限制)。',
'help.option.analyze.workerTimeout': 'Worker 子批次空闲超时,超时后重试/回退。默认30。',
'help.option.analyze.walCheckpointThreshold':
'analyze 期间 LadybugDB WAL 自动 checkpoint 阈值(字节,整数 >= -1默认67108864 = 64 MiB-1 保持 Ladybug 默认约 16 MiB。',
'help.option.analyze.workers':
'解析 worker 池大小。默认cores-1最多 16。传 0 禁用 worker顺序执行。',
'help.option.analyze.embeddingThreads': '限制本地 ONNX 嵌入 CPU 线程数',
'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数',
'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数',
'help.option.analyze.embeddingDevice': '嵌入设备auto、cpu、dml、cuda 或 wasm',
'help.option.index.force': '即使缺少 meta.json 也注册(统计为空)',
'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹',
'help.option.port': '端口号',
'help.option.serve.host': '绑定地址默认127.0.0.1;远程访问可用 0.0.0.0',
'help.option.force.confirmation': '跳过确认提示',
'help.option.clean.all': '清理所有已索引仓库',
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider': 'LLM 提供商openai 或 cursor默认openai',
'help.option.wiki.model': 'LLM 模型或 Azure deployment 名称默认minimax/minimax-m2.5',
'help.option.wiki.baseUrl':
'LLM API base URL。Azure v1https://{resource}.openai.azure.com/openai/v1',
'help.option.wiki.apiKey': 'LLM API key 或 Azure api-key保存到 ~/.gitnexus/config.json',
'help.option.wiki.apiVersion': 'Azure api-version 查询参数,例如 2024-10-21仅旧版 Azure API',
'help.option.wiki.reasoningModel':
'标记 deployment 为 reasoning modelo1/o3/o4-mini— 去除 temperature使用 max_completion_tokens',
'help.option.wiki.noReasoningModel': '禁用 reasoning model 模式(覆盖已保存配置)',
'help.option.wiki.concurrency': '并行 LLM 调用数默认3',
'help.option.wiki.timeout': 'LLM 请求超时时间(秒,默认:禁用)',
'help.option.wiki.retries': '每个请求的最大 LLM 重试次数默认3',
'help.option.wiki.gist': '生成后发布 Wiki 为公开 GitHub Gist',
'help.option.wiki.review': '分组后停止,以便在生成页面前审查模块结构',
'help.option.wiki.lang': '生成文档的输出语言(如 english、chinese、spanish、japanese',
'help.option.publish.id': '覆盖注册表 id默认使用 origin remote',
'help.option.repo.targetOmitOne': '目标仓库(仅有一个已索引仓库时可省略)',
'help.option.query.context': '用于提升排序质量的任务上下文',
'help.option.query.goal': '你想查找的目标',
'help.option.query.limit': '最多返回的流程数默认5',
'help.option.content': '包含完整符号源码',
'help.option.repo.target': '目标仓库',
'help.option.context.uid': '直接符号 UID零歧义查找',
'help.option.context.file': '用于消除常见名称歧义的文件路径',
'help.option.impact.direction': 'upstream依赖它的项或 downstream它依赖的项',
'help.option.impact.depth': '最大关系遍历深度默认3',
'help.option.impact.includeTests': '在结果中包含测试文件',
'help.option.detectChanges.scope': '分析范围unstaged、staged、all 或 compare',
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main',
'help.option.evalServer.host': '绑定地址默认127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭0 = 禁用)',
'help.option.group.create.force': '覆盖现有仓库组',
'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25不使用嵌入回退',
'help.option.group.sync.exactOnly': '仅精确匹配',
'help.option.group.sync.allowStale': '跳过过期索引警告',
'help.option.group.sync.verbose': '显示每条跨仓库链接详情',
'help.option.json': 'JSON 输出',
'help.option.group.impact.target': '要分析的符号或文件名',
'help.option.group.impact.repo': 'group.yaml 中的成员路径(如 app/backend不是已索引仓库名称',
'help.option.group.impact.service': '可选的 monorepo 服务目录前缀(路径过滤器)',
'help.option.group.impact.subgroup': '可选前缀,用于限制参与跨仓库扇出的仓库组成员',
'help.option.group.impact.crossDepth': '跨仓库跳转深度',
'help.option.group.impact.minConfidence': '最小关系置信度01',
'help.option.group.impact.timeoutMs': 'Phase-1 本地影响分析墙钟时间(毫秒)',
'help.option.group.query.subgroup': '限制搜索范围',
'help.option.group.query.limit': '最大合并结果数',
'help.option.group.contracts.type': '按契约类型过滤',
'help.option.group.contracts.repo': '按仓库过滤',
'help.option.group.contracts.unmatched': '仅显示未匹配契约',
'help.analyze.environment':
'\n环境变量\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值KB。默认 512最大 32768。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB-1 保持 Ladybug 默认约 16 MiB。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n\n当参数和对应环境变量同时提供时参数优先。\n\n提示`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771。',
} satisfies EnglishMessages;

View file

@ -7,6 +7,8 @@ import { Command } from 'commander';
import { createRequire } from 'node:module';
import { createLazyAction } from './lazy-action.js';
import { registerGroupCommands } from './group.js';
import { localizeCliHelp } from './help-i18n.js';
import { t } from './i18n/index.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
@ -84,25 +86,7 @@ program
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')
.option('--embedding-device <device>', 'Embedding device: auto, cpu, dml, cuda, or wasm')
.addHelpText(
'after',
'\nEnvironment variables:\n' +
' GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n' +
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
' GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n' +
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
' GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n' +
' GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n' +
' GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n' +
' GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n' +
' GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n' +
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
'\nFlags override the corresponding env vars when both are provided.\n' +
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
' `!__tests__/` to index a directory that is auto-filtered by default (#771).',
)
.addHelpText('after', () => t('help.analyze.environment'))
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
program
@ -146,6 +130,7 @@ program
.description('Delete GitNexus index for current repo')
.option('-f, --force', 'Skip confirmation prompt')
.option('--all', 'Clean all indexed repos')
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
@ -266,5 +251,6 @@ program
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
registerGroupCommands(program);
localizeCliHelp(program);
program.parse(process.argv);

View file

@ -5,17 +5,18 @@
*/
import { listRegisteredRepos } from '../storage/repo-manager.js';
import { t } from './i18n/index.js';
export const listCommand = async () => {
const entries = await listRegisteredRepos({ validate: true });
if (entries.length === 0) {
console.log('No indexed repositories found.');
console.log('Run `gitnexus analyze` in a git repo to index it.');
console.log(t('common.notIndexed'));
console.log(t('common.runAnalyze'));
return;
}
console.log(`\n Indexed Repositories (${entries.length})\n`);
console.log(`\n ${t('list.title', { count: entries.length })}\n`);
// Count occurrences of each name so colliding entries can be
// disambiguated in the header (#829). Unique-name entries render
@ -29,19 +30,23 @@ export const listCommand = async () => {
for (const entry of entries) {
const indexedDate = new Date(entry.indexedAt).toLocaleString();
const stats = entry.stats || {};
const commitShort = entry.lastCommit?.slice(0, 7) || 'unknown';
const commitShort = entry.lastCommit?.slice(0, 7) || t('list.unknown');
const hasCollision = (nameCounts.get(entry.name.toLowerCase()) ?? 0) > 1;
const header = hasCollision ? `${entry.name} (${entry.path})` : entry.name;
console.log(` ${header}`);
console.log(` Path: ${entry.path}`);
console.log(` Indexed: ${indexedDate}`);
console.log(` Commit: ${commitShort}`);
console.log(` ${t('common.path')}: ${entry.path}`);
console.log(` ${t('list.indexed')}: ${indexedDate}`);
console.log(` ${t('list.commit')}: ${commitShort}`);
console.log(
` Stats: ${stats.files ?? 0} files, ${stats.nodes ?? 0} symbols, ${stats.edges ?? 0} edges`,
` ${t('list.stats')}: ${t('list.statsValue', {
files: stats.files ?? 0,
symbols: stats.nodes ?? 0,
edges: stats.edges ?? 0,
})}`,
);
if (stats.communities) console.log(` Clusters: ${stats.communities}`);
if (stats.processes) console.log(` Processes: ${stats.processes}`);
if (stats.communities) console.log(` ${t('list.clusters')}: ${stats.communities}`);
if (stats.processes) console.log(` ${t('list.processes')}: ${stats.processes}`);
console.log('');
}
};

View file

@ -29,6 +29,7 @@
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import { cliError } from './cli-message.js';
import { t } from './i18n/index.js';
import {
readRegistry,
resolveRegistryEntry,
@ -53,14 +54,14 @@ export const removeCommand = async (target: string, options?: { force?: boolean
// Idempotent: missing target is a no-op warning, not an error.
// The `availableNames` hint comes from the error itself so users
// can see what they might have meant.
logger.warn(`Nothing to remove: ${err.message}`);
logger.warn(t('remove.nothingToRemove', { message: err.message }));
return;
}
if (err instanceof RegistryAmbiguousTargetError) {
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
// refuse to guess which one the user meant — surface the full list
// and exit non-zero so scripts don't silently pick the wrong repo.
cliError(`Error: ${err.message}`);
cliError(t('common.error', { message: err.message }));
process.exit(1);
}
throw err;
@ -69,10 +70,10 @@ export const removeCommand = async (target: string, options?: { force?: boolean
// Confirmation gate — same shape as `clean`. Default is a dry-run
// that describes what would be deleted; `--force` actually deletes.
if (!options?.force) {
console.log(`This will delete the GitNexus index for: ${entry.name}`);
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
console.log('\nRun with --force to confirm deletion.');
console.log(t('remove.deleteTarget', { name: entry.name }));
console.log(` ${t('common.path')}: ${entry.path}`);
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
@ -88,7 +89,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
cliError(`Error: ${err.message}`);
cliError(t('common.error', { message: err.message }));
process.exit(1);
}
throw err;
@ -102,12 +103,12 @@ export const removeCommand = async (target: string, options?: { force?: boolean
try {
await fs.rm(entry.storagePath, { recursive: true, force: true });
await unregisterRepo(entry.path);
console.log(`Removed: ${entry.name}`);
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
console.log(t('remove.removed', { name: entry.name }));
console.log(` ${t('common.path')}: ${entry.path}`);
console.log(` ${t('common.storage')}: ${entry.storagePath}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cliError(`Failed to remove ${entry.name}: ${msg}`, { err });
cliError(t('remove.failed', { name: entry.name, message: msg }), { err });
process.exit(1);
}
};

View file

@ -1,6 +1,6 @@
import { createServer } from '../server/api.js';
import { logger, flushLoggerSync } from '../core/logger.js';
import { cliError } from './cli-message.js';
import { cliErrorKey } from './cli-message.js';
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
// Catch anything that would cause a silent exit. Pino v10's default
@ -36,26 +36,23 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
await createServer(port, host);
} catch (err: any) {
if (isWalCorruptionError(err)) {
cliError(
`\nGitNexus server could not start: the index has a corrupted WAL file.\n` +
` ${WAL_RECOVERY_SUGGESTION}\n`,
cliErrorKey(
'serve.walCorruption',
{ suggestion: WAL_RECOVERY_SUGGESTION },
{ recoveryHint: 'wal-corruption' },
);
} else if (err.code === 'EADDRINUSE') {
cliError(
`\nFailed to start GitNexus server:\n` +
` ${err.message || err}\n\n` +
` Port ${port} is already in use. Either:\n` +
` 1. Stop the other process using port ${port}\n` +
` 2. Use a different port: gitnexus serve --port 4748\n`,
cliErrorKey(
'serve.portInUse',
{ message: String(err.message || err), port },
{ code: err.code, port, host },
);
} else {
cliError(`\nFailed to start GitNexus server:\n ${err.message || err}\n`, {
code: err.code,
port,
host,
});
cliErrorKey(
'serve.startFailed',
{ message: String(err.message || err) },
{ code: err.code, port, host },
);
}
if (err.stack && process.env.DEBUG) {
logger.debug({ stack: err.stack }, 'serve start error stack');

View file

@ -6,12 +6,13 @@
import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
import { t } from './i18n/index.js';
export const statusCommand = async () => {
const cwd = process.cwd();
if (!isGitRepo(cwd)) {
console.log('Not a git repository.');
console.log(t('status.notGitRepo'));
return;
}
@ -21,11 +22,11 @@ export const statusCommand = async () => {
const repoRoot = getGitRoot(cwd) ?? cwd;
const { storagePath } = getStoragePaths(repoRoot);
if (await hasKuzuIndex(storagePath)) {
console.log('Repository has a stale KuzuDB index from a previous version.');
console.log('Run: gitnexus analyze (rebuilds the index with LadybugDB)');
console.log(t('status.staleKuzu'));
console.log(t('status.rebuildLadybug'));
} else {
console.log('Repository not indexed.');
console.log('Run: gitnexus analyze');
console.log(t('status.repoNotIndexed'));
console.log(t('common.runAnalyzeShort'));
}
return;
}
@ -33,9 +34,9 @@ export const statusCommand = async () => {
const currentCommit = getCurrentCommit(repo.repoPath);
const isUpToDate = currentCommit === repo.meta.lastCommit;
console.log(`Repository: ${repo.repoPath}`);
console.log(`Indexed: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
console.log(`Indexed commit: ${repo.meta.lastCommit?.slice(0, 7)}`);
console.log(`Current commit: ${currentCommit?.slice(0, 7)}`);
console.log(`Status: ${isUpToDate ? '✅ up-to-date' : '⚠️ stale (re-run gitnexus analyze)'}`);
console.log(`${t('status.repository')}: ${repo.repoPath}`);
console.log(`${t('status.indexed')}: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
console.log(`${t('status.indexedCommit')}: ${repo.meta.lastCommit?.slice(0, 7)}`);
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
};

View file

@ -17,7 +17,8 @@
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { cliError } from './cli-message.js';
import { cliErrorKey } from './cli-message.js';
import { formatDetectChangesResult } from './detect-changes-format.js';
let _backend: LocalBackend | null = null;
@ -26,7 +27,7 @@ async function getBackend(): Promise<LocalBackend> {
_backend = new LocalBackend();
const ok = await _backend.init();
if (!ok) {
cliError('GitNexus: No indexed repositories found. Run: gitnexus analyze');
cliErrorKey('tool.noIndexed');
process.exit(1);
}
return _backend;
@ -68,7 +69,7 @@ export async function queryCommand(
},
): Promise<void> {
if (!queryText?.trim()) {
cliError('Usage: gitnexus query <search_query>');
cliErrorKey('tool.usage.query');
process.exit(1);
}
@ -94,7 +95,7 @@ export async function contextCommand(
},
): Promise<void> {
if (!name?.trim() && !options?.uid) {
cliError('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
cliErrorKey('tool.usage.context');
process.exit(1);
}
@ -119,7 +120,7 @@ export async function impactCommand(
},
): Promise<void> {
if (!target?.trim()) {
cliError('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
cliErrorKey('tool.usage.impact');
process.exit(1);
}
@ -154,7 +155,7 @@ export async function cypherCommand(
},
): Promise<void> {
if (!query?.trim()) {
cliError('Usage: gitnexus cypher <cypher_query>');
cliErrorKey('tool.usage.cypher');
process.exit(1);
}
@ -166,44 +167,6 @@ export async function cypherCommand(
output(result);
}
function formatDetectChangesResult(result: any): string {
if (result?.error) return `Error: ${result.error}`;
const summary = result?.summary || {};
if ((summary.changed_count || 0) === 0) {
return 'No changes detected.';
}
const lines: string[] = [];
lines.push(`Changes: ${summary.changed_files || 0} files, ${summary.changed_count || 0} symbols`);
lines.push(`Affected processes: ${summary.affected_count || 0}`);
lines.push(`Risk level: ${summary.risk_level || 'unknown'}`);
lines.push('');
const changed = result?.changed_symbols || [];
if (changed.length > 0) {
lines.push('Changed symbols:');
for (const symbol of changed.slice(0, 15)) {
lines.push(` ${symbol.type} ${symbol.name}${symbol.filePath}`);
}
if (changed.length > 15) {
lines.push(` ... and ${changed.length - 15} more`);
}
lines.push('');
}
const affected = result?.affected_processes || [];
if (affected.length > 0) {
lines.push('Affected execution flows:');
for (const processInfo of affected.slice(0, 10)) {
const steps = (processInfo.changed_steps || []).map((s: any) => s.symbol).join(', ');
lines.push(`${processInfo.name} (${processInfo.step_count} steps) — changed: ${steps}`);
}
}
return lines.join('\n').trim();
}
export async function detectChangesCommand(options?: {
scope?: string;
baseRef?: string;

View file

@ -1,4 +1,4 @@
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
import { makeScopeId, type Capture, type CaptureMatch, type Range } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
@ -13,12 +13,13 @@ import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
import { normalizeKotlinType } from './interpret.js';
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
import { markCompanionScope } from './companion-scopes.js';
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
export function emitKotlinScopeCaptures(
sourceText: string,
_filePath: string,
filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<ReturnType<typeof getKotlinParser>['parse']> | undefined;
@ -36,6 +37,7 @@ export function emitKotlinScopeCaptures(
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
out.push(...synthesizeKotlinSmartCastBindings(tree.rootNode));
out.push(...synthesizeKotlinLambdaBindings(tree.rootNode, returnTypes));
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
const grouped: Record<string, Capture> = {};
@ -45,6 +47,27 @@ export function emitKotlinScopeCaptures(
}
if (Object.keys(grouped).length === 0) continue;
// Companion-object marker (#1756 / U4). The `@scope.companion`
// capture is a side-channel marker — it shares its range with the
// existing `(companion_object) @scope.class` rule, so the Class
// scope already exists in the scope tree. Record the scope id into
// the per-file companion-scope set so `populateCompanionMembersOn
// EnclosingClass` (owners.ts) can identify companion scopes
// unambiguously, regardless of whether they are anonymous, named,
// or contain nested classes. The match itself is consumed here and
// NOT pushed to the output — the scope-extractor would reject the
// `companion` kind suffix anyway, but suppressing the emit keeps
// downstream pipelines from re-processing the same range twice.
if (grouped['@scope.companion'] !== undefined) {
const scopeId = makeScopeId({
filePath,
range: grouped['@scope.companion']!.range,
kind: 'Class',
});
markCompanionScope(filePath, scopeId);
continue;
}
if (grouped['@import.statement'] !== undefined) {
const importNode = findNodeAtRange(
tree.rootNode,
@ -309,6 +332,330 @@ function buildNarrowedTypeBindingCapture(
};
}
/**
* Synthesize lambda-body type-bindings issue #1757.
*
* For each `lambda_literal` we emit one or more `@type-binding.annotation`
* captures anchored INSIDE the lambda body (the lambda's `statements` child
* or the `lambda_literal` itself when no statements child exists). The
* `@scope.block` query rule (see query.ts) makes each `lambda_literal` a
* Block scope, and the `@type-binding.lambda-scoped` marker forces the
* scope-extractor to keep the binding at the innermost (lambda body) scope
* via `kotlinBindingScopeFor`. This guarantees:
* - explicit parameter names (`{ user -> ... }`) bind only inside the
* body, NOT in the enclosing function scope;
* - implicit `it` is visible only inside the lambda body and shadows
* any same-named outer binding (`val it = "outer"; users.forEach
* { it.save() }` — inner `it` is the lambda parameter);
* - nested lambdas shadow deterministically (innermost lambda's `it`
* wins; outer lambda's parameters are still visible by their own
* names through the parent scope chain).
*
* Receiver-type inference is best-effort: the lambda's call-expression
* parent is inspected; if the receiver has a known local-variable type
* and the call's member is a well-known stdlib idiom (`forEach`/`map`/
* `filter` element type of the collection; `let`/`apply`/`also`/`run`/
* `takeIf`/`takeUnless`/`use` receiver type itself), the inferred type
* is attached. When inference fails (chained receivers, unknown member,
* non-stdlib idiom), we still emit the binding with a sentinel/erased
* type so the binding's scope semantics (no leak; no `it` cross-fire) are
* enforced call-resolution from the body still falls through to free-
* call fallback, which is the correct behavior when the type is unknown.
*
* Standard-library coverage: `forEach`, `map`, `filter`, `flatMap`,
* `mapNotNull`, `filterNotNull`, `onEach`, `find`, `firstOrNull`,
* `lastOrNull`, `any`, `all`, `none`, `count`, `forEachIndexed`,
* `let`, `apply`, `also`, `run`, `takeIf`, `takeUnless`, `use`, `with`.
*
* Lambda-receiver typing for non-stdlib higher-order functions is a
* follow-up; the binding-existence guarantee above is the minimum
* acceptance criterion per the U9 plan.
*/
function synthesizeKotlinLambdaBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
const classMembers = collectKotlinClassMembers(rootNode);
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
for (const lambdaNode of descendantsOfType(fnNode, 'lambda_literal')) {
const anchor = lambdaBodyAnchor(lambdaNode);
if (anchor === null) continue;
const inferredType = inferKotlinLambdaReceiverType(
lambdaNode,
localTypes,
returnTypes,
classMembers,
);
const params = explicitLambdaParameters(lambdaNode);
if (params.length === 0) {
// No explicit `(x ->)` parameter list — implicit `it` is in
// scope inside the body. Synthesize the `it` type-binding so
// calls like `it.save()` resolve through the typeBinding chain.
const typeNode = inferredType?.typeNode ?? lambdaNode;
const typeText = inferredType?.typeText ?? '';
out.push(buildLambdaTypeBindingCapture(anchor, 'it', typeNode, typeText));
} else {
// Explicit parameters: `{ user -> ... }`, `{ (a, b) -> ... }`,
// `{ key, value -> ... }`. Emit one binding per parameter.
// For multi-arg lambdas (destructuring, `forEachIndexed { i, x
// -> ... }`), the per-arg type inference is finer than what we
// currently support — we bind the FIRST parameter to the
// inferred receiver type (matches single-arg idioms) and bind
// additional parameters with an empty/erased type, which still
// gates leakage but won't drive call resolution for those names.
for (let i = 0; i < params.length; i++) {
const paramName = params[i]!.text;
const typeNode = i === 0 ? (inferredType?.typeNode ?? params[i]!) : params[i]!;
const typeText = i === 0 ? (inferredType?.typeText ?? '') : '';
out.push(buildLambdaTypeBindingCapture(anchor, paramName, typeNode, typeText));
}
}
}
}
return out;
}
/** Anchor node used for synthesized lambda-body type-bindings.
* Prefers the `statements` child of `lambda_literal` (always strictly
* inside the lambda body, so the scope-extractor's `rangesEqual` auto-
* hoist check fails the binding stays in the Block scope). Falls
* back to the lambda_literal itself when no statements child exists
* (e.g. empty lambda); the `@type-binding.lambda-scoped` marker in
* `kotlinBindingScopeFor` then forces no-hoist explicitly. */
function lambdaBodyAnchor(lambdaNode: SyntaxNode): SyntaxNode | null {
const statements = lambdaNode.namedChildren.find((c) => c.type === 'statements');
return statements ?? lambdaNode;
}
/** Extract explicit lambda parameter `simple_identifier` nodes from a
* `lambda_literal`. Returns an empty array when no `lambda_parameters`
* is present (implicit `it` form). */
function explicitLambdaParameters(lambdaNode: SyntaxNode): SyntaxNode[] {
const params = lambdaNode.namedChildren.find((c) => c.type === 'lambda_parameters');
if (params === undefined) return [];
const out: SyntaxNode[] = [];
for (const child of params.namedChildren) {
if (child.type !== 'variable_declaration') continue;
const ident = child.namedChildren.find((c) => c.type === 'simple_identifier');
if (ident !== undefined) out.push(ident);
}
return out;
}
function buildLambdaTypeBindingCapture(
anchor: SyntaxNode,
name: string,
typeNode: SyntaxNode,
typeText: string,
): CaptureMatch {
return {
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
'@type-binding.name': syntheticCapture('@type-binding.name', anchor, name),
'@type-binding.type': syntheticCapture(
'@type-binding.type',
typeNode,
typeText === '' ? '' : normalizeKotlinType(typeText),
),
// Marker consumed by `kotlinBindingScopeFor` (simple-hooks.ts) to
// pin this binding inside the lambda Block scope — without it the
// scope-extractor would auto-hoist the binding to the enclosing
// function scope and `it` (or the lambda parameter name) would
// leak past the closing brace.
'@type-binding.lambda-scoped': syntheticCapture('@type-binding.lambda-scoped', anchor, '1'),
};
}
/** Stdlib higher-order functions whose lambda parameter receives the
* ELEMENT type of the receiver collection (Map / Iterable element). */
const KOTLIN_ELEMENT_TYPE_LAMBDAS = new Set([
'forEach',
'forEachIndexed',
'map',
'mapNotNull',
'mapIndexed',
'filter',
'filterNot',
'filterNotNull',
'filterIsInstance',
'flatMap',
'flatten',
'onEach',
'find',
'findLast',
'firstOrNull',
'lastOrNull',
'singleOrNull',
'any',
'all',
'none',
'count',
'partition',
'sortedBy',
'sortedByDescending',
'groupBy',
'associate',
'associateBy',
'associateWith',
'minByOrNull',
'maxByOrNull',
'sumOf',
'distinctBy',
]);
/** Stdlib scope functions whose lambda receives the RECEIVER itself as
* `it` (or as `this` for `apply`/`run`/`with`). For the binding-
* existence guarantee we treat both forms the same way `it` binds
* to the receiver type; `apply`/`run`/`with` callers see free calls
* inside the body which fall through to free-call resolution against
* the enclosing scope (no `this`-aware dispatch yet follow-up). */
const KOTLIN_SCOPE_FUNCTION_LAMBDAS = new Set(['let', 'also', 'takeIf', 'takeUnless', 'use']);
/** `apply`, `run`, `with` expose the receiver as `this` rather than
* `it`. We still synthesize an `it` binding because the lambda may
* reference the receiver elsewhere but the more common usage
* (`user.apply { save() }`) goes through free-call resolution on the
* body, not through `it`. Including these here keeps the binding
* scope correct without claiming we resolve `this`-form correctly. */
const KOTLIN_THIS_RECEIVER_LAMBDAS = new Set(['apply', 'run', 'with']);
/** Walk up from `lambdaNode` to the enclosing `call_expression` and
* infer the lambda parameter's type from the call's receiver and
* member name. Returns null when the inference path is not yet
* supported (chained receivers, unknown member, non-stdlib idiom).
*
* Best-effort: a null return is harmless `synthesizeKotlinLambda
* Bindings` still emits the binding with an empty type so the scope
* semantics (no leak, no cross-fire) are enforced; only the call-
* resolution path from `it.method()` may fall through to free-call
* fallback when the type isn't known. */
function inferKotlinLambdaReceiverType(
lambdaNode: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers,
): { typeText: string; typeNode: SyntaxNode } | null {
const callExpr = findEnclosingCallExpression(lambdaNode);
if (callExpr === null) return null;
const callee = callExpr.namedChildren.find(
(c) => c.type === 'navigation_expression' || c.type === 'simple_identifier',
);
if (callee === undefined) return null;
if (callee.type === 'simple_identifier') {
// `with(receiver) { ... }` — argument is the receiver. Not yet
// wired through; defer to follow-up.
return null;
}
// navigation_expression: <receiver>.<member>
const receiver = callee.namedChild(0);
const memberName = callee.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (receiver === null || memberName === undefined) return null;
const receiverType = inferKotlinLambdaReceiverExpressionType(
receiver,
localTypes,
returnTypes,
classMembers,
);
if (receiverType === null) return null;
if (KOTLIN_ELEMENT_TYPE_LAMBDAS.has(memberName)) {
const element = kotlinContainerElementType(receiverType, 'values');
if (element === null || element === '') return null;
return { typeText: element, typeNode: lambdaNode };
}
if (
KOTLIN_SCOPE_FUNCTION_LAMBDAS.has(memberName) ||
KOTLIN_THIS_RECEIVER_LAMBDAS.has(memberName)
) {
// Strip nullable suffix for `?.let { ... }` semantics — inside the
// body, the receiver is non-null per Kotlin smart-cast.
const stripped = normalizeKotlinType(receiverType);
return { typeText: stripped, typeNode: lambdaNode };
}
return null;
}
/** Infer the static type of the expression that produced the lambda's
* enclosing call. Supports: `simple_identifier` (lookup in
* `localTypes`), `indexing_expression` on a Map-typed receiver, and
* `call_expression` whose callee return type is in `returnTypes`. */
function inferKotlinLambdaReceiverExpressionType(
receiver: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
returnTypes: ReadonlyMap<string, string>,
classMembers: KotlinClassMembers,
): string | null {
if (receiver.type === 'simple_identifier') {
return localTypes.get(receiver.text) ?? null;
}
if (receiver.type === 'indexing_expression') {
// `posts[user]` — the underlying receiver's container type tells
// us the element/value type.
const base = receiver.namedChild(0);
if (base === null) return null;
const baseType = base.type === 'simple_identifier' ? localTypes.get(base.text) : null;
if (baseType === undefined || baseType === null) return null;
// Indexing a Map returns the value type; indexing a List returns
// the element type. `kotlinContainerElementType` already encodes
// both via the 'values' tag.
return kotlinContainerElementType(baseType, 'values');
}
if (receiver.type === 'navigation_expression') {
// `users.map { ... }` chain — receiver is itself a navigation/
// call. Tier-2 chain inference: try the navigation field/method.
const navField = inferKotlinNavigationFieldType(receiver, localTypes, classMembers);
if (navField !== null) return navField;
const callee = receiver.namedChildren
.find((c) => c.type === 'navigation_suffix')
?.namedChildren.find((c) => c.type === 'simple_identifier');
if (callee !== undefined) {
return inferKotlinNavigationCallReturnType(receiver, localTypes, classMembers);
}
return null;
}
if (receiver.type === 'call_expression') {
const callee = receiver.namedChildren.find((c) => c.type === 'simple_identifier');
if (callee === undefined) return null;
return returnTypes.get(callee.text) ?? null;
}
return null;
}
/** Walk up from `lambdaNode` (lambda_literal) to the enclosing call:
* `lambda_literal → annotated_lambda → call_suffix → call_expression`
* for trailing lambdas, or `lambda_literal → value_argument →
* value_arguments call_suffix call_expression` for paren form.
* Returns null if the lambda is not inside a call. */
function findEnclosingCallExpression(lambdaNode: SyntaxNode): SyntaxNode | null {
let current: SyntaxNode | null = lambdaNode.parent;
while (current !== null) {
if (current.type === 'call_expression') return current;
// Don't cross out of the immediate call boundary — if we hit a
// function_body or function_declaration ancestor, the lambda is
// not call-bound.
if (current.type === 'function_body' || current.type === 'function_declaration') {
return null;
}
current = current.parent;
}
return null;
}
function synthesizeKotlinLocalAssignmentBindings(
rootNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@ -393,15 +740,21 @@ function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
} else if (member.type === 'function_declaration') {
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const paramsIdx = member.namedChildren.findIndex(
(c) => c.type === 'function_value_parameters',
);
const rtype =
paramsIdx < 0
? undefined
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
collectKotlinFunctionReturn(member, mmap);
} else if (member.type === 'companion_object') {
// Companion-object methods (`companion object { fun create() … }`)
// are addressable via the outer class name (`Logger.create()`).
// Register them on the outer class so chain-binding for
// `val x = Logger.create(...)` picks up the return type (#1756).
// The receiver-side filtering needed to prevent
// `instance.companionMethod()` crossover is handled elsewhere.
const compBody = member.namedChildren.find((c) => c.type === 'class_body');
if (compBody !== undefined) {
for (const compMember of compBody.namedChildren) {
if (compMember.type !== 'function_declaration') continue;
collectKotlinFunctionReturn(compMember, mmap);
}
}
}
}
}
@ -412,6 +765,16 @@ function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
return { fields, methods };
}
function collectKotlinFunctionReturn(fnNode: SyntaxNode, target: Map<string, string>): void {
const mname = fnNode.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
const paramsIdx = fnNode.namedChildren.findIndex((c) => c.type === 'function_value_parameters');
const rtype =
paramsIdx < 0
? undefined
: fnNode.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
if (mname !== undefined && rtype !== undefined) target.set(mname, rtype);
}
function collectKotlinLocalTypeTexts(
fnNode: SyntaxNode,
returnTypes: ReadonlyMap<string, string>,
@ -520,9 +883,19 @@ function inferKotlinNavigationFieldType(
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
}
/** Resolve `receiver.method()` method's declared return type, where
* `receiver` is a simple identifier whose type is in `localTypes` and
* `method` is declared on that type in `classMembers.methods`. */
/** Resolve `receiver.method()` method's declared return type. The
* `receiver` is a simple identifier; we try two interpretations in
* order:
*
* 1. `receiver` is a local variable whose type is in `localTypes`
* look up `method` on that type's class members.
* 2. `receiver` is itself a class name (e.g. `Logger.create("app")`,
* a companion-object call via the class) look up `method` on
* `classMembers.methods.get(receiver.text)` directly.
*
* Tier 2 supports `val logger = Logger.create(...)` patterns where the
* RHS is a companion-object factory: the loop variable's type is the
* factory's return type (#1756). */
function inferKotlinNavigationCallReturnType(
navCallee: SyntaxNode,
localTypes: ReadonlyMap<string, string>,
@ -536,8 +909,10 @@ function inferKotlinNavigationCallReturnType(
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
if (methodName === undefined) return null;
const recvType = localTypes.get(receiver.text);
if (recvType === undefined) return null;
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
if (recvType !== undefined) {
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
}
return classMembers.methods.get(receiver.text)?.get(methodName) ?? null;
}
function inferKotlinIterableElementType(

View file

@ -0,0 +1,61 @@
import type { ScopeId } from 'gitnexus-shared';
/**
* Per-file set of `ScopeId`s that came from a `companion_object` AST node
* (issue #1756 / U4 remediation).
*
* Populated during `emitKotlinScopeCaptures` from the `@scope.companion`
* marker capture (see `query.ts`) and consumed by
* `populateCompanionMembersOnEnclosingClass` in `owners.ts` to decide
* whether to promote a class scope's methods onto its enclosing class.
*
* The previous `ownedDefs.some(isClassLike)` heuristic in `owners.ts`
* silently misclassified two shapes as "regular classes":
* - named companions (`companion object Helper { ... }`) the `Helper`
* `type_identifier` registered as a class-like def on the companion
* scope, hiding the companion-ness from the heuristic; AND
* - companions containing nested classes (`companion object { class
* Token; fun create() }`) — the nested class def lived on the
* companion scope, again hiding it from the heuristic.
*
* The marker capture lifts that distinction up to the parser layer
* where it is unambiguous (any `companion_object` AST node is a
* companion, regardless of what it contains).
*
* Parallels the C-language pattern in `c/static-linkage.ts`: per-file
* `Map<filePath, Set<key>>` side-channel for language-specific def /
* scope metadata that does not belong on the shared `Scope` /
* `SymbolDefinition` types.
*
* NOTE: module-level state. `clearCompanionScopes()` is called once per
* workspace pass from `kotlinScopeResolver.loadResolutionConfig`, which
* the scope-resolution orchestrator awaits before extracting any
* `ParsedFile`s for this language (see `pipeline/phase.ts` and the
* mirror pattern in `c/scope-resolver.ts` `clearStaticNames()`). This
* keeps server-mode and multi-repo-in-one-process callers safe from
* unbounded memory growth and from stale companion-scope ids from a
* previous workspace's files. Tests that exercise the captures /
* owners modules directly may still need to call `clearCompanionScopes`
* themselves (see `test/unit/kotlin-static-marker.test.ts`).
*/
const companionScopesByFile = new Map<string, Set<ScopeId>>();
/** Record a scope id as a companion-object scope for the given file. */
export function markCompanionScope(filePath: string, scopeId: ScopeId): void {
let scopes = companionScopesByFile.get(filePath);
if (scopes === undefined) {
scopes = new Set<ScopeId>();
companionScopesByFile.set(filePath, scopes);
}
scopes.add(scopeId);
}
/** Check whether `scopeId` belongs to a companion-object scope in `filePath`. */
export function isCompanionScope(filePath: string, scopeId: ScopeId): boolean {
return companionScopesByFile.get(filePath)?.has(scopeId) ?? false;
}
/** Clear all tracked companion scopes (for testing). */
export function clearCompanionScopes(): void {
companionScopesByFile.clear();
}

View file

@ -1,5 +1,20 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import { isCompanionScope } from './companion-scopes.js';
/** Module-level identity-based marker for companion-promoted Kotlin
* method defs (the "this member can only be dispatched through the
* class name" set). Parallels the C language `static-linkage.ts`
* side-channel pattern but uses a WeakSet because the mark is
* per-def (no per-name keying needed). Eliminates the cast-and-
* mutate pattern the previous marker implementation required,
* removes any serialization-survival risk surface, and keeps the
* Kotlin-specific metadata off the shared `SymbolDefinition` type. */
const KOTLIN_STATIC_DEFS = new WeakSet<SymbolDefinition>();
export function isKotlinStaticOnly(def: SymbolDefinition): boolean {
return KOTLIN_STATIC_DEFS.has(def);
}
export function populateKotlinOwners(parsed: ParsedFile): void {
populateClassOwnedMembers(parsed);
@ -37,13 +52,47 @@ function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
if (scope.kind !== 'Function' || scope.parent === null) continue;
const parent = scopesById.get(scope.parent);
if (parent === undefined || parent.kind !== 'Class') continue;
if (parent.ownedDefs.some((def) => isClassLike(def.type))) continue;
// Identify companion-object scopes via the `@scope.companion` marker
// capture (see captures.ts / companion-scopes.ts) rather than via
// the old `parent.ownedDefs.some(isClassLike)` heuristic. The
// heuristic silently bypassed two real shapes (#1756 / U4):
// - named companions (`companion object Helper { ... }`) — `Helper`
// registered as a class-like def on the companion scope; AND
// - companions containing nested classes (`companion object {
// class Token; fun create() }`) — the nested class lived on
// the companion scope.
// Both bypasses left companion methods unpromoted and unmarked,
// breaking class-name dispatch (`Outer.create()`) and crossover
// suppression (`outer.create()`) for those shapes. The marker
// capture lifts the distinction to the parser layer where any
// `companion_object` AST node is a companion, full stop.
if (!isCompanionScope(parsed.filePath, parent.id)) continue;
const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
if (enclosing === undefined) continue;
for (const def of scope.ownedDefs) {
if (def.ownerId !== undefined) continue;
// Class-like defs nested inside the companion's methods (rare —
// would be a local class declared inside a fun-body) are not
// companion members and must not be promoted. The companion's
// direct nested classes live in their OWN scope's ownedDefs
// (NOT the function-scope ownedDefs we iterate here), so this
// guard is defense-in-depth.
if (isClassLike(def.type)) continue;
// OVERRIDE rather than skip-when-set: for named companions,
// `populateClassOwnedMembers` already set `ownerId = Helper`
// (the named-companion class-like def). That is the WRONG
// owner — companion methods are dispatched through the
// enclosing outer class, not through the companion's own
// type name. Overwriting restores the intended ownership.
(def as { ownerId?: string }).ownerId = enclosing.nodeId;
// Mark as static-only so `ScopeResolver.isStaticOnly` (see
// `isKotlinStaticOnly`) can filter these out of instance-receiver
// dispatch (#1756). Promoting the companion method onto the
// outer class lets `Foo.companionMethod()` resolve via Case 2;
// without this marker, `fooInstance.companionMethod()` would
// ALSO resolve to it via Case 4, which is incorrect (and a
// compile error in real Kotlin).
KOTLIN_STATIC_DEFS.add(def);
qualify(def, enclosing);
}
}
@ -67,7 +116,16 @@ function findEnclosingClassWithDef(
}
function qualify(def: SymbolDefinition, owner: SymbolDefinition): void {
if (def.qualifiedName === undefined || def.qualifiedName.includes('.')) return;
if (def.qualifiedName === undefined) return;
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return;
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
// For named companions, `populateClassOwnedMembers` qualified the
// def as `Helper.create`. Strip the companion-class prefix and
// re-qualify with the outer class so the graph-bridge lookup keys
// resolve to `Outer.create` rather than the spurious `Helper.create`.
// For unqualified defs (the anonymous-companion path), the simple
// name is unchanged — `populateClassOwnedMembers` found no class-like
// def in the anonymous companion scope, so the prior pass left the
// simple name in place.
const simple = def.qualifiedName.split('.').pop() ?? def.qualifiedName;
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${simple}`;
}

View file

@ -9,6 +9,20 @@ const KOTLIN_SCOPE_QUERY = `
(companion_object) @scope.class
(function_declaration) @scope.function
;; Companion-object marker (issue #1756 / U4). Side-channel capture that
;; lets populateCompanionMembersOnEnclosingClass distinguish a companion
;; Class scope from a regular Class scope without inspecting ownedDefs.
;; Anonymous companions AND companions containing nested classes both
;; look like regular classes through the old ownedDefs-based heuristic;
;; the marker lifts the distinction up to the parser layer where it is
;; unambiguous (any companion_object AST node is a companion, full
;; stop). Consumed by markCompanionScope / isCompanionScope in
;; captures.ts / companion-scopes.ts. The scope-extractor ignores the
;; "companion" suffix (no ScopeKind mapping), so this rule contributes
;; no Scope record of its own the existing (companion_object)
;; @scope.class rule still creates the Class scope.
(companion_object) @scope.companion
;; Smart-cast narrowing scopes (RFC #909 Ring 3, issue #1758).
;; Each is-test arm body and each if-then body becomes its own Block
;; scope so synthesized narrowed type-bindings (see captures.ts
@ -22,6 +36,25 @@ const KOTLIN_SCOPE_QUERY = `
(check_expression)
(control_structure_body) @scope.block)
;; Lambda body scope (issue #1757). Each lambda_literal becomes its
;; own Block scope so synthesized lambda-parameter and implicit-'it'
;; type-bindings (see captures.ts synthesizeKotlinLambdaBindings) stay
;; inside the lambda they must not leak to the enclosing function
;; scope and must shadow same-named outer bindings (val it = "outer";
;; users.forEach { it.save() } inner 'it' is the lambda's, not the
;; outer String).
;;
;; Lambdas appear inside call_suffix for trailing-lambda syntax
;; (list.forEach { it.foo() }) and inside value_arguments for
;; explicit-paren syntax (list.forEach({ x -> x.foo() })); both AST
;; positions produce the same lambda_literal subtree, so a single
;; capture suffices.
;;
;; Uses @scope.block (not @scope.function) to match the smart-cast
;; precedent (#1758) keeps narrowed/lambda bindings scope-local
;; without the auto-hoist semantics of Function scopes.
(lambda_literal) @scope.block
;; Declarations types
(class_declaration
"interface"

View file

@ -13,39 +13,65 @@ import {
resolveKotlinImportTarget,
type KotlinResolveContext,
} from './index.js';
import { clearCompanionScopes } from './companion-scopes.js';
import { isKotlinStaticOnly } from './owners.js';
/**
* Kotlin scope resolver for RFC #909 Ring 3.
*
* Kotlin is intentionally registered but not yet listed in
* `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
* the resolver can run in shadow/forced mode, while production default
* stays on the legacy DAG until the RFC flip criteria in #1746 are met.
* **Migration status:** Kotlin is in `MIGRATED_LANGUAGES`. Default
* production resolution flows through the scope-resolution pipeline;
* the legacy DAG is consulted only when the per-language env var
* (`REGISTRY_PRIMARY_KOTLIN=0`) explicitly forces the legacy parity
* run for CI comparison.
*
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 175/175 fixtures
* after the migration sub-issues #1758#1763 closed. Covers core
* import, receiver, companion, default-param, vararg, constructor,
* local assignment-chain, collection-iteration, smart casts
* (`when (x) { is T -> … }` and `if (x is T)` #1758), cross-file
* iterable return propagation (#1759), single-level method-chain
* fixpoint receiver types (#1760), parameter-type-narrowed overload
* target-id selection (#1761), virtual dispatch via constructor RHS
* (`val x: Animal = Dog()` #1762), and interface default-method
* dispatch via implements-split MRO (#1763).
* **Forced-mode parity (`REGISTRY_PRIMARY_KOTLIN=1`):** 208/208
* fixtures pass after the migration sub-issues #1758#1763, the
* companion/instance dispatch fix #1756, and the lambda scopes
* fix #1757. Covers core import, receiver, companion, default-param,
* vararg, constructor, local assignment-chain, collection-iteration,
* smart casts (`when (x) { is T -> … }` and `if (x is T)` #1758),
* cross-file iterable return propagation (#1759), single-level
* method-chain fixpoint receiver types (#1760), parameter-type-narrowed
* overload target-id selection (#1761), virtual dispatch via constructor
* RHS (`val x: Animal = Dog()` #1762), interface default-method
* dispatch via implements-split MRO (#1763), companion-object vs
* instance member dispatch (#1756) via the `isStaticOnly` hook
* (including named companions and MRO-shadow / chain-typebinding /
* value-receiver crossover cases), and lambda-body Block scopes
* with scoped type-bindings for explicit parameters and implicit
* `it` (#1757) via `synthesizeKotlinLambdaBindings` plus the
* `(lambda_literal) @scope.block` query rule.
*
* **Remaining pre-flip blockers (#1746):** #1755 (forced-mode preview
* CI workflow obviated once Kotlin lands in `MIGRATED_LANGUAGES`
* because the existing scope-parity matrix auto-discovers it), #1756
* (companion vs instance member dispatch), and #1757 (lambda scopes
* and lambda-parameter bindings). The flip PR adds
* `SupportedLanguages.Kotlin` to `MIGRATED_LANGUAGES` after the named
* blockers close.
* **Legacy parity skip list:** `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin`
* in `test/integration/resolvers/helpers.ts` records scope-resolver-only
* correctness wins that the legacy DAG cannot replicate. As of #1756 /
* #1757 there are 8 entries covering: the bare companion-vs-instance
* crossover, three MRO-shadow / standalone-chain cases, the chained-
* forEach lambda-scope case, the named-companion crossover, and the
* Case-0 / Case-3b / Case-5 companion crossovers under
* `kotlin-companion-other-cases`. Each entry is documented inline with
* its issue ref and rationale.
*/
export const kotlinScopeResolver: ScopeResolver = {
language: SupportedLanguages.Kotlin,
languageProvider: kotlinProvider,
importEdgeReason: 'kotlin-scope: import',
loadResolutionConfig: () => {
// Drop the module-level `companionScopesByFile` table from any
// prior workspace pass before this run populates it via
// `emitKotlinScopeCaptures`. Mirrors the C resolver's
// `clearStaticNames()` call in `loadResolutionConfig` — the
// orchestrator awaits this hook exactly once per workspace pass
// (see `pipeline/phase.ts`), making it the right lifecycle seam
// for clearing per-language side-channel state. Returns
// `undefined` because Kotlin has no external resolution config
// to load.
clearCompanionScopes();
return undefined;
},
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
const ws: KotlinResolveContext = { fromFile, allFilePaths };
return resolveKotlinImportTarget(
@ -64,6 +90,8 @@ export const kotlinScopeResolver: ScopeResolver = {
isSuperReceiver: (text) => text.trim() === 'super',
isStaticOnly: isKotlinStaticOnly,
fieldFallbackOnMethodLookup: false,
propagatesReturnTypesAcrossImports: true,
collapseMemberCallsByCallerTarget: false,

View file

@ -19,6 +19,17 @@ export function kotlinBindingScopeFor(
// and erase the arm-local narrowing.
if (decl['@type-binding.narrowed'] !== undefined) return innermost.id;
// Lambda-scoped bindings (issue #1757) — explicit lambda parameters
// and implicit `it` must stay inside the lambda body Block scope.
// Without this gating, the binding hoists to the enclosing function
// scope and:
// - `it` leaks past the closing brace of the lambda, shadowing the
// parameter-scope `it` (or outer `val it = "outer"`) for everything
// that follows in the function body.
// - Nested lambda parameters override each other across siblings.
// Same mechanism as the smart-cast precedent above.
if (decl['@type-binding.lambda-scoped'] !== undefined) return innermost.id;
if (decl['@type-binding.return'] === undefined) return null;
let current: Scope | undefined = innermost;

View file

@ -76,6 +76,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.CPlusPlus,
SupportedLanguages.PHP,
SupportedLanguages.JavaScript,
SupportedLanguages.Kotlin,
]);
/**

Some files were not shown because too many files have changed in this diff Show more