diff --git a/.gitignore b/.gitignore index 008e985eb..d37fa8a28 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 46960b967..7971154aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: "@"` + `service`. Removed `group_query`/`group_contracts`/`group_status` MCP tools; added `gitnexus://group/{name}/contracts` and `gitnexus://group/{name}/status` resources. | diff --git a/docs/code-indexing/cobol/README.md b/docs/code-indexing/cobol/README.md deleted file mode 100644 index c96eb4626..000000000 --- a/docs/code-indexing/cobol/README.md +++ /dev/null @@ -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 | diff --git a/docs/code-indexing/cobol/copy-expansion.md b/docs/code-indexing/cobol/copy-expansion.md deleted file mode 100644 index 7c6aaa2a3..000000000 --- a/docs/code-indexing/cobol/copy-expansion.md +++ /dev/null @@ -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 diff --git a/docs/code-indexing/cobol/deep-indexing.md b/docs/code-indexing/cobol/deep-indexing.md deleted file mode 100644 index f28376782..000000000 --- a/docs/code-indexing/cobol/deep-indexing.md +++ /dev/null @@ -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 ` | `SELECT * FROM EMPLOYEES` | -| `INSERT INTO
` | `INSERT INTO EMPLOYEES` | -| `UPDATE
` | `UPDATE EMPLOYEES SET ...` | -| `JOIN
` | `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 ` | `SELECT MASTER-FILE` | -| ASSIGN | `ASSIGN TO ` | `ASSIGN TO "MASTER.DAT"` | -| ORGANIZATION | `ORGANIZATION IS ` | `ORGANIZATION IS INDEXED` | -| ACCESS | `ACCESS MODE IS ` | `ACCESS MODE IS DYNAMIC` | -| RECORD KEY | `RECORD KEY IS ` | `RECORD KEY IS WK-EMP-ID` | -| FILE STATUS | `FILE STATUS IS ` | `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 diff --git a/docs/code-indexing/cobol/file-detection.md b/docs/code-indexing/cobol/file-detection.md deleted file mode 100644 index 60b60918a..000000000 --- a/docs/code-indexing/cobol/file-detection.md +++ /dev/null @@ -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
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`: - -```typescript -// From gitnexus/src/core/ingestion/utils.ts -const getCobolDirs = (): Set => { - 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` diff --git a/docs/code-indexing/cobol/graph-model.md b/docs/code-indexing/cobol/graph-model.md deleted file mode 100644 index de82c0723..000000000 --- a/docs/code-indexing/cobol/graph-model.md +++ /dev/null @@ -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) diff --git a/docs/code-indexing/cobol/performance.md b/docs/code-indexing/cobol/performance.md deleted file mode 100644 index b0f69e701..000000000 --- a/docs/code-indexing/cobol/performance.md +++ /dev/null @@ -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` diff --git a/docs/code-indexing/cobol/regex-extraction.md b/docs/code-indexing/cobol/regex-extraction.md deleted file mode 100644 index 9f37c10c9..000000000 --- a/docs/code-indexing/cobol/regex-extraction.md +++ /dev/null @@ -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 diff --git a/docs/guides/microservices-grpc.md b/docs/guides/microservices-grpc.md deleted file mode 100644 index b09fd55e3..000000000 --- a/docs/guides/microservices-grpc.md +++ /dev/null @@ -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//` 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 `/contracts.json`. Those cross-links are what lets `impact({repo: "@", 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 ` 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 ` — 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; `@/` 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//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//status` resource. - -### 5. Run cross-repo impact with `@` routing - -From any shell (you do **not** have to `cd` into a member repo), the normal `impact` / `query` / `context` tools accept `repo: "@"` to fan out across all members, or `repo: "@/"` 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::./` 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('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::./` when a method is named and the service resolves against the proto map, -- `grpc::./*` (wildcard) when only the service is known, or -- `grpc::/*` 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::/`. 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::::`) 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 --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 `. Use `gitnexus group status ` 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 `@` 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). diff --git a/docs/guides/microservices-thrift.md b/docs/guides/microservices-thrift.md deleted file mode 100644 index 7b37680aa..000000000 --- a/docs/guides/microservices-thrift.md +++ /dev/null @@ -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::./`. -- Service wildcard ids in the form `thrift::./*` 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. diff --git a/docs/plans/2026-03-26-feat-cobol-full-language-coverage-plan.md b/docs/plans/2026-03-26-feat-cobol-full-language-coverage-plan.md deleted file mode 100644 index b1a2e880c..000000000 --- a/docs/plans/2026-03-26-feat-cobol-full-language-coverage-plan.md +++ /dev/null @@ -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 `:` 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 diff --git a/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md b/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md deleted file mode 100644 index 0c9204e8c..000000000 --- a/docs/superpowers/plans/2026-04-02-pr626-high-fixes.md +++ /dev/null @@ -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) " -``` - ---- - -### 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', ''); - - 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) " -``` - ---- - -### 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) " -``` - ---- - -### 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) " -``` - ---- - -### 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) " -``` diff --git a/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md b/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md deleted file mode 100644 index bdd1774e6..000000000 --- a/docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md +++ /dev/null @@ -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 {` 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` (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). diff --git a/gitnexus-web/e2e/language-switching.spec.ts b/gitnexus-web/e2e/language-switching.spec.ts new file mode 100644 index 000000000..c8390e081 --- /dev/null +++ b/gitnexus-web/e2e/language-switching.spec.ts @@ -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'); + }); +}); diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index e718eb7f2..f31a7ef5e 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index de51268d0..318624e3e 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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", diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 2ee3569a8..68f7d4968 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -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 => { // 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 && (
- Server connection lost — reconnecting… + {t('errors:backend.reconnecting')}
)} diff --git a/gitnexus-web/src/components/AnalyzeOnboarding.tsx b/gitnexus-web/src/components/AnalyzeOnboarding.tsx index f4147f57c..53260e388 100644 --- a/gitnexus-web/src/components/AnalyzeOnboarding.tsx +++ b/gitnexus-web/src/components/AnalyzeOnboarding.tsx @@ -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 (
{/* Ambient glows — mirrors OnboardingGuide aesthetic */} @@ -47,11 +50,10 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {

- Analyze your first repository + {t('analyzeFirst.title')}

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

@@ -63,7 +65,7 @@ export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => { {/* Footer hint */}

- Public repos only · Cloned locally by the server · No data leaves your machine + {t('analyzeFirst.footer')}

); diff --git a/gitnexus-web/src/components/AnalyzeProgress.tsx b/gitnexus-web/src/components/AnalyzeProgress.tsx index 213c7b3e6..ded08d9dc 100644 --- a/gitnexus-web/src/components/AnalyzeProgress.tsx +++ b/gitnexus-web/src/components/AnalyzeProgress.tsx @@ -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 = { - 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" > - Cancel + {t('actions.cancel')} diff --git a/gitnexus-web/src/components/CodeReferencesPanel.tsx b/gitnexus-web/src/components/CodeReferencesPanel.tsx index bd854df9c..2fc877c55 100644 --- a/gitnexus-web/src/components/CodeReferencesPanel.tsx +++ b/gitnexus-web/src/components/CodeReferencesPanel.tsx @@ -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) =
{showSelectedViewer && (
- SELECTED + {t('graph:codePanel.selected')}
)} {showCitations && ( @@ -325,20 +327,22 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{/* Header */}
- Code Inspector + + {t('graph:codePanel.title')} +
{showCitations && ( @@ -346,7 +350,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = @@ -361,7 +365,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
- Selected + {t('graph:codePanel.selected')}
@@ -372,7 +376,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = @@ -381,7 +385,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = {isLoadingFile ? (
- Loading source... + {t('graph:codePanel.loadingSource')}
) : selectedFileContent ? ( {selectedIsFile ? ( - <> - Code not available in memory for{' '} - {selectedFilePath} - + <>{t('graph:codePanel.codeNotAvailable', { path: selectedFilePath })} ) : ( - <>Select a file node to preview its contents. + <>{t('graph:codePanel.selectFile')} )}
)} @@ -446,11 +447,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
- AI Citations + {t('graph:codePanel.aiCitations')}
- {aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''} + {t('graph:codePanel.references', { count: aiReferences.length })}
@@ -483,9 +484,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = - {ref.label ?? 'Code'} + {ref.label ?? t('graph:codePanel.code')}
@@ -501,7 +502,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = )} {totalLines > 0 && ( - • {totalLines} lines + + {' '} + • {t('graph:codePanel.lines', { count: totalLines })} + )}
@@ -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')} > @@ -526,7 +530,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = @@ -572,8 +576,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) = ) : (
- Code not available in memory for{' '} - {ref.filePath} + {t('graph:codePanel.codeNotAvailable', { path: ref.filePath })}
)}
diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index f268eb656..521b64115 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -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; @@ -60,6 +62,8 @@ function Crossfade({ activeKey, children }: { activeKey: string; children: React // ── Phase cards ───────────────────────────────────────────────────────────── function SuccessCard() { + const { t } = useTranslation('onboarding'); + return (

- Server Connected + {t('success.title')}

- Preparing your code knowledge graph... + {t('success.description')}

{/* Subtle progress hint */} @@ -100,6 +104,8 @@ function SuccessCard() { } function LoadingCard({ message }: { message: string }) { + const { t } = useTranslation(['common', 'onboarding']); + return (

- {message || 'Connecting...'} + {message || t('common:progress.connectingShort')}

- This may take a moment for large repositories + {t('onboarding:loading.largeRepoHint')}

{/* 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(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; diff --git a/gitnexus-web/src/components/EmbeddingStatus.tsx b/gitnexus-web/src/components/EmbeddingStatus.tsx index ecadc7dfb..835d7019c 100644 --- a/gitnexus-web/src/components/EmbeddingStatus.tsx +++ b/gitnexus-web/src/components/EmbeddingStatus.tsx @@ -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 = () => {
@@ -83,7 +85,7 @@ export const EmbeddingStatus = () => {
- Loading AI model... + {t('embedding.loadingModel')}
{
- Embedding {processed}/{total} nodes + {t('embedding.embeddingNodes', { processed, total })}
{ return (
- Creating vector index... + {t('embedding.creatingIndex')}
); } @@ -136,10 +138,10 @@ export const EmbeddingStatus = () => { return (
- Semantic Ready + {t('embedding.ready')}
); } @@ -151,10 +153,10 @@ export const EmbeddingStatus = () => { {fallbackDialog} diff --git a/gitnexus-web/src/components/FileTreePanel.tsx b/gitnexus-web/src/components/FileTreePanel.tsx index 70a9a826c..a361725ef 100644 --- a/gitnexus-web/src/components/FileTreePanel.tsx +++ b/gitnexus-web/src/components/FileTreePanel.tsx @@ -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) => { @@ -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')} > @@ -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')} > @@ -345,7 +347,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { : 'text-text-secondary hover:bg-hover hover:text-text-primary' }`} > - Explorer + {t('graph:fileTree.explorer')}
@@ -375,7 +377,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { 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 */}
{fileTree.length === 0 ? ( -
No files loaded
+
+ {t('graph:fileTree.noFilesLoaded')} +
) : ( fileTree.map((node) => ( {

- Node Types + {t('graph:fileTree.nodeTypes')}

-

- Toggle visibility of node types in the graph -

+

{t('graph:fileTree.nodeTypesDesc')}

@@ -449,11 +451,9 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { {/* Edge Type Toggles */}

- Edge Types + {t('graph:fileTree.edgeTypes')}

-

- Toggle visibility of relationship types -

+

{t('graph:fileTree.edgeTypesDesc')}

{ALL_EDGE_TYPES.map((edgeType) => { @@ -488,19 +488,17 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {

- Focus Depth + {t('graph:fileTree.focusDepth')}

-

- Show nodes within N hops of selection -

+

{t('graph:fileTree.focusDepthDesc')}

{[ - { 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 }) => (
{depthFilter !== null && !selectedNode && ( -

Select a node to apply depth filter

+

+ {t('graph:fileTree.selectNodeDepth')} +

)}
{/* Legend */}

- Color Legend + {t('graph:fileTree.colorLegend')}

{( @@ -558,8 +558,8 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { {graph && (
- {graph.nodes.length} nodes - {graph.relationships.length} edges + {t('common:counts.nodes', { count: graph.nodes.length })} + {t('common:counts.edges', { count: graph.relationships.length })}
)} diff --git a/gitnexus-web/src/components/GraphCanvas.tsx b/gitnexus-web/src/components/GraphCanvas.tsx index e34fd434c..cdf00c3bb 100644 --- a/gitnexus-web/src/components/GraphCanvas.tsx +++ b/gitnexus-web/src/components/GraphCanvas.tsx @@ -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((_, ref) => { + const { t } = useTranslation('graph'); const { graph, setSelectedNode, @@ -268,7 +270,7 @@ export const GraphCanvas = forwardRef((_, 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')}
)} @@ -278,21 +280,21 @@ export const GraphCanvas = forwardRef((_, ref) => { @@ -305,7 +307,7 @@ export const GraphCanvas = forwardRef((_, ref) => { @@ -316,7 +318,7 @@ export const GraphCanvas = forwardRef((_, ref) => { @@ -333,7 +335,7 @@ export const GraphCanvas = forwardRef((_, 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 ? : } @@ -343,7 +345,9 @@ export const GraphCanvas = forwardRef((_, ref) => { {isLayoutRunning && (
- Layout optimizing... + + {t('canvas.layoutOptimizing')} +
)} @@ -359,7 +363,9 @@ export const GraphCanvas = forwardRef((_, 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 ? ( diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 3061a2e4a..3fae0c48f 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -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 = { @@ -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 && (
- Repositories + {t('header:repositories')}
{availableRepos.map((repo) => (
{repo.name === projectName && ( - active + {t('header:active')} )} @@ -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 }) } > @@ -332,7 +336,10 @@ export const Header = ({
- Re-analyzing {reanalyzing}: {reanalyzeProgress.message} + {t('header:reanalyzingRepo', { + repoName: reanalyzing, + message: translateProgressMessage(reanalyzeProgress.message, t), + })}
@@ -359,7 +366,7 @@ export const Header = ({ > - Analyze a new repository... + {t('header:analyzeNew')}
@@ -378,7 +385,7 @@ export const Header = ({ { setSearchQuery(e.target.value); @@ -399,7 +406,7 @@ export const Header = ({
{searchResults.length === 0 ? (
- No nodes found for “{searchQuery}” + {t('header:noNodesFound', { query: searchQuery })}
) : (
@@ -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" > - Star if cool + {t('header:starIfCool')} ✨ @@ -449,24 +456,26 @@ export const Header = ({ {/* Stats */} {graph && (
- {nodeCount} nodes - {edgeCount} edges + {t('common:counts.nodes', { count: nodeCount })} + {t('common:counts.edges', { count: edgeCount })}
)} {/* Embedding Status */} + + {/* Icon buttons */}
diff --git a/gitnexus-web/src/components/HelpPanel.tsx b/gitnexus-web/src/components/HelpPanel.tsx index fcc21ff4d..2df94ef87 100644 --- a/gitnexus-web/src/components/HelpPanel.tsx +++ b/gitnexus-web/src/components/HelpPanel.tsx @@ -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: }, - { id: 'graph', label: 'Graph & nodes', icon: }, - { id: 'search', label: 'Search & filter', icon: }, - { id: 'ai', label: 'Nexus AI', icon: }, - { id: 'shortcuts', label: 'Shortcuts', icon: }, - { id: 'status', label: 'Status bar', icon: }, + { id: 'overview', icon: }, + { id: 'graph', icon: }, + { id: 'search', icon: }, + { id: 'ai', icon: }, + { id: 'shortcuts', icon: }, + { id: 'status', icon: }, ]; 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: ( [ }} /> ), - 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} ), - 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} ), - 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')} ), - title: 'AI index status', - desc: 'Repo is fully indexed for AI queries', + title: t('status.aiIndexStatus'), + desc: t('status.aiIndexStatusDesc'), }, // { badge: typescript, 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 (
@@ -131,7 +133,7 @@ function TabContent({ letterSpacing: '0.08em', }} > - Getting started + {t('overview.gettingStarted')}

- What is GitNexus? + {t('overview.whatIsTitle')}

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

@@ -160,11 +161,10 @@ function TabContent({ }} >

- Your current repo + {t('overview.currentRepoTitle')}

- Loaded: {nodeCount}{' '} - nodes · {edgeCount} edges + {t('overview.loadedCounts', { nodeCount, edgeCount })}

@@ -177,15 +177,16 @@ function TabContent({ }} >

- Three ways to explore + {t('overview.threeWaysTitle')}

- 1. Click nodes to inspect + 1.{' '} + {t('overview.wayInspect')}
- 2. Search by name or type + 2.{' '} + {t('overview.waySearch')}
- 3. Ask Nexus AI a natural - language question + 3. {t('overview.wayAsk')}

@@ -198,11 +199,11 @@ function TabContent({ }} >

- Navigation + {t('overview.navigationTitle')}

- · Scroll to zoom
- · Click and drag to pan
· Double-click a node to focus its subgraph + · {t('overview.navZoom')}
· {t('overview.navPan')}
·{' '} + {t('overview.navFocus')}

@@ -220,44 +221,44 @@ function TabContent({ letterSpacing: '0.08em', }} > - Node color legend + {t('graph.nodeColorLegend')}

- {nodeColors.map(({ color, label, desc }) => ( -
- -
-

- {label} nodes -

-

{desc}

+ {nodeColors.map(({ color, labelKey, descKey }) => { + const label = t(labelKey); + return ( +
+ +
+

+ {t('graph.nodeLabel', { label })} +

+

{t(descKey)}

+
-
- ))} + ); + })}

- Node size reflects - connection count — larger nodes are depended on by more files. Edges point from importer → - imported. + {t('graph.sizeDescription')}

- Click any node to open its detail panel — showing imports, exports, and reverse - dependencies. + {t('graph.detailDescription')}

@@ -275,7 +276,7 @@ function TabContent({ letterSpacing: '0.08em', }} > - Search & filter + {t('search.title')}

⌘K/Ctrl K

- Search nodes + {t('search.searchNodes')}

- Search by filename, function name, or import path. Matching nodes are highlighted live - in the graph. + {t('search.searchDescription')}

@@ -299,12 +299,11 @@ function TabContent({

- Filter panel + {t('search.filterPanel')}

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

@@ -312,13 +311,13 @@ function TabContent({ style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 10, padding: '12px 14px' }} >

- Search syntax + {t('search.syntax')}

{[ - { 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 }) => (
{query} - {hint} + {t(hintKey)}
))}
@@ -355,7 +354,7 @@ function TabContent({ letterSpacing: '0.08em', }} > - Nexus AI + {t('ai.title')}

- ✓ Semantic Ready + {t('ai.semanticReady')}

- Your repo is indexed and ready for semantic queries. Nexus AI understands code structure - and relationships, not just file names. + {t('ai.description')}

-

Try asking:

+

{t('tryAsking')}

{[ - '"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) => (

- Open the prompt via the Nexus AI button - (top-right). + {t('ai.openPrompt')}

); @@ -428,7 +425,7 @@ function TabContent({ letterSpacing: '0.08em', }} > - Action + {t('shortcuts.columns.action')}
- {shortcuts.map(({ label, mac, win }, i) => ( + {shortcuts.map(({ labelKey, mac, win }, i) => (
- {label} + {t(labelKey)} {mac} @@ -491,9 +488,9 @@ function TabContent({ letterSpacing: '0.08em', }} > - Status bar explained + {t('status.explained')}

- {getStatusItems(nodeCount, edgeCount).map(({ badge, title, desc }) => ( + {getStatusItems(t, nodeCount, edgeCount).map(({ badge, title, desc }) => (
{ + const { t } = useTranslation('help'); const [active, setActive] = useState('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

- Help & Reference + {t('title')}

-

GitNexus — graph explorer

+

{t('footer')}

diff --git a/gitnexus-web/src/components/LanguageSwitcher.tsx b/gitnexus-web/src/components/LanguageSwitcher.tsx new file mode 100644 index 000000000..e61efa6ce --- /dev/null +++ b/gitnexus-web/src/components/LanguageSwitcher.tsx @@ -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 ( + + ); +}; diff --git a/gitnexus-web/src/components/LoadingOverlay.tsx b/gitnexus-web/src/components/LoadingOverlay.tsx index 9fa46c9ee..e099961b0 100644 --- a/gitnexus-web/src/components/LoadingOverlay.tsx +++ b/gitnexus-web/src/components/LoadingOverlay.tsx @@ -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 (
{/* Background gradient effects */} @@ -32,11 +38,11 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => { {/* Status text */}

- {progress.message} + {message} |

{progress.detail && ( -

{progress.detail}

+

{detail}

)}
@@ -46,12 +52,15 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
- {progress.stats.filesProcessed} / {progress.stats.totalFiles} files + {t('graph:loading.filesProgress', { + processed: progress.stats.filesProcessed, + total: progress.stats.totalFiles, + })}
- {progress.stats.nodesCreated} nodes + {t('common:counts.nodes', { count: progress.stats.nodesCreated })}
)} diff --git a/gitnexus-web/src/components/MarkdownRenderer.tsx b/gitnexus-web/src/components/MarkdownRenderer.tsx index 8e728ece4..7417cefd0 100644 --- a/gitnexus-web/src/components/MarkdownRenderer.tsx +++ b/gitnexus-web/src/components/MarkdownRenderer.tsx @@ -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 = ({ toolCalls, showCopyButton = false, }) => { + const { t } = useTranslation('common'); const [copied, setCopied] = useState(false); const copyTimerRef = useRef>(undefined); @@ -125,7 +127,9 @@ export const MarkdownRenderer: React.FC = ({ 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} > {children} @@ -182,7 +186,7 @@ export const MarkdownRenderer: React.FC = ({ }, pre: ({ children }: any) => <>{children}, }), - [handleLinkClick], + [handleLinkClick, t], ); return ( @@ -205,14 +209,14 @@ export const MarkdownRenderer: React.FC = ({
)} diff --git a/gitnexus-web/src/components/MermaidDiagram.tsx b/gitnexus-web/src/components/MermaidDiagram.tsx index b44a640d2..22529137e 100644 --- a/gitnexus-web/src/components/MermaidDiagram.tsx +++ b/gitnexus-web/src/components/MermaidDiagram.tsx @@ -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(null); const [error, setError] = useState(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) => {
- Diagram Error + {t('graph:diagram.error')}
{error}
- Show source + {t('graph:diagram.showSource')}
             {code}
@@ -134,12 +136,12 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
           {/* Header */}
           
- Diagram + {t('graph:diagram.label')} @@ -161,7 +163,9 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => { {/* Use ProcessFlowModal for expansion */} {showModal && processData && ( - Loading diagram…
}> + {t('graph:diagram.loading')}
} + > setShowModal(false)} /> )} diff --git a/gitnexus-web/src/components/OnboardingGuide.tsx b/gitnexus-web/src/components/OnboardingGuide.tsx index 796e0f148..2efd65855 100644 --- a/gitnexus-web/src/components/OnboardingGuide.tsx +++ b/gitnexus-web/src/components/OnboardingGuide.tsx @@ -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 | null>(null); @@ -32,7 +34,7 @@ function CopyButton({ text }: { text: string }) { return (
@@ -168,6 +171,8 @@ function StepRow({ // ── Polling status bar ──────────────────────────────────────────────────────── function PollingBar() { + const { t } = useTranslation('onboarding'); + return (

- Listening for server + {t('guide.listeningForServer')} ...

-

Will auto-connect when detected

+

{t('guide.willAutoConnect')}

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

- Start your local server + {t('guide.startServer')}

- {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')}

@@ -248,8 +252,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => { @@ -259,13 +263,13 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
- or install globally + {t('guide.orInstallGlobally')}
@@ -276,10 +280,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => { {isPolling && } @@ -288,8 +290,8 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
@@ -297,7 +299,7 @@ export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
- Requires{' '} + {t('guide.requires')}{' '} { · - Port 4747 + {t('guide.port')}
); diff --git a/gitnexus-web/src/components/ProcessFlowModal.tsx b/gitnexus-web/src/components/ProcessFlowModal.tsx index df538cb3e..7dffd2b84 100644 --- a/gitnexus-web/src/components/ProcessFlowModal.tsx +++ b/gitnexus-web/src/components/ProcessFlowModal.tsx @@ -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(null); const diagramRef = useRef(null); const scrollContainerRef = useRef(null); @@ -171,13 +173,13 @@ export const ProcessFlowModal = ({ diagramRef.current!.innerHTML = `
- ${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'} + ${isSizeError ? t('graph:processFlow.diagramTooLarge') : t('graph:processFlow.renderError')}
${ 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 }) }
@@ -186,7 +188,7 @@ export const ProcessFlowModal = ({ }; renderDiagram(); - }, [process]); + }, [process, t]); // Close on escape useEffect(() => { @@ -242,7 +244,9 @@ export const ProcessFlowModal = ({ {/* Header */}
-

Process: {process.label}

+

+ {t('graph:processFlow.title', { label: process.label })} +

{/* Diagram */} @@ -271,7 +275,7 @@ export const ProcessFlowModal = ({ @@ -281,7 +285,7 @@ export const ProcessFlowModal = ({ @@ -289,9 +293,9 @@ export const ProcessFlowModal = ({ {onFocusInGraph && ( )}
diff --git a/gitnexus-web/src/components/ProcessesPanel.tsx b/gitnexus-web/src/components/ProcessesPanel.tsx index 784cec90c..73839678c 100644 --- a/gitnexus-web/src/components/ProcessesPanel.tsx +++ b/gitnexus-web/src/components/ProcessesPanel.tsx @@ -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(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 = () => {
-

No Processes Detected

+

+ {t('graph:processes.emptyTitle')} +

- Processes are execution flows traced from entry points. Load a codebase to see detected - processes. + {t('graph:processes.emptyDescription')}

); @@ -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" />
@@ -356,7 +359,7 @@ export const ProcessesPanel = () => { className="flex items-center gap-2 text-xs text-text-muted" data-testid="process-list-loaded" > - {totalCount} processes detected + {t('graph:processes.detected', { count: totalCount })}
@@ -374,9 +377,11 @@ export const ProcessesPanel = () => {

- Full Process Map + {t('graph:processes.fullMap')}

-

View combined map of {totalCount} processes

+

+ {t('graph:processes.viewCombined', { count: totalCount })} +

{loadingProcess === 'all' ? ( @@ -401,7 +406,9 @@ export const ProcessesPanel = () => { )} - Cross-Community + + {t('graph:processes.crossCommunity')} + {filteredProcesses.cross.length} @@ -438,7 +445,9 @@ export const ProcessesPanel = () => { )} - Intra-Community + + {t('graph:processes.intraCommunity')} + {filteredProcesses.intra.length} @@ -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 = ({
{process.label}
- {process.stepCount} steps + {t('graph:processes.steps', { count: process.stepCount })} {process.clusters.length > 0 && ( <> • - {process.clusters.length} clusters + {t('graph:processes.clusters', { count: process.clusters.length })} )}
@@ -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" > @@ -541,16 +555,16 @@ const ProcessItem = ({ }`} > {isLoading ? ( - Loading... + {t('graph:processes.loading')} ) : isSelected ? ( <> - Viewing + {t('graph:processes.viewing')} ) : ( <> - View + {t('graph:processes.view')} )} diff --git a/gitnexus-web/src/components/QueryFAB.tsx b/gitnexus-web/src/components/QueryFAB.tsx index 9b8307233..121a44fc3 100644 --- a/gitnexus-web/src/components/QueryFAB.tsx +++ b/gitnexus-web/src/components/QueryFAB.tsx @@ -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)]" > - Query + {t('graph:queryFab.query')} {queryResult && queryResult.nodeIds.length > 0 && ( {queryResult.nodeIds.length} @@ -209,7 +220,7 @@ export const QueryFAB = () => {
- Cypher Query + {t('graph:queryFab.cypherQuery')}
))} @@ -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')} )} @@ -297,12 +308,13 @@ export const QueryFAB = () => {
- {queryResult.rows.length} rows + {queryResult.rows.length}{' '} + {t('graph:queryFab.rows')} {queryResult.nodeIds.length > 0 && ( {queryResult.nodeIds.length}{' '} - highlighted + {t('graph:queryFab.highlighted')} )} {queryResult.executionTime.toFixed(1)}ms @@ -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')} )}
{queryResult.rows.length > 50 && (
- Showing 50 of {queryResult.rows.length} rows + {t('graph:queryFab.showingRows', { count: queryResult.rows.length })}
)} diff --git a/gitnexus-web/src/components/RepoAnalyzer.tsx b/gitnexus-web/src/components/RepoAnalyzer.tsx index 3676e7676..0b7f0abbd 100644 --- a/gitnexus-web/src/components/RepoAnalyzer.tsx +++ b/gitnexus-web/src/components/RepoAnalyzer.tsx @@ -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 ( -
+
); @@ -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 ? : } - {isLoading ? 'Starting analysis...' : 'Analyze Repository'} + {isLoading ? t('repoAnalyzer.starting') : t('repoAnalyzer.analyzeRepository')} {canSubmit && !isLoading && } ); @@ -124,6 +132,8 @@ function AnalyzeButton({ // ── Done state ─────────────────────────────────────────────────────────────── function DoneState({ repoName }: { repoName: string }) { + const { t } = useTranslation('onboarding'); + return (
-

Analysis complete

+

{t('repoAnalyzer.complete')}

{repoName}

-

Loading graph...

+

{t('repoAnalyzer.loadingGraph')}

); } @@ -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(null); const [mode, setMode] = useState('github'); @@ -164,7 +175,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp const [progress, setProgress] = useState({ 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')}
- GitLab Repository URL + {t('onboarding:repoAnalyzer.gitlabRepositoryUrl')}
)}
-

- Supports GitLab.com and self-hosted GitLab instances. -

+

{t('onboarding:repoAnalyzer.gitlabSupported')}

)} @@ -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')}
- Browse for folder + {t('onboarding:repoAnalyzer.browseForFolder')}
)} @@ -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')} {onCancel && ( )} @@ -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')} )} diff --git a/gitnexus-web/src/components/RepoLanding.tsx b/gitnexus-web/src/components/RepoLanding.tsx index e6a44a78e..600312a44 100644 --- a/gitnexus-web/src/components/RepoLanding.tsx +++ b/gitnexus-web/src/components/RepoLanding.tsx @@ -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 }) {repo.indexedAt && (

- Indexed {formatRelativeTime(repo.indexedAt)} + {t('onboarding:landing.indexed', { time: formatRelativeTime(repo.indexedAt, t) })}

)} @@ -64,17 +67,18 @@ function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void })
{stats.files != null && ( - {stats.files.toLocaleString()} files + {t('common:counts.files', { count: stats.files })} )} {stats.nodes != null && ( - {stats.nodes.toLocaleString()} symbols + {t('common:counts.symbols', { count: stats.nodes })} )} {stats.processes != null && stats.processes > 0 && ( - {stats.processes} flows + {' '} + {t('common:counts.flows', { count: stats.processes })} )}
@@ -92,6 +96,8 @@ interface RepoLandingProps { } export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => { + const { t } = useTranslation('onboarding'); + return (
{/* Ambient glows — mirrors OnboardingGuide aesthetic */} @@ -109,10 +115,10 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand

- Choose a repository + {t('landing.chooseRepository')}

- Select an indexed repository to explore, or analyze a new one. + {t('landing.description')}

@@ -128,7 +134,7 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand
- or analyze new + {t('landing.orAnalyzeNew')}
@@ -140,8 +146,7 @@ export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLand {/* Footer hint */}

- Public & private repos · Cloned locally by the server · No data leaves - your machine + {t('landing.footer')}

); diff --git a/gitnexus-web/src/components/RightPanel.tsx b/gitnexus-web/src/components/RightPanel.tsx index 328e4b8c5..100392ce2 100644 --- a/gitnexus-web/src/components/RightPanel.tsx +++ b/gitnexus-web/src/components/RightPanel.tsx @@ -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 = () => { }`} > - Nexus AI + {t('chat:tabs.chat')} {/* Processes Tab */} @@ -238,9 +240,9 @@ export const RightPanel = () => { }`} > - Processes + {t('chat:tabs.processes')} - NEW + {t('chat:newBadge')}
@@ -249,7 +251,7 @@ export const RightPanel = () => { @@ -270,12 +272,12 @@ export const RightPanel = () => {
{!isAgentReady && ( - Configure AI + {t('chat:badges.configureAI')} )} {isAgentInitializing && ( - Connecting + {t('chat:badges.connecting')} )}
@@ -296,10 +298,9 @@ export const RightPanel = () => {
🧠
-

Ask me anything

+

{t('chat:empty.title')}

- I can help you understand the architecture, find functions, or explain - connections. + {t('chat:empty.description')}

{chatSuggestions.map((suggestion) => ( @@ -323,7 +324,7 @@ export const RightPanel = () => {
- You + {t('chat:roles.you')}
{message.content}
@@ -336,7 +337,7 @@ export const RightPanel = () => {
- Nexus AI + {t('chat:roles.assistant')} {isChatLoading && message === chatMessages[chatMessages.length - 1] && ( @@ -394,7 +395,7 @@ export const RightPanel = () => { {/* Scroll to bottom */} {/* 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 = () => { {isChatLoading ? ( @@ -449,8 +450,8 @@ export const RightPanel = () => { {isProviderConfigured() - ? 'Initializing AI agent...' - : 'Configure an LLM provider to enable chat.'} + ? t('chat:input.initializing') + : t('chat:input.configureProvider')}
)} diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index 4ba6a3f74..e6b933e49 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -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(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 = ({ - {displayValue || 'Select or type a model...'} + {displayValue || t('selectModelPlaceholder')} )}
@@ -167,20 +169,20 @@ const OpenRouterModelCombobox = ({ {isLoading ? (
- Loading models... + {t('loadingModels')}
) : filteredModels.length === 0 ? (
{models.length === 0 ? (
-

Type a model ID or press Enter

-

e.g. openai/gpt-4o

+

{t('customModelHint')}

+

{t('customModelExample')}

) : (
-

No models match "{searchTerm}"

-

Press Enter to use as custom ID

+

{t('noModelsMatch', { searchTerm })}

+

{t('pressEnterCustom')}

)}
@@ -198,7 +200,7 @@ const OpenRouterModelCombobox = ({ ))} {filteredModels.length > 50 && (
- +{filteredModels.length - 50} more • Refine your search + {t('moreModels', { count: filteredModels.length - 50 })}
)}
@@ -248,6 +250,7 @@ export const SettingsPanel = ({ isBackendConnected, onBackendUrlChange, }: SettingsPanelProps) => { + const { t } = useTranslation(['common', 'settings']); const [settings, setSettings] = useState(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); 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 = ({
-

AI Settings

-

Configure your LLM provider

+

{t('settings:title')}

+

{t('settings:subtitle')}

@@ -438,7 +445,7 @@ export const SettingsPanel = ({
- API keys are stored in session storage and will be cleared when you close this tab. + {t('settings:apiKeySession')}
{/* 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 = ({

- Leave empty to use the default OpenAI API. Set a custom URL for proxies or - compatible APIs. + {t('settings:providers.openai.baseUrlHint')}

@@ -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 = ({

- Default port is 11434. + {t('settings:defaultPort')}{' '} + 11434.

- + {ollamaError && !isCheckingOllama && (
@@ -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" />

- Pull a model with{' '} + {t('settings:pullModel')}{' '} ollama pull llama3.2

@@ -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 = ({ }} >
- + @@ -795,14 +814,14 @@ export const SettingsPanel = ({ onLoadModels={loadOpenRouterModels} />

- Browse all models at{' '} + {t('settings:browseModels')}{' '} - OpenRouter Models + {t('settings:openRouterModels')}

@@ -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' && ( + + 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)', + }} + > +

+ Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and + requires round-tripping reasoning content. +

+
+ )} + {/* GLM Settings */} {settings.activeProvider === 'glm' && (

- Get your API key from{' '} + {t('settings:providers.openai.helperText')}{' '} - Z.AI Platform + {t('settings:zaiPlatform')}

- + -

- Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API. -

+

{t('settings:glmCodingApi')}

)} @@ -934,10 +992,10 @@ export const SettingsPanel = ({ 🔒
- Privacy: 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. + + {t('settings:privacyLabel')} + {' '} + {t('settings:privacyFull')}
@@ -949,13 +1007,13 @@ export const SettingsPanel = ({ {saveStatus === 'saved' && ( - Settings saved + {t('settings:settingsSaved')} )} {saveStatus === 'error' && ( - Failed to save + {t('settings:failedToSave')} )} @@ -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')} diff --git a/gitnexus-web/src/components/StatusBar.tsx b/gitnexus-web/src/components/StatusBar.tsx index 7468072fa..4193d4f21 100644 --- a/gitnexus-web/src/components/StatusBar.tsx +++ b/gitnexus-web/src/components/StatusBar.tsx @@ -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}%` }} /> - {progress.message} + {translateProgressMessage(progress.message, t)} ) : (
- Ready + {t('common:progress.ready')}
)} @@ -56,10 +59,10 @@ export const StatusBar = () => { > - Sponsor + {t('graph:statusBar.sponsor')} - need to buy some API credits to run SWE-bench 😅 + {t('graph:statusBar.sponsorHint')} @@ -67,9 +70,9 @@ export const StatusBar = () => {
{graph && ( <> - {nodeCount} nodes + {t('common:counts.nodes', { count: nodeCount })} • - {edgeCount} edges + {t('common:counts.edges', { count: edgeCount })} {primaryLanguage && ( <> • diff --git a/gitnexus-web/src/components/ToolCallCard.tsx b/gitnexus-web/src/components/ToolCallCard.tsx index f96f068d9..da27b789a 100644 --- a/gitnexus-web/src/components/ToolCallCard.tsx +++ b/gitnexus-web/src/components/ToolCallCard.tsx @@ -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 => { +const formatArgs = (args: Record, t: TFunction): string => { if (!args || Object.keys(args).length === 0) { return ''; } @@ -34,7 +36,7 @@ const formatArgs = (args: Record): 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 = { // 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 (
- {getToolDisplayName(toolCall.name)} + {getToolDisplayName(toolCall.name, t)} {/* Status indicator */} {status.icon} - {toolCall.status} + {t(`graph:toolCall.status.${toolCall.status}`)}
@@ -148,7 +151,7 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard {formattedArgs && (
- {toolCall.name === 'cypher' ? 'Query' : 'Input'} + {toolCall.name === 'cypher' ? t('graph:toolCall.query') : t('graph:toolCall.input')}
                 {formattedArgs}
@@ -160,12 +163,12 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
           {toolCall.result && (
             
- Result + {t('graph:toolCall.result')}
                   {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}
                 
@@ -176,7 +179,7 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard {toolCall.status === 'running' && !toolCall.result && (
- Executing... + {t('common:progress.executing')}
)}
diff --git a/gitnexus-web/src/components/WebGPUFallbackDialog.tsx b/gitnexus-web/src/components/WebGPUFallbackDialog.tsx index 91c0a16f5..5b607ac1b 100644 --- a/gitnexus-web/src/components/WebGPUFallbackDialog.tsx +++ b/gitnexus-web/src/components/WebGPUFallbackDialog.tsx @@ -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 = ({ 🤔
-

WebGPU said "nope"

-

- Your browser doesn't support GPU acceleration -

+

+ {t('embedding.fallback.title')} +

+

{t('embedding.fallback.subtitle')}

@@ -80,24 +82,31 @@ export const WebGPUFallbackDialog = ({ {/* Content */}

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

- Your options: + + {t('embedding.fallback.options')} +

  • - Use CPU — Works but{' '} - {isSmallCodebase ? 'a bit' : 'way'} slower + {t('embedding.fallback.useCpu')}{' '} + —{' '} + {isSmallCodebase + ? t('embedding.fallback.useCpuDescriptionSmall') + : t('embedding.fallback.useCpuDescriptionLarge')} {nodeCount > 0 && ( {' '} - (~{estimatedMinutes} min for {nodeCount} nodes) + {t('embedding.fallback.estimated', { + minutes: estimatedMinutes, + count: nodeCount, + })} )} @@ -105,8 +114,8 @@ export const WebGPUFallbackDialog = ({
  • - Skip it — Graph works, just no AI - semantic search + {t('embedding.fallback.skipIt')}{' '} + — {t('embedding.fallback.skipDescription')}
@@ -115,11 +124,11 @@ export const WebGPUFallbackDialog = ({ {isSmallCodebase && (

- Small codebase detected! CPU should be fine. + {t('embedding.fallback.smallCodebase')}

)} -

💡 Tip: Try Chrome or Edge for WebGPU support

+

{t('embedding.fallback.tip')}

{/* 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" > - Skip Embeddings + {t('embedding.fallback.skipEmbeddings')}
diff --git a/gitnexus-web/src/components/settings/ProviderConfigCard.tsx b/gitnexus-web/src/components/settings/ProviderConfigCard.tsx index 291ca74c4..b41fa74b1 100644 --- a/gitnexus-web/src/components/settings/ProviderConfigCard.tsx +++ b/gitnexus-web/src/components/settings/ProviderConfigCard.tsx @@ -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 (
@@ -48,7 +51,7 @@ export const ProviderConfigCard = ({
- {apiKey.helperLinkLabel ?? 'Learn more'} + {apiKey.helperLinkLabel ?? t('learnMore')} ) : null}

@@ -87,7 +90,7 @@ export const ProviderConfigCard = ({ {model && (
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, messages: AgentMessage[], + options: AgentRuntimeOptions = {}, ): AsyncGenerator { 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, messages: AgentMessage[], ): Promise => { - const formattedMessages = messages.map((m) => ({ - role: m.role, - content: m.content, - })); + const formattedMessages = buildLangChainMessages(messages); const result = await agent.invoke({ messages: formattedMessages }); diff --git a/gitnexus-web/src/core/llm/deepseek-chat-model.ts b/gitnexus-web/src/core/llm/deepseek-chat-model.ts new file mode 100644 index 000000000..9abeb46e2 --- /dev/null +++ b/gitnexus-web/src/core/llm/deepseek-chat-model.ts @@ -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 { + 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 { + 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 { + this.setActiveMessages(messages); + try { + yield* super._streamResponseChunks(messages, options, runManager); + } finally { + this.activeMessages = null; + } + } + + override async completionWithRetry(request: any, requestOptions?: any): Promise { + 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 { + 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, + ): Runnable { + // 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(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 => { + if (toolCall?.args && typeof toolCall.args === 'object') { + return toolCall.args as Record; + } + 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> => { + 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>, +): Array> => + messages.map((message: any) => { + const role = getOpenAIRole(message); + const additionalKwargs = + message.additional_kwargs && typeof message.additional_kwargs === 'object' + ? message.additional_kwargs + : {}; + const requestMessage: Record = { + 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; + }); diff --git a/gitnexus-web/src/core/llm/settings-service.ts b/gitnexus-web/src/core/llm/settings-service.ts index 86330d2a5..79a7a4309 100644 --- a/gitnexus-web/src/core/llm/settings-service.ts +++ b/gitnexus-web/src/core/llm/settings-service.ts @@ -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 | null): LLMSettings => ...DEFAULT_LLM_SETTINGS.glm, ...parsed?.glm, }, + deepseek: { + ...DEFAULT_LLM_SETTINGS.deepseek, + ...parsed?.deepseek, + }, }); const readSettings = (storage: Storage): Partial | null => { @@ -144,7 +149,9 @@ export const updateProviderSettings = ( ? Partial> : T extends 'glm' ? Partial> - : never + : T extends 'deepseek' + ? Partial> + : never >, ): LLMSettings => { const current = loadSettings(); @@ -239,6 +246,17 @@ export const updateProviderSettings = ( saveSettings(updated); return updated; } + case 'deepseek': { + const updated: LLMSettings = { + ...current, + deepseek: { + ...(current.deepseek ?? {}), + ...(updates as Partial>), + }, + }; + 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 = { 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> = { + 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 []; } diff --git a/gitnexus-web/src/core/llm/types.ts b/gitnexus-web/src/core/llm/types.ts index bebbab830..c568c7d93 100644 --- a/gitnexus-web/src/core/llm/types.ts +++ b/gitnexus-web/src/core/llm/types.ts @@ -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>; minimax?: Partial>; glm?: Partial>; + deepseek?: Partial>; // 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; + 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 */ diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index 25c57767f..5a7e85457 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -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 => { + 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((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; diff --git a/gitnexus-web/src/i18n/error-messages.ts b/gitnexus-web/src/i18n/error-messages.ts new file mode 100644 index 000000000..620350b93 --- /dev/null +++ b/gitnexus-web/src/i18n/error-messages.ts @@ -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'); +} diff --git a/gitnexus-web/src/i18n/index.ts b/gitnexus-web/src/i18n/index.ts new file mode 100644 index 000000000..41724c79d --- /dev/null +++ b/gitnexus-web/src/i18n/index.ts @@ -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; diff --git a/gitnexus-web/src/i18n/languages.ts b/gitnexus-web/src/i18n/languages.ts new file mode 100644 index 000000000..af6cc7cd0 --- /dev/null +++ b/gitnexus-web/src/i18n/languages.ts @@ -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] + ); +} diff --git a/gitnexus-web/src/i18n/progress.ts b/gitnexus-web/src/i18n/progress.ts new file mode 100644 index 000000000..5d45f3dc1 --- /dev/null +++ b/gitnexus-web/src/i18n/progress.ts @@ -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 = { + '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', +}; diff --git a/gitnexus-web/src/i18n/resources.ts b/gitnexus-web/src/i18n/resources.ts new file mode 100644 index 000000000..e7bdc2b8c --- /dev/null +++ b/gitnexus-web/src/i18n/resources.ts @@ -0,0 +1,20 @@ +import type { Resource } from 'i18next'; + +const localeModules = import.meta.glob('../locales/*/*.json', { + eager: true, + import: 'default', +}) as Record>; + +export const resources: Resource = {}; +export const namespaces = new Set(); + +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(); diff --git a/gitnexus-web/src/locales/en/chat.json b/gitnexus-web/src/locales/en/chat.json new file mode 100644 index 000000000..8fd9719bc --- /dev/null +++ b/gitnexus-web/src/locales/en/chat.json @@ -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" +} diff --git a/gitnexus-web/src/locales/en/common.json b/gitnexus-web/src/locales/en/common.json new file mode 100644 index 000000000..568978c91 --- /dev/null +++ b/gitnexus-web/src/locales/en/common.json @@ -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" + } +} diff --git a/gitnexus-web/src/locales/en/errors.json b/gitnexus-web/src/locales/en/errors.json new file mode 100644 index 000000000..e7bdcde33 --- /dev/null +++ b/gitnexus-web/src/locales/en/errors.json @@ -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}}" + } +} diff --git a/gitnexus-web/src/locales/en/graph.json b/gitnexus-web/src/locales/en/graph.json new file mode 100644 index 000000000..6f4bdb66f --- /dev/null +++ b/gitnexus-web/src/locales/en/graph.json @@ -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…" + } +} diff --git a/gitnexus-web/src/locales/en/header.json b/gitnexus-web/src/locales/en/header.json new file mode 100644 index 000000000..3b5318315 --- /dev/null +++ b/gitnexus-web/src/locales/en/header.json @@ -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" +} diff --git a/gitnexus-web/src/locales/en/help.json b/gitnexus-web/src/locales/en/help.json new file mode 100644 index 000000000..9891d3421 --- /dev/null +++ b/gitnexus-web/src/locales/en/help.json @@ -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)." + } +} diff --git a/gitnexus-web/src/locales/en/onboarding.json b/gitnexus-web/src/locales/en/onboarding.json new file mode 100644 index 000000000..cd12c2095 --- /dev/null +++ b/gitnexus-web/src/locales/en/onboarding.json @@ -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)" + } +} diff --git a/gitnexus-web/src/locales/en/settings.json b/gitnexus-web/src/locales/en/settings.json new file mode 100644 index 000000000..ffa18b691 --- /dev/null +++ b/gitnexus-web/src/locales/en/settings.json @@ -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." +} diff --git a/gitnexus-web/src/locales/zh-CN/chat.json b/gitnexus-web/src/locales/zh-CN/chat.json new file mode 100644 index 000000000..366fb7ccb --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/chat.json @@ -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": "新" +} diff --git a/gitnexus-web/src/locales/zh-CN/common.json b/gitnexus-web/src/locales/zh-CN/common.json new file mode 100644 index 000000000..6249db87a --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/common.json @@ -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}} 秒" + } +} diff --git a/gitnexus-web/src/locales/zh-CN/errors.json b/gitnexus-web/src/locales/zh-CN/errors.json new file mode 100644 index 000000000..47dcb4f8d --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/errors.json @@ -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}}" + } +} diff --git a/gitnexus-web/src/locales/zh-CN/graph.json b/gitnexus-web/src/locales/zh-CN/graph.json new file mode 100644 index 000000000..d65689f8a --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/graph.json @@ -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": "正在加载图表…" + } +} diff --git a/gitnexus-web/src/locales/zh-CN/header.json b/gitnexus-web/src/locales/zh-CN/header.json new file mode 100644 index 000000000..303ba02c8 --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/header.json @@ -0,0 +1,16 @@ +{ + "repositories": "仓库", + "active": "当前", + "reanalyzing": "正在重新分析...", + "reanalyzeRepo": "重新分析 {{repoName}}", + "deleteRepo": "删除 {{repoName}}", + "reanalyzingRepo": "正在重新分析 {{repoName}}:{{message}}", + "analyzeNew": "分析新仓库...", + "searchNodes": "搜索节点...", + "noNodesFound": "未找到“{{query}}”相关节点", + "starIfCool": "觉得不错就点星", + "aiSettings": "AI 设置", + "help": "帮助", + "language": "语言", + "selectLanguage": "选择语言" +} diff --git a/gitnexus-web/src/locales/zh-CN/help.json b/gitnexus-web/src/locales/zh-CN/help.json new file mode 100644 index 000000000..7af348373 --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/help.json @@ -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 按钮打开提问面板。" + } +} diff --git a/gitnexus-web/src/locales/zh-CN/onboarding.json b/gitnexus-web/src/locales/zh-CN/onboarding.json new file mode 100644 index 000000000..6199511f3 --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/onboarding.json @@ -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 URL,GitNexus 会克隆仓库、解析代码,并直接在浏览器中构建实时知识图谱。", + "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": "隐藏(分析继续在后台进行)" + } +} diff --git a/gitnexus-web/src/locales/zh-CN/settings.json b/gitnexus-web/src/locales/zh-CN/settings.json new file mode 100644 index 000000000..cc91c74a9 --- /dev/null +++ b/gitnexus-web/src/locales/zh-CN/settings.json @@ -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 提供商,你的代码不会离开本机。" +} diff --git a/gitnexus-web/src/main.tsx b/gitnexus-web/src/main.tsx index 5549107e9..82f07da51 100644 --- a/gitnexus-web/src/main.tsx +++ b/gitnexus-web/src/main.tsx @@ -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( diff --git a/gitnexus-web/test/setup.ts b/gitnexus-web/test/setup.ts index 860ee70c1..c9a698118 100644 --- a/gitnexus-web/test/setup.ts +++ b/gitnexus-web/test/setup.ts @@ -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(); + 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); }); diff --git a/gitnexus-web/test/unit/agent-history.test.ts b/gitnexus-web/test/unit/agent-history.test.ts new file mode 100644 index 000000000..756534b25 --- /dev/null +++ b/gitnexus-web/test/unit/agent-history.test.ts @@ -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'); + }); +}); diff --git a/gitnexus-web/test/unit/embedding-auto-start.test.ts b/gitnexus-web/test/unit/embedding-auto-start.test.ts new file mode 100644 index 000000000..440396c65 --- /dev/null +++ b/gitnexus-web/test/unit/embedding-auto-start.test.ts @@ -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); + }); +}); diff --git a/gitnexus-web/test/unit/i18n.test.tsx b/gitnexus-web/test/unit/i18n.test.tsx new file mode 100644 index 000000000..916370030 --- /dev/null +++ b/gitnexus-web/test/unit/i18n.test.tsx @@ -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).flatMap(([key, nested]) => + flattenKeys(nested, prefix ? `${prefix}.${key}` : key), + ); +} + +describe('web i18n', () => { + afterEach(async () => { + delete (i18n.getResourceBundle('en', 'settings') as Record | undefined) + ?.crossNamespaceProbe; + delete (i18n.getResourceBundle('zh-CN', 'graph') as Record | 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(); + + 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'); + }); +}); diff --git a/gitnexus-web/test/unit/settings-service.test.ts b/gitnexus-web/test/unit/settings-service.test.ts index 17514725c..a9ded356f 100644 --- a/gitnexus-web/test/unit/settings-service.test.ts +++ b/gitnexus-web/test/unit/settings-service.test.ts @@ -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); + }); +}); diff --git a/gitnexus-web/test/unit/test-setup-storage.test.ts b/gitnexus-web/test/unit/test-setup-storage.test.ts new file mode 100644 index 000000000..8e24aa6d2 --- /dev/null +++ b/gitnexus-web/test/unit/test-setup-storage.test.ts @@ -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(); + }); +}); diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 95141c640..95da20de2 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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": { diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 2bbc32aef..ef5ab8bb8 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -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:'); } diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index 1f1eb9521..87b3fa74f 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -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, +): 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, +): 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, +): void { + cliError(t(key, vars), fields); +} diff --git a/gitnexus/src/cli/detect-changes-format.ts b/gitnexus/src/cli/detect-changes-format.ts new file mode 100644 index 000000000..e3407d342 --- /dev/null +++ b/gitnexus/src/cli/detect-changes-format.ts @@ -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(); +} diff --git a/gitnexus/src/cli/doctor.ts b/gitnexus/src/cli/doctor.ts index 43866ae00..b137d9d50 100644 --- a/gitnexus/src/cli/doctor.ts +++ b/gitnexus/src/cli/doctor.ts @@ -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[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 })}`, + ); }; diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 88ad10592..d8e171947 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -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.'; diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts new file mode 100644 index 000000000..1c4312112 --- /dev/null +++ b/gitnexus/src/cli/help-i18n.ts @@ -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; + +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; + +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 ': 'help.option.analyze.name', + 'analyze|--allow-duplicate-name': 'help.option.analyze.allowDuplicateName', + 'analyze|-v, --verbose': 'help.option.verbose', + 'analyze|--max-file-size ': 'help.option.analyze.maxFileSize', + 'analyze|--worker-timeout ': 'help.option.analyze.workerTimeout', + 'analyze|--wal-checkpoint-threshold ': 'help.option.analyze.walCheckpointThreshold', + 'analyze|--workers ': 'help.option.analyze.workers', + 'analyze|--embedding-threads ': 'help.option.analyze.embeddingThreads', + 'analyze|--embedding-batch-size ': 'help.option.analyze.embeddingBatchSize', + 'analyze|--embedding-sub-batch-size ': 'help.option.analyze.embeddingSubBatchSize', + 'analyze|--embedding-device ': 'help.option.analyze.embeddingDevice', + 'index|-f, --force': 'help.option.index.force', + 'index|--allow-non-git': 'help.option.index.allowNonGit', + 'serve|-p, --port ': 'help.option.port', + 'serve|--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 ': 'help.option.wiki.provider', + 'wiki|--model ': 'help.option.wiki.model', + 'wiki|--base-url ': 'help.option.wiki.baseUrl', + 'wiki|--api-key ': 'help.option.wiki.apiKey', + 'wiki|--api-version ': 'help.option.wiki.apiVersion', + 'wiki|--reasoning-model': 'help.option.wiki.reasoningModel', + 'wiki|--no-reasoning-model': 'help.option.wiki.noReasoningModel', + 'wiki|--concurrency ': 'help.option.wiki.concurrency', + 'wiki|--timeout ': 'help.option.wiki.timeout', + 'wiki|--retries ': 'help.option.wiki.retries', + 'wiki|--gist': 'help.option.wiki.gist', + 'wiki|-v, --verbose': 'help.option.verbose', + 'wiki|--review': 'help.option.wiki.review', + 'wiki|--lang ': 'help.option.wiki.lang', + 'publish|--id ': 'help.option.publish.id', + 'publish|--skip-git': 'help.option.skipGit', + 'query|-r, --repo ': 'help.option.repo.targetOmitOne', + 'query|-c, --context ': 'help.option.query.context', + 'query|-g, --goal ': 'help.option.query.goal', + 'query|-l, --limit ': 'help.option.query.limit', + 'query|--content': 'help.option.content', + 'context|-r, --repo ': 'help.option.repo.target', + 'context|-u, --uid ': 'help.option.context.uid', + 'context|-f, --file ': 'help.option.context.file', + 'context|--content': 'help.option.content', + 'impact|-d, --direction ': 'help.option.impact.direction', + 'impact|-r, --repo ': 'help.option.repo.target', + 'impact|--depth ': 'help.option.impact.depth', + 'impact|--include-tests': 'help.option.impact.includeTests', + 'cypher|-r, --repo ': 'help.option.repo.target', + 'detect-changes|-s, --scope ': 'help.option.detectChanges.scope', + 'detect-changes|-b, --base-ref ': 'help.option.detectChanges.baseRef', + 'detect-changes|-r, --repo ': 'help.option.repo.target', + 'eval-server|-p, --port ': 'help.option.port', + 'eval-server|--host ': 'help.option.evalServer.host', + 'eval-server|--idle-timeout ': '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 ': 'help.option.group.impact.target', + 'group impact|--repo ': 'help.option.group.impact.repo', + 'group impact|--direction ': 'help.option.impact.direction', + 'group impact|--service ': 'help.option.group.impact.service', + 'group impact|--subgroup ': 'help.option.group.impact.subgroup', + 'group impact|--max-depth ': 'help.option.impact.depth', + 'group impact|--cross-depth ': 'help.option.group.impact.crossDepth', + 'group impact|--min-confidence ': 'help.option.group.impact.minConfidence', + 'group impact|--include-tests': 'help.option.impact.includeTests', + 'group impact|--timeout-ms ': 'help.option.group.impact.timeoutMs', + 'group impact|--json': 'help.option.json', + 'group query|--subgroup ': 'help.option.group.query.subgroup', + 'group query|--limit ': 'help.option.group.query.limit', + 'group query|--json': 'help.option.json', + 'group contracts|--type ': 'help.option.group.contracts.type', + 'group contracts|--repo ': 'help.option.group.contracts.repo', + 'group contracts|--unmatched': 'help.option.group.contracts.unmatched', + 'group contracts|--json': 'help.option.json', +} satisfies Record; + +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; +} diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts new file mode 100644 index 000000000..5649bec30 --- /dev/null +++ b/gitnexus/src/cli/i18n/en.ts @@ -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 ', + 'tool.usage.context': 'Usage: gitnexus context [--uid ] [--file ]', + 'tool.usage.impact': 'Usage: gitnexus impact [--direction upstream|downstream]', + 'tool.usage.cypher': 'Usage: gitnexus cypher ', + '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. = hierarchy path (e.g. hr/hiring/backend), = 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 ` ambiguous for the two paths; use -r 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 (0–1)', + '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; diff --git a/gitnexus/src/cli/i18n/index.ts b/gitnexus/src/cli/i18n/index.ts new file mode 100644 index 000000000..67b5962b7 --- /dev/null +++ b/gitnexus/src/cli/i18n/index.ts @@ -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; + +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); + }); +} diff --git a/gitnexus/src/cli/i18n/resources.ts b/gitnexus/src/cli/i18n/resources.ts new file mode 100644 index 000000000..fd1f457e5 --- /dev/null +++ b/gitnexus/src/cli/i18n/resources.ts @@ -0,0 +1,7 @@ +import { en } from './en.js'; +import { zhCN } from './zh-CN.js'; + +export const cliResources = { + en, + 'zh-CN': zhCN, +} as const; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts new file mode 100644 index 000000000..639c36a81 --- /dev/null +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -0,0 +1,228 @@ +import { en } from './en.js'; + +type EnglishMessages = Record; + +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 ] [--file <路径>]', + 'tool.usage.impact': '用法:gitnexus impact <符号名> [--direction upstream|downstream]', + 'tool.usage.cypher': '用法:gitnexus 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': + '向仓库组添加仓库。 = 层级路径(如 hr/hiring/backend), = 注册表中的名称', + '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 ` 产生歧义;请用 -r 消除歧义。', + 'help.option.verbose': '启用详细输出', + 'help.option.analyze.maxFileSize': + '跳过大于该值的文件(KB)。默认:512。硬上限:32768(tree-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 v1:https://{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 model(o1/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': '最小关系置信度(0–1)', + '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; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 17fab2068..6d619a62c 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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 ', 'Number of nodes per embedding batch') .option('--embedding-sub-batch-size ', 'Number of chunks per embedding model call') .option('--embedding-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); diff --git a/gitnexus/src/cli/list.ts b/gitnexus/src/cli/list.ts index 5da9a86f0..20214e8a8 100644 --- a/gitnexus/src/cli/list.ts +++ b/gitnexus/src/cli/list.ts @@ -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(''); } }; diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts index 18d2a340d..02a0cf0c6 100644 --- a/gitnexus/src/cli/remove.ts +++ b/gitnexus/src/cli/remove.ts @@ -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); } }; diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 003e2ce69..55ee8d855 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -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'); diff --git a/gitnexus/src/cli/status.ts b/gitnexus/src/cli/status.ts index b20c0a60a..c56b027d1 100644 --- a/gitnexus/src/cli/status.ts +++ b/gitnexus/src/cli/status.ts @@ -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')}`); }; diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index b40ffdd25..dd15f09c8 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -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 { _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 { if (!queryText?.trim()) { - cliError('Usage: gitnexus query '); + cliErrorKey('tool.usage.query'); process.exit(1); } @@ -94,7 +95,7 @@ export async function contextCommand( }, ): Promise { if (!name?.trim() && !options?.uid) { - cliError('Usage: gitnexus context [--uid ] [--file ]'); + cliErrorKey('tool.usage.context'); process.exit(1); } @@ -119,7 +120,7 @@ export async function impactCommand( }, ): Promise { if (!target?.trim()) { - cliError('Usage: gitnexus impact [--direction upstream|downstream]'); + cliErrorKey('tool.usage.impact'); process.exit(1); } @@ -154,7 +155,7 @@ export async function cypherCommand( }, ): Promise { if (!query?.trim()) { - cliError('Usage: gitnexus cypher '); + 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; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts index 4864d7d38..2fd8fac8e 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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['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 = {}; @@ -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, +): 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, + returnTypes: ReadonlyMap, + 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: . + 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, + returnTypes: ReadonlyMap, + 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, @@ -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): 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, @@ -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, @@ -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( diff --git a/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts b/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts new file mode 100644 index 000000000..ac4034620 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/companion-scopes.ts @@ -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>` 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>(); + +/** 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(); + 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(); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts index f034bafd7..25d085fd5 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts @@ -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(); + +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}`; } diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts index 5a2968055..9209655d8 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/query.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -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" diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts index 591e79bfc..3603c2698 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts index bbd34e160..3ea5bbb6b 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 94fc172dc..9016a68b5 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -76,6 +76,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set boolean; + /** + * Optional predicate to identify members for which dispatch through + * an instance receiver is **invalid at the language level** — i.e. + * calling `instance.member()` would be a compile error or a + * type-system violation, even if a member of that name exists on + * the receiver's class. When provided, the receiver-bound calls + * pass filters out such members at every instance-receiver dispatch + * case (Case 0 compound receiver, Case 3b chain-typebinding, Case 4 + * simple typeBinding, Case 5 value-receiver bridge) so the resolver + * does not emit a misleading `CALLS` edge for a call site the + * language itself would reject. + * + * **Reserved for the "instance receiver is invalid" semantic only.** + * Hooks for languages where static / class-level members are still + * legally callable through an instance (Python `@staticmethod`, + * JavaScript `static` methods accessed via the prototype chain in + * some lookup paths) should return `false` for those members — the + * filter would silently suppress legitimate edges otherwise. The + * canonical fit today is Kotlin companion-object methods, where + * `instance.companionMethod()` is a compile error. + * + * Case 2 (class-name receiver) is intentionally unaffected: a call + * through the class name (`Foo.staticMethod()`) is a legitimate + * dispatch. + * + * Case 0.5 (implicit `this` receiver) currently fires only for + * languages with `resolveThisViaEnclosingClass === true` (C++ at + * time of writing), none of which expose static-only semantics. A + * future language that enables BOTH `resolveThisViaEnclosingClass` + * AND `isStaticOnly` must wire the filter into Case 0.5's chain + * walk too — see the inline note in `receiver-bound-calls.ts`. + * + * Languages without static-only semantics leave this undefined and + * the legacy unfiltered behavior applies (every owned member of the + * receiver class is a dispatch candidate). + */ + readonly isStaticOnly?: (def: SymbolDefinition) => boolean; + /** * Optional predicate to gate free-call fallback emission by caller-side * visibility. When provided, `pickUniqueGlobalCallable` rejects candidates diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index 6a9469693..ac7164c70 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -81,6 +81,7 @@ type ReceiverBoundProviderSubset = Pick< | 'resolveThisViaEnclosingClass' | 'conversionRankFn' | 'constraintCompatibility' + | 'isStaticOnly' >; function normalizeTemplateArgToken(value: string): string { @@ -303,9 +304,28 @@ export function emitReceiverBoundCalls( if (currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; let memberDef: SymbolDefinition | undefined; + // Static-only filter (#1756 / U3): same shape as Case 4's + // chain walk (skip-and-walk-on) but without overload + // narrowing — Case 0 uses `findOwnedMember` directly. When + // an owner's resolved candidate is static-only (Kotlin + // companion-promoted), continue to the next ancestor in + // the MRO chain so a legitimate instance member can bind. + // If the entire chain is static-only, no edge is emitted — + // unlike Case 4, Case 0 does NOT mark the site handled in + // that situation because compound receivers (`a.b.c()`) + // are not pre-emitted by `emitReferencesViaLookup` (the + // reference index has no compound-receiver entry for + // shapes like `Logger.create("a")`), so there's no wrong + // target to suppress. for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; + const candidate = findOwnedMember(ownerId, memberName, model); + if (candidate === undefined) continue; + if (provider.isStaticOnly?.(candidate) === true) { + // Skip static-only candidate; walk to next ancestor. + continue; + } + memberDef = candidate; + break; } if (memberDef !== undefined) { const ok = tryEmitEdge( @@ -334,6 +354,17 @@ export function emitReceiverBoundCalls( // C++ `this->member()` (and same-shape receivers in other OO // languages) should resolve against the enclosing class + MRO // even when there is no explicit `this` typeBinding in scope. + // + // **Static-only filter dependency (#1756 / U3):** this case does + // NOT currently consult `provider.isStaticOnly`. Today it fires + // only for C++ (the sole `resolveThisViaEnclosingClass === true` + // language), which has no static-only semantics. Kotlin — the + // current `isStaticOnly` consumer — leaves `resolveThisVia + // EnclosingClass` unset, so Case 0.5 is dead code for Kotlin + // crossover suppression and U3 leaves it untouched. If any + // future language enables BOTH `resolveThisViaEnclosingClass` + // AND `isStaticOnly`, the chain-walk below MUST adopt the + // skip-and-walk-on filter pattern used by Cases 0, 3b, and 4. if (provider.resolveThisViaEnclosingClass === true && receiverName === 'this') { const enclosingClass = findEnclosingClassDef(site.inScope, scopes); if (enclosingClass !== undefined) { @@ -600,9 +631,22 @@ export function emitReceiverBoundCalls( if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; + // Static-only filter (#1756 / U3): mirrors Case 0's chain + // walk — `findOwnedMember` without overload narrowing. When + // a static-only candidate is found at an ancestor, walk on + // so a legitimate instance member can bind. If the entire + // chain is static-only, no edge is emitted (Case 3b is fed + // by chain-typebinding receivers, not pre-emitted by + // `emitReferencesViaLookup` for compound shapes, so no + // handled-site marker is needed for chain-only-static). for (const ownerId of chain) { - memberDef = findOwnedMember(ownerId, memberName, model); - if (memberDef !== undefined) break; + const candidate = findOwnedMember(ownerId, memberName, model); + if (candidate === undefined) continue; + if (provider.isStaticOnly?.(candidate) === true) { + continue; + } + memberDef = candidate; + break; } if (memberDef !== undefined) { const ok = tryEmitEdge( @@ -658,16 +702,53 @@ export function emitReceiverBoundCalls( const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined; let ambiguous = false; + // Track whether the chain walk filtered out any static-only + // candidates. When it did and the chain ended with no + // legitimate instance member, we mark the site as handled so + // `emitReferencesViaLookup` doesn't re-emit a wrong target + // from the pre-resolved reference index (which has no + // static-only awareness). + let allFilteredStaticOnly = false; + // Static-only filter (#1756 / U2): the filter must run INSIDE + // the chain walk and BEFORE arity narrowing. + // + // INSIDE: when a derived owner's only candidates are static- + // only (Kotlin companion-promoted), `pickFirstNonStaticOnly` + // returns `undefined` and the loop `continue`s to the next + // ancestor in the MRO chain — giving a legitimate ancestor + // instance method a chance to bind. The earlier after-chain + // filter aborted the entire site instead, producing a false + // negative whenever the most-derived owner shadowed an + // ancestor's instance method with a static-only companion + // member. + // + // BEFORE narrowing: filtering survivors of `lookupAllByOwner` + // (rather than survivors of `narrowOverloadCandidates`) means + // a same-arity static + instance pair on one owner doesn't + // collapse to `OVERLOAD_AMBIGUOUS`. Kotlin compile-resolves + // such a pair unambiguously to the instance method because + // companion members are not legal instance-dispatch + // candidates. for (const ownerId of chain) { - const picked = pickOverload(ownerId, memberName, site, model, provider); + const picked = pickFirstNonStaticOnly(ownerId, memberName, site, model, provider); if (picked === OVERLOAD_AMBIGUOUS) { ambiguous = true; break; } + if (picked === STATIC_ONLY_FILTERED) { + // At least one static-only candidate was filtered out at + // this owner; remember so we can mark handled if the + // chain ends with no legitimate match. + allFilteredStaticOnly = true; + continue; + } if (picked !== undefined) { memberDef = picked; break; } + // `picked === undefined` means this owner had no member of + // this name at all. Walk on to the next ancestor in the + // MRO chain. } if (ambiguous) { // Suppress and mark handled so `emitReferencesViaLookup` @@ -676,6 +757,15 @@ export function emitReceiverBoundCalls( handledSites.add(siteKey); continue; } + if (memberDef === undefined && allFilteredStaticOnly) { + // The chain ended with no candidates because every viable + // owner had only static-only members. Mark handled so + // `emitReferencesViaLookup` doesn't re-emit a wrong target + // from the pre-resolved reference index. Parallels the old + // after-chain `isStaticOnly` suppression block. + handledSites.add(siteKey); + continue; + } if (memberDef !== undefined) { // For read/write ACCESSES, mirror the legacy DAG's reason // convention so consumers asserting `reason === 'write'` @@ -744,6 +834,19 @@ export function emitReceiverBoundCalls( continue; } if (picked !== undefined) { + // Static-only filter (#1756 / U3): unlike Case 4 there's no + // MRO chain to walk here — Case 5 dispatches on a single + // owner via `pickOverload`. When the picked candidate is + // static-only (Kotlin companion-promoted), suppress the + // edge entirely and mark the site handled so + // `emitReferencesViaLookup` doesn't re-emit a wrong target + // from the pre-resolved reference index. Matches the after- + // chain handled-marker semantic used by Case 4's + // all-filtered fall-through. + if (provider.isStaticOnly?.(picked) === true) { + handledSites.add(siteKey); + continue; + } const reason = site.kind === 'write' || site.kind === 'read' ? site.kind @@ -822,3 +925,95 @@ function pickOverload( * collapses distinct types in arity-metadata). */ export const OVERLOAD_AMBIGUOUS = Symbol('overload-ambiguous'); + +/** + * Sentinel returned by `pickFirstNonStaticOnly` when the only candidates + * at the queried owner were filtered out by `provider.isStaticOnly`. Lets + * the Case 4 chain walk distinguish "owner had no member of this name" + * (return `undefined`, continue silently) from "owner had only static- + * only members" (return this sentinel, continue and remember so the + * post-chain handled-marker logic can suppress wrong-target re-emission + * from `emitReferencesViaLookup`). See #1756 / remediation plan U2. + */ +const STATIC_ONLY_FILTERED = Symbol('static-only-filtered'); + +/** + * Receiver-bound member lookup that filters static-only candidates BEFORE + * arity narrowing. Wraps the raw `lookupAllByOwner` → `narrowOverloadCandidates` + * pipeline so: + * + * 1. Candidates flagged by `provider.isStaticOnly` (Kotlin companion- + * promoted methods today) never enter the narrowing stage. A same- + * name same-arity static + instance pair on one owner therefore does + * NOT collapse to `OVERLOAD_AMBIGUOUS` — the instance member wins + * unambiguously, matching Kotlin's compile-time resolution. + * 2. The chain walk in `emitReceiverBoundCalls` Case 4 can fall through + * to ancestors when only static-only candidates exist at the + * most-derived owner (returns `STATIC_ONLY_FILTERED`), rather than + * aborting the site as the previous after-chain filter did. + * + * Returns: + * - `undefined` — no member with this name on this owner; chain walk + * continues silently. + * - `STATIC_ONLY_FILTERED` — at least one candidate existed but every + * one was static-only; chain walk continues and remembers so the + * post-chain handled-marker can fire if no ancestor binds. + * - `OVERLOAD_AMBIGUOUS` — narrowing on the surviving non-static + * candidates left >1 ambiguous match; chain walk aborts and the + * site is marked handled (existing sentinel handling preserved). + * - `SymbolDefinition` — single survivor (the chosen target). + * + * See remediation plan `docs/plans/2026-05-22-002-fix-lang-kotlin-1782- + * remediation-plan.md` § U2 for the full rationale. + */ +function pickFirstNonStaticOnly( + ownerId: string, + memberName: string, + site: ParsedFile['referenceSites'][number], + model: SemanticModel, + provider: ReceiverBoundProviderSubset, +): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | typeof STATIC_ONLY_FILTERED | undefined { + const rawOverloads = model.methods.lookupAllByOwner(ownerId, memberName); + if (rawOverloads.length === 0) { + // Non-callable member (field / property / variable) — ACCESSES + // write/read sites target these too. Static-only filtering doesn't + // apply to fields, so delegate straight to `lookupFieldByOwner`. + return model.fields.lookupFieldByOwner(ownerId, memberName); + } + const isStaticOnly = provider.isStaticOnly; + let overloads: readonly SymbolDefinition[] = rawOverloads; + let filteredAny = false; + if (isStaticOnly !== undefined) { + const survivors: SymbolDefinition[] = []; + for (const candidate of rawOverloads) { + if (isStaticOnly(candidate) === true) { + filteredAny = true; + continue; + } + survivors.push(candidate); + } + overloads = survivors; + } + if (overloads.length === 0) { + // Every candidate was static-only; the caller (Case 4 chain walk) + // should walk on to the next owner AND remember that filtering + // happened so it can mark the site handled if the whole chain + // ends with no legitimate match. + return filteredAny ? STATIC_ONLY_FILTERED : undefined; + } + if (overloads.length === 1) return overloads[0]; + + const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, + conversionRankFn: provider.conversionRankFn, + constraintCompatibility: provider.constraintCompatibility, + }); + // Same ambiguity handling as `pickOverload`: when normalization + // collapses the surviving overloads into a single bucket (e.g., C++ + // `f(int)`/`f(long)` normalized to `['int']`), suppress rather than + // arbitrarily picking. When narrowing leaves >1 distinct candidate + // with no tie-breaker, suppress for the same reason. + if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS; + if (candidates.length > 1) return OVERLOAD_AMBIGUOUS; + return candidates[0] ?? overloads[0]; +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt new file mode 100644 index 000000000..1ee3a27aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/app/Main.kt @@ -0,0 +1,13 @@ +package app + +import logging.Logger + +fun useCrossFileFactory() { + val l = Logger.create("app") + l.log("hello") +} + +fun useCrossFileCrossover() { + val l = Logger("explicit") + l.create("nope") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt new file mode 100644 index 000000000..50e4ec0df --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-cross-file/logging/Logger.kt @@ -0,0 +1,8 @@ +package logging + +class Logger(val name: String) { + fun log(msg: String) {} + companion object { + fun create(name: String): Logger = Logger(name) + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt new file mode 100644 index 000000000..ee95998aa --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-mro-shadow/App.kt @@ -0,0 +1,52 @@ +// Fixture for U2 (#1756 remediation plan): MRO shadowing and same-arity +// static+instance collision in receiver-bound dispatch. +// +// Three scenarios: +// 1. `Child` has only a companion `foo` AND extends `Base` whose +// instance `foo` is the legitimate target. The static-only filter +// must run INSIDE the MRO chain walk so the chain falls through to +// `Base.foo` instead of aborting on `Child.Companion.foo`. +// 2. `ChildWithInstance` has BOTH an instance `foo` AND a same-arity +// companion `foo`. The filter must run BEFORE arity narrowing so +// the pair doesn't collapse to OVERLOAD_AMBIGUOUS — Kotlin compile- +// resolves this unambiguously to the instance method because +// companion members are not legal instance-dispatch candidates. +// 3. `Standalone` only has a companion `foo` (no instance ancestor). +// The chain walk filters every owner; no edge should be emitted. +open class Base { + open fun foo() {} +} + +class Child : Base() { + companion object { + fun foo(): Child = Child() + } +} + +class ChildWithInstance : Base() { + fun foo(): Int = 0 + companion object { + fun foo(): ChildWithInstance = ChildWithInstance() + } +} + +class Standalone { + companion object { + fun foo(): Standalone = Standalone() + } +} + +// Should resolve to Base.foo via MRO chain skip past static-only Child.foo. +fun useChild(c: Child) { + c.foo() +} + +// Should resolve to ChildWithInstance.foo (instance, not companion, not Base). +fun useChildWithInstance(c: ChildWithInstance) { + c.foo() +} + +// Should emit no edge — entire chain is static-only. +fun useStandalone(s: Standalone) { + s.foo() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt new file mode 100644 index 000000000..3750cc339 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-named/App.kt @@ -0,0 +1,60 @@ +// Fixture for U4 (#1756 remediation plan): named companions, companions +// containing nested classes, and inner-class-plus-companion mixes. +// +// The pre-U4 `populateCompanionMembersOnEnclosingClass` guard used +// `parent.ownedDefs.some(isClassLike) → continue`, which silently +// bypassed two real shapes: +// - named companions (`companion object Helper { ... }`) — the +// `Helper` `type_identifier` registered as a class-like def on +// the companion scope, hiding the companion-ness; and +// - companions containing nested classes (`companion object { +// class Token; fun create() }`) — the nested class def lived on +// the companion scope, again hiding the companion-ness. +// Both bypasses left companion methods unpromoted and unmarked, +// breaking class-name dispatch (`Outer.create()`) and crossover +// suppression (`outer.create()`) for those shapes. + +class Outer { + fun greet() {} + companion object Helper { + fun create(): Outer = Outer() + } +} + +class WithNested { + companion object { + class Token + fun forge(): WithNested = WithNested() + } +} + +class InnerClassAndCompanion { + class Inner + companion object { + fun build(): InnerClassAndCompanion = InnerClassAndCompanion() + } +} + +// Happy path: named companion dispatched through the class name. +fun useNamed() { Outer.create() } + +// Crossover (adversarial): `o.create()` on an instance is a compile +// error in Kotlin — companion-object methods can only be called via +// the class name. Must emit no CALLS edge. +fun useNamedCrossover() { + val o = Outer() + o.create() +} + +// Happy path: companion containing a nested class — the companion +// method should still be promoted onto the enclosing class. +fun useNested() { WithNested.forge() } + +// Mix: outer class has BOTH a nested class AND a companion object. +// The companion method is promoted (new behavior); the nested class +// stays owned by its own scope (existing behavior). +fun useInnerMix() { + InnerClassAndCompanion.build() + val i = InnerClassAndCompanion() + i.build() +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt new file mode 100644 index 000000000..bb2a5773b --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-other-cases/App.kt @@ -0,0 +1,91 @@ +// Fixture for U3 (#1756 remediation plan): extend the `isStaticOnly` +// crossover filter to receiver-bound dispatch cases beyond Case 4. +// +// Three target cases: +// - Case 0 (compound receiver): the call site's `receiverName` +// contains `.` or `(`, so `resolveCompoundReceiverClass` is used +// to resolve the receiver's class. e.g. `Logger.create("a").create("b")` +// — the OUTER `.create("b")` has compound receiver `Logger.create("a")` +// and `resolveCompoundReceiverClass` resolves it to `Logger`. +// `findOwnedMember(Logger, "create")` then returns the static-only +// companion-promoted `create`. Pre-U3, Case 0 would emit a CALLS +// edge to the companion `create`. Post-U3, the static-only filter +// suppresses the edge. +// - Case 3b (chain-typebinding): the call site's receiver has a +// typeBinding whose `rawName` is a dotted chain expression (e.g., +// a chain-bound value). For Kotlin, this fires when an expression +// produces a typeBinding that walks through chained receivers. +// - Case 5 (value-receiver bridge): the receiver is a Const/Variable +// without a class-like or typeBinding match; resolved via +// `findValueBindingInScope` + `pickOverload` on a single owner. +// +// The legitimate edges (e.g., `Logger.create("a")` via class-name receiver) +// must continue to emit, so we test both the crossover (zero edges) AND +// the happy paths (exact-count edges). + +class Logger { + fun log(s: String) {} + companion object { + fun create(name: String): Logger = Logger() + } +} + +class Service { + fun perform() {} + companion object { + fun build(): Service = Service() + } +} + +class Repo { + fun getAll(): List = listOf() +} + +// Case 0 (compound receiver) — outer `.create("b")` on a Logger instance +// returned by `Logger.create("a")`. The receiverName is the compound +// expression `Logger.create("a")` which resolves to `Logger`; then +// looking up `create` on Logger returns the companion-promoted static- +// only `create`. That edge must be suppressed. +// +// The INNER `Logger.create("a")` is a Case 2 class-name receiver — it +// resolves through `findClassBindingInScope` and `findOwnedMember` +// returns the companion-promoted `create` (legitimate). Companion +// dispatch through the class name is the canonical happy path; that +// edge must emit. +fun useCompoundCrossover() { + Logger.create("a").create("b") +} + +// Case 3b (chain-typebinding) — `services` has a chain typeBinding for +// `Service` (inferred via the chain from `r.getAll()`), so calling +// `.build()` on `services.first()` looks up `build` on `Service` +// through Case 3b's `resolveCompoundReceiverClass(rawName, ...)` path +// where `rawName` contains a dot from the chain. The static-only +// companion `build` must be suppressed. +// +// The legitimate edge in this function is `r.getAll()`; that edge +// must emit (resolves through Case 4 simple-typeBinding `r: Repo`). +fun useChainTypeBindingCrossover() { + val r = Repo() + val services = r.getAll() + services.first().build() +} + +// Case 5 (value-receiver bridge) — `l` is a Const/Variable whose +// typeBinding would normally route to Case 4. Listed here as a +// defensive wire-up site: Kotlin annotations make Case 4 the +// primary path even for `val l: Logger = ...`, but adding the +// filter at Case 5 preserves contract symmetry for any future +// shape where the value-binding is hit (e.g., object-literal-like +// receivers via cross-language conventions). +// +// We split the legitimate `Logger.create(...)` setup into a helper +// function so the crossover assertion can target the +// `useValueReceiverCrossover → create` edge count directly without +// having to subtract the legitimate setup edge. +fun makeLoggerForCrossover(): Logger = Logger.create("v") + +fun useValueReceiverCrossover() { + val l = makeLoggerForCrossover() + l.create("nope") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt new file mode 100644 index 000000000..3e9e592fd --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-companion-vs-instance/App.kt @@ -0,0 +1,35 @@ +class Logger(val name: String) { + fun log(message: String): String { + return "$name: $message" + } + + companion object { + fun create(name: String): Logger { + return Logger(name) + } + } +} + +// Companion call via the class name — must resolve to Logger.create(). +fun makeLogger() { + val logger = Logger.create("app") + // Instance call via a value receiver — must resolve to the instance log(), + // not to the companion's create(). + logger.log("hello") +} + +// Direct instance call on a freshly-constructed Logger — must resolve to +// the instance log(). +fun directLog() { + val logger = Logger("direct") + logger.log("hi") +} + +// Adversarial call: `logger.create(...)` is invalid Kotlin (you can't call a +// companion-object method through an instance receiver — it's a compile +// error). A code-intelligence tool that emits an edge here would be telling +// readers the call resolves when it doesn't. Test asserts no CALLS edge. +fun crossover() { + val logger = Logger("x") + logger.create("nope") +} diff --git a/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt b/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt new file mode 100644 index 000000000..c8db3bd34 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/kotlin-lambda-scopes/App.kt @@ -0,0 +1,47 @@ +// Kotlin lambda scopes fixture — issue #1757. +// +// Each function exercises a different lambda-binding shape; assertions +// in kotlin.test.ts verify the lambda parameter / implicit `it` binds +// only inside the lambda body and resolves to the correct stdlib idiom. + +class User(val name: String) { + fun save() {} + fun isActive(): Boolean = true +} + +class Post(val title: String) { + fun like() {} +} + +fun println(message: String) {} + +fun explicitParam(users: List) { + users.forEach { user -> user.save() } +} + +fun implicitIt(users: List) { + users.forEach { it.save() } +} + +fun chained(users: List) { + users.map { it.name }.forEach { name -> println(name) } +} + +fun nested(users: List, posts: Map>) { + users.forEach { user -> + posts[user]?.forEach { it.like() } + } +} + +fun letScope(user: User?) { + user?.let { it.save() } +} + +fun applyScope(user: User) { + user.apply { save() } +} + +fun outerItShadow(users: List) { + val it = "outer" + users.forEach { it.save() } +} diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index c7dff0947..74152c6ea 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -124,6 +124,82 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // #1756 companion-vs-instance dispatch: the registry-primary path + // suppresses `instance.companionMethod()` via `ScopeResolver. + // isStaticOnly` (see `isKotlinStaticOnly` + the Case 4 filter in + // `receiver-bound-calls.ts`). The legacy DAG has no equivalent + // static-only gate — companion methods promoted onto the outer + // class are also returned by `lookupMethodByOwner` when the + // receiver is an instance, producing a false `CALLS` edge. Scope- + // resolver-only correctness win; backporting to legacy is out of + // scope per the migration policy (the bug stops mattering once + // Kotlin enters `MIGRATED_LANGUAGES` and legacy stops running). + 'crossover() invoking logger.create() on an instance emits NO CALLS edge', + // #1756 / U2 (remediation plan 2026-05-22-002) MRO shadow tests: + // the registry-primary path filters static-only candidates INSIDE + // the Case-4 MRO chain walk (`pickFirstNonStaticOnly` in + // `receiver-bound-calls.ts`), so a derived class whose only + // member is a companion-promoted static method falls through to + // an ancestor's legitimate instance method; if no ancestor has + // an instance method, no CALLS edge is emitted. The legacy DAG + // returns the static-only companion method via + // `lookupMethodByOwner` on the most-derived owner and emits a + // false `CALLS` edge to it. Same scope-resolver-only correctness + // class as the bare `crossover()` test above; backporting is out + // of scope per the migration policy. + 'useChild() falls through static-only Child.foo to Base.foo', + 'useChild() does NOT emit an edge to the companion-promoted Child.foo', + 'useStandalone() emits no CALLS edge (entire chain is static-only)', + // #1757 lambda scopes: the registry-primary path creates a Block + // scope per `lambda_literal` and synthesizes scoped type-bindings + // for the lambda parameter / implicit `it` (see + // `synthesizeKotlinLambdaBindings` in `kotlin/captures.ts` plus + // the `@type-binding.lambda-scoped` gate in + // `kotlinBindingScopeFor`). This lets the body's call-resolution + // chain see the chain-typebinding for the lambda's enclosing + // call (`users.map { it.name }.forEach { name -> println(name) }`) + // and emit the `chained -> println` edge correctly. The legacy DAG + // has no lambda-body scope and no per-lambda type-binding + // synthesis; calls inside lambdas resolve against the enclosing + // function scope only, so the `name` parameter chain inside a + // chained-receiver forEach lambda doesn't carry the right binding + // and the call-extractor never emits the CALLS edge. Scope- + // resolver-only correctness win; backporting requires re-modeling + // lambda bodies as their own scopes in `call-processor.ts`, which + // is out of scope per migration policy. + 'chained: println(name) inside forEach resolves to file-scope println', + // #1756 / U4 (remediation plan 2026-05-22-002) named-companion + // crossover: the registry-primary path stamps the static-only + // marker on named-companion methods (via the new `@scope.companion` + // marker capture and the updated `populateCompanionMembersOn + // EnclosingClass` guard), so `instance.namedCompanionMethod()` + // is filtered out at the `isStaticOnly` hook. The legacy DAG has + // no static-only gate AND no named-companion-aware owner + // promotion — it both leaves the named-companion method owned + // by `Helper` AND emits a crossover edge when the call site uses + // an instance receiver. Same scope-resolver-only correctness + // class as the bare `crossover()` test; backporting is out of + // scope per the migration policy. + 'useNamedCrossover: o.create() emits NO CALLS edge to create', + // #1756 / U3 (remediation plan 2026-05-22-002) other-receiver + // crossover: the registry-primary path applies the `isStaticOnly` + // filter across Cases 0 (compound receiver), 3b (chain-typebinding), + // and 5 (value-receiver bridge) of `receiver-bound-calls.ts`. For + // the U3 fixture `kotlin-companion-other-cases/App.kt`, the + // chain-typebinding crossover (`services.first().build()` on a + // chain whose receiver type resolves through the legacy DAG's + // unfiltered lookup) and the value-receiver crossover + // (`l.create("nope")` where the legacy DAG binds `l` directly + // via its receiver-resolution path) both emit false `CALLS` + // edges to the companion-promoted static-only members. The + // legacy DAG has no `isStaticOnly`-equivalent hook, so these + // edges leak. Same scope-resolver-only correctness class as the + // bare `crossover()` test and the U2 MRO-shadow tests above; + // backporting is out of scope per the migration policy. + 'useChainTypeBindingCrossover: services.first().build() emits NO CALLS edge to build', + 'useValueReceiverCrossover: l.create("nope") emits NO CALLS edge to create', + ]), cpp: new Set([ // The legacy DAG path has no scope-aware filtering on the global // free-call fallback, so `#include`d headers still leak class diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index d70a240d1..f2d241950 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -2063,3 +2063,582 @@ describe('Kotlin User implements Validator — interface default method (SM-11)' expect(validateCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// #1756: companion-object members must dispatch through the class name, +// never through an instance receiver. +// +// `Logger.create(...)` — companion call via the class name — resolves to the +// companion's `create`. `logger.log(...)` and `logger.create(...)` — calls +// through an INSTANCE — must resolve to the instance method and NOT cross +// over to the companion-only `create`. +// --------------------------------------------------------------------------- + +describe('Kotlin companion vs instance member dispatch (#1756)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-companion-vs-instance'), + () => {}, + ); + }, 60000); + + it('detects Logger class with companion-only create() and instance log()', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Logger'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('create'); + expect(methods).toContain('log'); + }); + + it('Logger.create("app") resolves to the companion create', () => { + const calls = getRelationships(result, 'CALLS'); + const createCall = calls.find((c) => c.source === 'makeLogger' && c.target === 'create'); + expect(createCall).toBeDefined(); + expect(createCall!.targetFilePath).toBe('App.kt'); + }); + + it('logger.log("hello") resolves to the instance log, NOT companion create', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find((c) => c.source === 'makeLogger' && c.target === 'log'); + expect(logCall).toBeDefined(); + expect(logCall!.targetFilePath).toBe('App.kt'); + }); + + it('makeLogger emits exactly 2 CALLS edges — Logger.create and logger.log, no extras', () => { + const calls = getRelationships(result, 'CALLS'); + const fromMakeLogger = calls.filter((c) => c.source === 'makeLogger'); + expect(fromMakeLogger.length).toBe(2); + }); + + it('logger.log() in directLog() resolves to the instance log on App.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const logCall = calls.find((c) => c.source === 'directLog' && c.target === 'log'); + expect(logCall).toBeDefined(); + expect(logCall!.targetFilePath).toBe('App.kt'); + }); + + it('crossover() invoking logger.create() on an instance emits NO CALLS edge', () => { + // `logger.create(...)` on an instance is a compile error in Kotlin — + // companion-object methods can only be called through the class name. + // The resolver must NOT emit a CALLS edge for this call site (#1756). + // Registry-primary path filters via `ScopeResolver.isStaticOnly`; the + // legacy DAG has a pre-existing crossover bug, so this assertion is + // marked as a legacy expected failure in + // `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin` (helpers.ts). + const calls = getRelationships(result, 'CALLS'); + const crossover = calls.find((c) => c.source === 'crossover' && c.target === 'create'); + expect(crossover).toBeUndefined(); + }); + + // #1756 / U7 edge-type completeness: in addition to the CALLS absence + // asserted above, the crossover() function must NOT leak any non-CALLS + // edge from `crossover` to the companion-promoted `create`. Without + // these assertions a hypothetical future regression that wired the + // crossover through a `USES` (type-reference) or `ACCESSES` (property- + // read) edge would silently pass the CALLS-only check while still + // misrepresenting the dispatch to users / consumers of the graph. + // Both `USES` and `ACCESSES` are valid `RelationshipType` values in + // `gitnexus-shared/src/graph/types.ts`. + it('crossover() emits NO USES edges to create (edge-type completeness)', () => { + const usesEdges = getRelationships(result, 'USES').filter( + (c) => c.source === 'crossover' && c.target === 'create', + ); + expect(usesEdges.length).toBe(0); + }); + + it('crossover() emits NO ACCESSES edges to create (edge-type completeness)', () => { + const accessesEdges = getRelationships(result, 'ACCESSES').filter( + (c) => c.source === 'crossover' && c.target === 'create', + ); + expect(accessesEdges.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Kotlin lambda scopes (#1757) +// +// Lambda bodies create a new lexical scope in which the lambda's parameter +// list (or implicit `it`) binds. Call sites inside the lambda body must +// resolve through these bindings; implicit `it` must be visible only inside +// the lambda; nested lambdas must shadow deterministically. Covers stdlib +// idioms: `forEach`, `map`, `filter`, `let`, `apply`, `also`, `with`, +// `takeIf`, `use`. +// --------------------------------------------------------------------------- + +describe('Kotlin lambda scopes (#1757)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-lambda-scopes'), () => {}); + }, 60000); + + it('detects User and Post classes plus save/like methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Class')).toContain('Post'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('like'); + }); + + // Happy path: explicit parameter + it('explicitParam: user.save() inside forEach resolves to User.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter((c) => c.source === 'explicitParam' && c.target === 'save'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toBe('App.kt'); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const userSave = hasMethod.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + expect(saveCalls[0].rel.targetId).toBe(userSave!.rel.targetId); + }); + + // Happy path: implicit `it` + it('implicitIt: it.save() inside forEach resolves to User.save via implicit it', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter((c) => c.source === 'implicitIt' && c.target === 'save'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Happy path: chain — outer lambda's `it.name` does not cross-bind + it('chained: emits no erroneous save/like edges (inner it bound to User, not Post)', () => { + const calls = getRelationships(result, 'CALLS'); + const erroneousSave = calls.find((c) => c.source === 'chained' && c.target === 'save'); + const erroneousLike = calls.find((c) => c.source === 'chained' && c.target === 'like'); + expect(erroneousSave).toBeUndefined(); + expect(erroneousLike).toBeUndefined(); + }); + + it('chained: println(name) inside forEach resolves to file-scope println', () => { + const calls = getRelationships(result, 'CALLS'); + const printlnCalls = calls.filter((c) => c.source === 'chained' && c.target === 'println'); + expect(printlnCalls.length).toBe(1); + expect(printlnCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Edge case: nested lambdas — inner `it` is Post, outer `user` is User + it('nested: inner it.like() resolves to Post.like (NOT User.like)', () => { + const calls = getRelationships(result, 'CALLS'); + const likeCalls = calls.filter((c) => c.source === 'nested' && c.target === 'like'); + expect(likeCalls.length).toBe(1); + expect(likeCalls[0].targetFilePath).toBe('App.kt'); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const postLike = hasMethod.find((e) => e.source === 'Post' && e.target === 'like'); + expect(postLike).toBeDefined(); + expect(likeCalls[0].rel.targetId).toBe(postLike!.rel.targetId); + }); + + it('nested: emits NO save() CALLS edge (outer `user` parameter is not called)', () => { + const calls = getRelationships(result, 'CALLS'); + const wrongSave = calls.find((c) => c.source === 'nested' && c.target === 'save'); + expect(wrongSave).toBeUndefined(); + }); + + // Edge case: `let` exposes the receiver as `it` + it('letScope: it.save() inside let { } resolves to User.save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter((c) => c.source === 'letScope' && c.target === 'save'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Edge case: shadowing — inner `it` (User) beats outer `val it = "outer"` + it('outerItShadow: inner it.save() resolves to User.save (outer val it is shadowed)', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter((c) => c.source === 'outerItShadow' && c.target === 'save'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toBe('App.kt'); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const userSave = hasMethod.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + expect(saveCalls[0].rel.targetId).toBe(userSave!.rel.targetId); + }); +}); + +// --------------------------------------------------------------------------- +// #1756 / U2 remediation: the `isStaticOnly` filter must run INSIDE the MRO +// chain walk (so static-only candidates fall through to ancestor instance +// methods) and BEFORE arity narrowing (so a same-name same-arity static + +// instance pair on the same owner doesn't collapse to OVERLOAD_AMBIGUOUS). +// +// Three scenarios in `kotlin-companion-mro-shadow/App.kt`: +// - `useChild(c: Child)` calls `c.foo()` — Child has only a companion +// `foo` but extends Base whose instance `foo` is the legitimate target. +// Expected: exactly one CALLS edge `useChild → Base.foo`, no edge to +// the companion-promoted `Child.foo`. +// - `useChildWithInstance(c: ChildWithInstance)` calls `c.foo()` — +// ChildWithInstance has BOTH an instance `foo(): Int` AND a same-arity +// companion `foo(): ChildWithInstance`. Expected: exactly one CALLS +// edge to the instance `foo` on ChildWithInstance (not the companion, +// not Base). +// - `useStandalone(s: Standalone)` calls `s.foo()` — Standalone has +// only a companion `foo` and no instance ancestor with the same +// name. Expected: no CALLS edge. +// --------------------------------------------------------------------------- + +describe('Kotlin companion vs instance MRO shadowing (#1756 / U2)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-companion-mro-shadow'), + () => {}, + ); + }, 60000); + + it('useChild() falls through static-only Child.foo to Base.foo', () => { + const calls = getRelationships(result, 'CALLS'); + const fromUseChild = calls.filter((c) => c.source === 'useChild'); + expect(fromUseChild.length).toBe(1); + expect(fromUseChild[0].target).toBe('foo'); + expect(fromUseChild[0].targetFilePath).toBe('App.kt'); + // The target should be the Base instance `foo`, not the companion + // `foo` promoted onto Child. We assert by checking the target node's + // qualified name resolves under Base (via HAS_METHOD). + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const baseFoo = hasMethod.find( + (e) => e.source === 'Base' && e.target === 'foo' && e.targetFilePath === 'App.kt', + ); + expect(baseFoo).toBeDefined(); + expect(fromUseChild[0].rel.targetId).toBe(baseFoo!.rel.targetId); + }); + + it('useChild() does NOT emit an edge to the companion-promoted Child.foo', () => { + const calls = getRelationships(result, 'CALLS'); + const fromUseChild = calls.filter((c) => c.source === 'useChild'); + // No edge whose target is the Child companion `foo`. We identify it + // by HAS_METHOD: Child → foo (the companion `foo` is promoted onto + // Child as the enclosing class). If such an edge existed, useChild + // would target it; assert it does not. + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const childFoo = hasMethod.find( + (e) => e.source === 'Child' && e.target === 'foo' && e.targetFilePath === 'App.kt', + ); + if (childFoo !== undefined) { + const wrongEdge = fromUseChild.find((c) => c.rel.targetId === childFoo.rel.targetId); + expect(wrongEdge).toBeUndefined(); + } + }); + + it('useChildWithInstance() resolves to the instance foo on ChildWithInstance', () => { + const calls = getRelationships(result, 'CALLS'); + const fromUseCWI = calls.filter((c) => c.source === 'useChildWithInstance'); + expect(fromUseCWI.length).toBe(1); + expect(fromUseCWI[0].target).toBe('foo'); + expect(fromUseCWI[0].targetFilePath).toBe('App.kt'); + // Assert the target is ChildWithInstance.foo (the instance method), + // not the companion `foo` (which also targets ChildWithInstance as + // the promoted owner but is static-only) and not Base.foo. + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const baseFoo = hasMethod.find( + (e) => e.source === 'Base' && e.target === 'foo' && e.targetFilePath === 'App.kt', + ); + expect(baseFoo).toBeDefined(); + expect(fromUseCWI[0].rel.targetId).not.toBe(baseFoo!.rel.targetId); + }); + + it('useStandalone() emits no CALLS edge (entire chain is static-only)', () => { + const calls = getRelationships(result, 'CALLS'); + const fromUseStandalone = calls.filter((c) => c.source === 'useStandalone'); + expect(fromUseStandalone.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// #1756 / U4 remediation: named companions and companions containing nested +// classes must promote their methods onto the enclosing class AND stamp the +// static-only marker (so crossover via instance receiver is suppressed). +// +// Pre-U4 `populateCompanionMembersOnEnclosingClass` used the heuristic +// `parent.ownedDefs.some(isClassLike) → continue`, which silently bypassed: +// - named companions (`companion object Helper { ... }`) — `Helper` +// looked like a class-like def on the companion scope; and +// - companions containing nested classes (`companion object { class +// Token; fun create() }`) — the nested class def lived on the +// companion scope. +// U4 replaces the heuristic with a parser-layer marker capture +// (`@scope.companion`), so any `companion_object` AST node is +// unambiguously identified as a companion regardless of contents. +// --------------------------------------------------------------------------- + +describe('Kotlin named companion + nested-class companions (#1756 / U4)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'kotlin-companion-named'), () => {}); + }, 60000); + + it('detects Outer / WithNested / InnerClassAndCompanion classes and create / forge / build methods', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Outer'); + expect(classes).toContain('WithNested'); + expect(classes).toContain('InnerClassAndCompanion'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('create'); + expect(methods).toContain('forge'); + expect(methods).toContain('build'); + }); + + // Happy path (named companion): Outer.create() resolves through the + // enclosing class name. Pre-U4 this emitted zero edges because the + // named-companion `create` was owned by `Helper`, not `Outer`. + it('useNamed: Outer.create() resolves to exactly 1 CALLS edge → create', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCalls = calls.filter((c) => c.source === 'useNamed' && c.target === 'create'); + expect(saveCalls.length).toBe(1); + expect(saveCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Crossover suppression (named): the instance-receiver `o.create()` is a + // compile error in Kotlin — companion methods are not legal instance- + // dispatch candidates. Pre-U4 this emitted a false edge because the + // static-only marker was never stamped on the named-companion `create`. + it('useNamedCrossover: o.create() emits NO CALLS edge to create', () => { + const calls = getRelationships(result, 'CALLS'); + const crossover = calls.filter( + (c) => c.source === 'useNamedCrossover' && c.target === 'create', + ); + expect(crossover.length).toBe(0); + }); + + // Happy path (companion containing a nested class): WithNested.forge() + // resolves through the enclosing class name. Pre-U4 the nested + // `class Token` made the companion look like a regular class to the + // heuristic, so `forge` was never promoted onto `WithNested`. + it('useNested: WithNested.forge() resolves to exactly 1 CALLS edge → forge', () => { + const calls = getRelationships(result, 'CALLS'); + const forgeCalls = calls.filter((c) => c.source === 'useNested' && c.target === 'forge'); + expect(forgeCalls.length).toBe(1); + expect(forgeCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Mix (inner-class + companion): the class-name call resolves to the + // promoted companion method; the instance-receiver crossover emits + // nothing. Verifies that the U4 fix does NOT misclassify a regular + // class with a sibling companion as a companion itself. + it('useInnerMix: exactly 1 CALLS edge to build (class-name call resolves; crossover suppressed)', () => { + const calls = getRelationships(result, 'CALLS'); + const buildCalls = calls.filter((c) => c.source === 'useInnerMix' && c.target === 'build'); + expect(buildCalls.length).toBe(1); + expect(buildCalls[0].targetFilePath).toBe('App.kt'); + }); +}); + +// --------------------------------------------------------------------------- +// #1756 / U6 remediation: cross-file companion factory dispatch. +// +// `Logger.create(...)` — a companion-object factory call via the class name — +// must resolve to the companion's `create` even when `Logger` is imported +// from a different file. The probe in U6 (2026-05-22) established that +// Case 2 (class-name receiver) dispatch traverses module boundaries +// correctly: `Logger.create()` in `app/Main.kt` resolves to +// `Logger.create` in `logging/Logger.kt` via the import-resolved +// receiver chain. +// +// What does NOT cross module boundaries today is the chain-typebinding: +// `val l = Logger.create(...)` followed by `l.log(...)` only resolves +// when `Logger` is defined in the same file as the call site. Two +// reasons: +// 1. `collectKotlinClassMembers` in `captures.ts` runs per-file, so +// the Tier-2 lookup that drives chain-typebinding return-type +// inference (`inferKotlinNavigationCallReturnType` → +// `classMembers.methods.get("Logger")?.get("create")`) returns +// undefined when `Logger` is imported. The local typeBinding +// `l → ?` is never emitted in the importer scope. +// 2. The chain-follow mirror in `propagateImportedReturnTypes` (#1759) +// treats dot-form rawNames like `Logger.create` as terminal, so it +// cannot bridge `l → Logger.create → Logger` cross-file either. +// +// Closing this gap requires either a workspace-level Kotlin class-member +// index (paralleling the `scanJavaImports` / `scanPythonImports` +// patterns) or refactoring `followChainPostFinalize` to look up dot-form +// bindings against a cross-file return-type map. Both are substantial +// enough that the U6 plan's "fix looks substantial" branch fires — +// neither qualifies as the additive `imported-return-types.ts` +// extension the U6 approach (a) allows. Deferred to a follow-up issue +// tracking cross-file companion factory chain binding alongside the +// broader cross-file Tier-2 class-member lookup work. +// +// The instance-receiver crossover (`l.create()` on an instance receiver +// emits no CALLS edge) is U3's surface and is asserted in the U2 / U3 +// same-file fixtures (`kotlin-companion-mro-shadow`, +// `kotlin-companion-other-cases`); this fixture intentionally does not +// duplicate that assertion to avoid coupling U6 to U3's static-only- +// filter extension to Cases 0 / 3b / 5. +// --------------------------------------------------------------------------- + +describe('Kotlin companion vs instance cross-file dispatch (#1756 / U6)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-companion-cross-file'), + () => {}, + ); + }, 60000); + + it('detects Logger class with companion create() and instance log()', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Logger'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('create'); + expect(methods).toContain('log'); + }); + + // Happy path: `Logger.create("app")` resolves via class-name receiver + // (Case 2) across module boundaries — the import-resolved receiver + // chain reaches the companion's `create` in `logging/Logger.kt`. + it('useCrossFileFactory: Logger.create() resolves to companion create on Logger.kt', () => { + const calls = getRelationships(result, 'CALLS'); + const createCall = calls.find( + (c) => c.source === 'useCrossFileFactory' && c.target === 'create', + ); + expect(createCall).toBeDefined(); + expect(createCall!.targetFilePath).toBe('logging/Logger.kt'); + }); + + // NOTE: a follow-up assertion `l.log()` resolving cross-file via the + // chain-typebinding `val l = Logger.create(...)` would belong here. + // The U6 probe (2026-05-22) confirmed that the existing pipeline does + // NOT propagate `l → Logger` across module boundaries — see the comment + // block above for the failure modes and deferral rationale. The class- + // name dispatch assertion above is the additive coverage U6 lands; the + // chain-typebinding cross-file path is tracked as a follow-up issue + // alongside the broader cross-file Tier-2 lookup work. +}); + +// --------------------------------------------------------------------------- +// #1756 / U3 remediation: extend the `isStaticOnly` filter to receiver-bound +// dispatch cases beyond Case 4. Pre-U3, the filter only fired on Case 4 +// (simple typeBinding receiver). Three other instance-dispatch cases also +// emit `CALLS` edges and could leak the companion-vs-instance crossover: +// - Case 0 (compound receiver): receiver like `Logger.create("a")` whose +// `findOwnedMember(Logger, "create")` returns the static-only +// companion-promoted `create`. +// - Case 3b (chain-typebinding): receiver inferred via a chain whose +// resolved owner has a static-only candidate. +// - Case 5 (value-receiver bridge): `findValueBindingInScope` + +// `pickOverload` on a single owner. +// +// Note: Case 0.5 (`this`-receiver) is NOT covered because Kotlin's scope- +// resolver does not enable `resolveThisViaEnclosingClass`. The dependency +// is documented inline in `receiver-bound-calls.ts` so any language that +// enables it must also wire the filter at that case. +// +// The legitimate edges (Case 2 class-name receiver `Logger.create("a")`, +// Case 4 simple typeBinding `r.getAll()`) must continue to emit. +// +// **Empirical case-coverage observations** (probe at commit pre-U3, test +// run 2026-05-22): in **registry-primary** mode, the existing pipeline +// already emits zero crossover edges for the fixture shapes below even +// without U3's filter wired at Cases 0 / 3b / 5. In **legacy DAG** mode +// (REGISTRY_PRIMARY_KOTLIN=0), the same shapes leak crossover edges for +// the `useChainTypeBindingCrossover` and `useValueReceiverCrossover` +// scenarios — confirming that *some* suppression mechanism in the +// registry-primary path is already catching them (most likely U2's +// Case-4 filter for `l.create("nope")`, since `val l = ...` produces a +// typeBinding routing through Case 4; the compound and chain shapes +// are suppressed by the receiver resolver not binding to the static- +// only def in the first place). +// +// Per the remediation plan's "be honest about which paths are actually +// exercised by tests vs which are added defensively" guidance, the +// per-case filters at Cases 0 / 3b / 5 are landing as **defensive +// wire-ups** — they ensure the contract symmetry the JSDoc now claims +// (filter applies to every instance-dispatch case) holds for future +// fixture shapes that DO trigger these paths with a static-only +// candidate. The crossover tests are registered as expected failures +// in `LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.kotlin` because the +// legacy DAG genuinely diverges on these shapes; the registry-primary +// path's suppression is a real scope-resolver-only correctness win. +// --------------------------------------------------------------------------- + +describe('Kotlin isStaticOnly across other receiver cases (#1756 / U3)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'kotlin-companion-other-cases'), + () => {}, + ); + }, 60000); + + it('detects Logger / Service / Repo and their companion + instance methods', () => { + const classes = getNodesByLabel(result, 'Class'); + expect(classes).toContain('Logger'); + expect(classes).toContain('Service'); + expect(classes).toContain('Repo'); + const methods = getNodesByLabel(result, 'Method'); + expect(methods).toContain('create'); + expect(methods).toContain('build'); + expect(methods).toContain('log'); + expect(methods).toContain('perform'); + expect(methods).toContain('getAll'); + }); + + // Happy path + Case 0 crossover suppression (combined): the legitimate + // `Logger.create("a")` (Case 2 class-name receiver) emits exactly 1 + // CALLS edge to `create`. The OUTER `.create("b")` on the compound + // receiver `Logger.create("a")` would route through Case 0 — per the + // empirical observation above, the existing pipeline already does NOT + // emit a crossover edge for this shape, so the post-U3 count stays + // at 1 (same as pre-U3). The U3 Case-0 filter is defensive: if a + // future fixture's compound-receiver shape DOES enter Case 0 with a + // static-only candidate, the filter would suppress. + it('useCompoundCrossover: Logger.create("a") emits exactly 1 CALLS edge to create', () => { + const calls = getRelationships(result, 'CALLS'); + const createCalls = calls.filter( + (c) => c.source === 'useCompoundCrossover' && c.target === 'create', + ); + expect(createCalls.length).toBe(1); + expect(createCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Happy path (Case 4 simple typeBinding, baseline): `r.getAll()` in + // `useChainTypeBindingCrossover` resolves through `findReceiverType + // Binding` for `r: Repo` and `findOwnedMember(Repo, "getAll")`. The + // instance dispatch on `Repo` is legitimate — that edge MUST emit. + it('useChainTypeBindingCrossover: r.getAll() emits exactly 1 CALLS edge to getAll', () => { + const calls = getRelationships(result, 'CALLS'); + const getAllCalls = calls.filter( + (c) => c.source === 'useChainTypeBindingCrossover' && c.target === 'getAll', + ); + expect(getAllCalls.length).toBe(1); + expect(getAllCalls[0].targetFilePath).toBe('App.kt'); + }); + + // Crossover (Case 3b chain-typebinding): the chained `.build()` on + // `services.first()` would route through Case 3b's chain-typebinding + // walk if the chain resolves to `Service`. Per the empirical + // observation above, the existing pipeline already does NOT emit a + // crossover edge for this shape — `services.first()` returns + // `Service?` from `List.first()` and the chain-typebinding + // walk doesn't terminate at the Service class for this expression + // tree. The U3 Case-3b filter is defensive: if a future shape DOES + // bind the chain to Service and reach `findOwnedMember(Service, + // "build")`, the filter would suppress. + it('useChainTypeBindingCrossover: services.first().build() emits NO CALLS edge to build', () => { + const calls = getRelationships(result, 'CALLS'); + const buildCalls = calls.filter( + (c) => c.source === 'useChainTypeBindingCrossover' && c.target === 'build', + ); + expect(buildCalls.length).toBe(0); + }); + + // Crossover (value-receiver-style): `l.create("nope")` is invalid + // Kotlin (companion methods are not legal instance-dispatch + // candidates). Kotlin's resolver typically routes `l` through Case 4 + // because `val l = makeLoggerForCrossover()` produces a typeBinding + // for Logger via call-result return-type inference — so the + // crossover suppression actually fires through Case 4 (U2's filter). + // The U3 Case-5 filter wire-up is defensive: it preserves contract + // symmetry for any future value-binding shape that bypasses Case 4 + // (e.g., object-literal-style receivers that fall through to the + // value-binding bridge instead). + it('useValueReceiverCrossover: l.create("nope") emits NO CALLS edge to create', () => { + const calls = getRelationships(result, 'CALLS'); + const createCalls = calls.filter( + (c) => c.source === 'useValueReceiverCrossover' && c.target === 'create', + ); + expect(createCalls.length).toBe(0); + }); +}); diff --git a/gitnexus/test/unit/cli-i18n.test.ts b/gitnexus/test/unit/cli-i18n.test.ts new file mode 100644 index 000000000..12a432630 --- /dev/null +++ b/gitnexus/test/unit/cli-i18n.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { detectCliLanguage, getCliLanguage, setCliLanguage, t } from '../../src/cli/i18n/index.js'; +import { cliResources } from '../../src/cli/i18n/resources.js'; + +describe('cli i18n', () => { + afterEach(() => setCliLanguage(null)); + + it('detects Chinese from GitNexus-specific or locale environment variables', () => { + expect(detectCliLanguage({ GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv)).toBe('zh-CN'); + expect(detectCliLanguage({ LC_ALL: 'zh_CN.UTF-8' } as NodeJS.ProcessEnv)).toBe('zh-CN'); + expect(detectCliLanguage({ LANG: 'en_US.UTF-8' } as NodeJS.ProcessEnv)).toBe('en'); + }); + + it('does not map Traditional Chinese locales to Simplified Chinese', () => { + expect(detectCliLanguage({ LANG: 'zh_TW.UTF-8' } as NodeJS.ProcessEnv)).toBe('en'); + expect(detectCliLanguage({ LANG: 'zh_HK.UTF-8' } as NodeJS.ProcessEnv)).toBe('en'); + expect(detectCliLanguage({ LANG: 'zh-Hant.UTF-8' } as NodeJS.ProcessEnv)).toBe('en'); + }); + + it('supports explicit language override and interpolation', () => { + setCliLanguage('zh-CN'); + expect(getCliLanguage()).toBe('zh-CN'); + expect(t('list.title', { count: 2 })).toBe('已索引仓库(2)'); + }); + + it('resolves plural suffixes when count is provided', () => { + setCliLanguage('en'); + expect(t('doctor.nodes', { count: 1 })).toBe('1 node'); + expect(t('doctor.nodes', { count: 2 })).toBe('2 nodes'); + expect(t('doctor.chunks', { count: 1 })).toBe('1 chunk'); + expect(t('doctor.chunks', { count: 2 })).toBe('2 chunks'); + + setCliLanguage('zh-CN'); + expect(t('doctor.nodes', { count: 1 })).toBe('1 个节点'); + expect(t('doctor.nodes', { count: 2 })).toBe('2 个节点'); + }); + + it('keeps base-key interpolation when no plural suffix exists', () => { + setCliLanguage('en'); + expect(t('list.title', { count: 1 })).toBe('Indexed Repositories (1)'); + }); + + it('keeps resource keys in parity across supported languages', () => { + const englishKeys = Object.keys(cliResources.en).sort(); + for (const [language, messages] of Object.entries(cliResources)) { + expect(Object.keys(messages).sort(), language).toEqual(englishKeys); + } + }); +}); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 00f5c0572..939a6a686 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -1,20 +1,182 @@ import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { Command, Option } from 'commander'; +import * as ts from 'typescript'; +import { afterEach, describe, expect, it } from 'vitest'; +import { localizeCliHelp } from '../../src/cli/help-i18n.js'; +import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../..'); const cliEntry = path.join(repoRoot, 'src/cli/index.ts'); -function runHelp(command: string) { - return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command, '--help'], { +function runHelp(command: string, env: NodeJS.ProcessEnv = {}) { + return runHelpArgs([command], env); +} + +function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...args, '--help'], { cwd: repoRoot, encoding: 'utf8', + env: { ...process.env, ...env }, }); } +function runRootHelp(env: NodeJS.ProcessEnv = {}) { + return runHelpArgs([], env); +} + +const allHelpCommands = [ + [], + ['setup'], + ['analyze'], + ['index'], + ['serve'], + ['mcp'], + ['list'], + ['status'], + ['doctor'], + ['clean'], + ['remove'], + ['wiki'], + ['augment'], + ['publish'], + ['query'], + ['context'], + ['impact'], + ['cypher'], + ['detect-changes'], + ['eval-server'], + ['group'], + ['group', 'create'], + ['group', 'add'], + ['group', 'remove'], + ['group', 'list'], + ['group', 'status'], + ['group', 'sync'], + ['group', 'impact'], + ['group', 'query'], + ['group', 'contracts'], +]; + +function staticStringValue(node: ts.Node | undefined): string | undefined { + if (!node) return undefined; + if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = staticStringValue(node.left); + const right = staticStringValue(node.right); + if (left !== undefined && right !== undefined) return `${left}${right}`; + } + return undefined; +} + +function extractRegisteredHelpDescriptions(): string[] { + const descriptions = new Set(); + const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts']; + + for (const relativePath of sourceFiles) { + const filePath = path.join(repoRoot, relativePath); + const source = fs.readFileSync(filePath, 'utf8'); + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + + function visit(node: ts.Node): void { + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { + const method = node.expression.name.text; + const description = + method === 'description' + ? staticStringValue(node.arguments[0]) + : method === 'option' || method === 'requiredOption' + ? staticStringValue(node.arguments[1]) + : undefined; + + if (description && /[A-Za-z]/.test(description)) { + descriptions.add(description.replace(/\s+/g, ' ').trim()); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + } + + return [...descriptions].filter((description) => description.length > 0).sort(); +} + +function metadataHelp(language: SupportedCliLanguage) { + setCliLanguage(language); + const command = new Command('probe'); + command.addOption(new Option('--mode ', 'Mode').choices(['fast', 'safe'])); + command.addOption(new Option('--limit ', 'Limit').default('5')); + command.addOption(new Option('--level [name]', 'Level').preset('auto')); + command.addOption(new Option('--token ', 'Token').env('GITNEXUS_TOKEN')); + localizeCliHelp(command); + return command.helpInformation(); +} + describe('CLI help surface', () => { + afterEach(() => setCliLanguage(null)); + + it('root help localizes commander headings, options, and command descriptions', () => { + const result = runRootHelp({ GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('用法: gitnexus [options] [command]'); + expect(result.stdout).toContain('GitNexus 本地 CLI 和 MCP 服务器'); + expect(result.stdout).toContain('选项:'); + expect(result.stdout).toContain('-V, --version 输出版本号'); + expect(result.stdout).toContain('-h, --help 显示命令帮助'); + expect(result.stdout).toContain('命令:'); + expect(result.stdout).toContain('setup'); + expect(result.stdout).toContain('一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP'); + expect(result.stdout).toContain('detect-changes|detect_changes [options]'); + expect(result.stdout).toContain('将 git diff hunk 映射到已索引符号和受影响执行流程'); + expect(result.stdout).not.toContain('GitNexus local CLI and MCP server'); + expect(result.stdout).not.toContain('display help for command'); + }); + + it('command help localizes option descriptions and help suffix text', () => { + const result = runHelp('query', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('用法: gitnexus query [options] '); + expect(result.stdout).toContain('搜索知识图谱中与概念相关的执行流程'); + expect(result.stdout).toContain('-r, --repo 目标仓库(仅有一个已索引仓库时可省略)'); + expect(result.stdout).toContain('-l, --limit 最多返回的流程数(默认:5)'); + expect(result.stdout).toContain('-h, --help 显示命令帮助'); + expect(result.stdout).not.toContain('Target repository (omit if only one indexed)'); + }); + + it('localizes every registered CLI command and option description in zh-CN help', () => { + const zhHelpOutput = allHelpCommands + .map((args) => { + const result = runHelpArgs(args, { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv); + + expect(result.status, `gitnexus ${args.join(' ')} --help`).toBe(0); + return result.stdout; + }) + .join('\n'); + + const untranslated = extractRegisteredHelpDescriptions().filter((description) => + zhHelpOutput.includes(description), + ); + + expect(untranslated).toEqual([]); + }); + + it('analyze help localizes custom environment variable help text', () => { + const result = runHelp('analyze', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('环境变量:'); + expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。'); + expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。'); + expect(result.stdout).not.toContain('Environment variables:'); + expect(result.stdout).not.toContain('Flags override the corresponding env vars'); + }); + it('query help keeps advanced search options without importing analyze deps', () => { const result = runHelp('query'); @@ -83,4 +245,19 @@ describe('CLI help surface', () => { expect(result.status).toBe(0); expect(result.stdout).toContain('--repair-fts'); }); + + it('localizes commander-generated option metadata labels', () => { + const english = metadataHelp('en'); + const chinese = metadataHelp('zh-CN'); + + expect(english).toContain('choices: "fast", "safe"'); + expect(english).toContain('default: "5"'); + expect(english).toContain('preset: "auto"'); + expect(english).toContain('env: GITNEXUS_TOKEN'); + + expect(chinese).toContain('可选值: "fast", "safe"'); + expect(chinese).toContain('默认: "5"'); + expect(chinese).toContain('预设: "auto"'); + expect(chinese).toContain('环境变量: GITNEXUS_TOKEN'); + }); }); diff --git a/gitnexus/test/unit/cli-message.test.ts b/gitnexus/test/unit/cli-message.test.ts index 321485c37..80a9b44f8 100644 --- a/gitnexus/test/unit/cli-message.test.ts +++ b/gitnexus/test/unit/cli-message.test.ts @@ -10,7 +10,8 @@ * embedded newlines). */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { cliInfo, cliWarn, cliError } from '../../src/cli/cli-message.js'; +import { cliInfo, cliWarn, cliError, cliInfoKey } from '../../src/cli/cli-message.js'; +import { setCliLanguage } from '../../src/cli/i18n/index.js'; import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; describe('cli-message — stderr + logger tee', () => { @@ -23,6 +24,7 @@ describe('cli-message — stderr + logger tee', () => { }); afterEach(() => { + setCliLanguage(null); stderrSpy.mockRestore(); cap.restore(); }); @@ -87,6 +89,16 @@ describe('cli-message — stderr + logger tee', () => { ); }); + it('cliInfoKey translates key-based messages before teeing', () => { + setCliLanguage('zh-CN'); + cliInfoKey('list.title', { count: 3 }); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + expect(stderrCalls).toContain('已索引仓库(3)\n'); + expect(cap.records().some((r) => r.msg === '已索引仓库(3)' && r.level === 30)).toBe(true); + }); + it('handles an empty message — stderr gets a bare newline, logger gets msg:""', () => { cliInfo(''); const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => diff --git a/gitnexus/test/unit/doctor-format.test.ts b/gitnexus/test/unit/doctor-format.test.ts new file mode 100644 index 000000000..199545885 --- /dev/null +++ b/gitnexus/test/unit/doctor-format.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { displayWidth, padDisplayEnd } from '../../src/cli/doctor.js'; + +describe('doctor output formatting', () => { + it('keeps ASCII padding equivalent to String.padEnd', () => { + expect(displayWidth('OS:')).toBe(3); + expect(padDisplayEnd('OS:', 10)).toBe('OS:'.padEnd(10)); + }); + + it('pads CJK labels by terminal display width, not code-unit length', () => { + const padded = padDisplayEnd('系统:', 10); + + expect(displayWidth('系统:')).toBe(6); + expect(displayWidth(padded)).toBe(10); + expect(padded).toBe('系统: '); + }); + + it('does not truncate labels that are already wider than the target width', () => { + expect(padDisplayEnd('图存储:', 4)).toBe('图存储:'); + }); +}); diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index cfc09acd1..f767f9ada 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -4,7 +4,7 @@ * Tests: formatQueryResult, formatContextResult, formatImpactResult, * formatCypherResult, formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE */ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { formatQueryResult, formatContextResult, @@ -18,6 +18,15 @@ import { // ─── validateHost ──────────────────────────────────────────────────── +beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('GITNEXUS_LANG', 'en'); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe('validateHost', () => { it('passes "localhost" through unchanged', () => { expect(validateHost('localhost')).toBe('localhost'); @@ -381,6 +390,25 @@ describe('formatDetectChangesResult', () => { }); expect(result).toContain('and 5 more'); }); + + it('localizes detect_changes labels for Simplified Chinese', () => { + vi.stubEnv('GITNEXUS_LANG', 'zh-CN'); + + const result = formatDetectChangesResult({ + summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' }, + changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }], + affected_processes: [ + { name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] }, + ], + }); + + expect(result).toContain('变更:2 个文件,3 个符号'); + expect(result).toContain('受影响流程:1'); + expect(result).toContain('风险等级:MEDIUM'); + expect(result).toContain('已变更符号:'); + expect(result).toContain('受影响执行流程:'); + expect(result).toContain('Auth Flow (5 步) — 已变更:foo'); + }); }); // ─── formatListReposResult ─────────────────────────────────────────── diff --git a/gitnexus/test/unit/kotlin-static-marker.test.ts b/gitnexus/test/unit/kotlin-static-marker.test.ts new file mode 100644 index 000000000..51bdb7c95 --- /dev/null +++ b/gitnexus/test/unit/kotlin-static-marker.test.ts @@ -0,0 +1,279 @@ +/** + * Unit tests for the Kotlin companion-promoted-method "static-only" + * marker mechanism (#1756 / U5 of the lang-kotlin remediation plan). + * + * Pins the contract of the `isKotlinStaticOnly` reader and the + * implicit `WeakSet`-backed writer driven by + * `populateKotlinOwners`: + * + * 1. Round-trip: methods declared inside a companion-object scope + * pass `isKotlinStaticOnly` after `populateKotlinOwners` runs; + * methods declared directly on a regular class scope do not. + * 2. Identity, not structure: spreading a marked def into a new + * object reference produces a structurally-identical but + * identity-distinct def that does NOT pass the marker check. + * Documents the identity-based design boundary that the + * previous enumerable-property mechanism did not enforce. + * 3. Multi-def fanout: marking three companion methods in one + * pass leaves all three readable and an unmarked sibling + * unaffected. + * + * The writer is intentionally not exported — these tests drive it + * through the public `populateKotlinOwners` entry point using a + * hand-built `ParsedFile` shape, mirroring the runtime call site. + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ParsedFile, Range, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { + clearCompanionScopes, + isCompanionScope, + markCompanionScope, +} from '../../src/core/ingestion/languages/kotlin/companion-scopes.js'; +import { + isKotlinStaticOnly, + populateKotlinOwners, +} from '../../src/core/ingestion/languages/kotlin/owners.js'; + +const RANGE: Range = { startLine: 1, startCol: 0, endLine: 1, endCol: 0 }; + +function makeScope(args: { + id: string; + parent: string | null; + kind: Scope['kind']; + filePath: string; + ownedDefs: readonly SymbolDefinition[]; +}): Scope { + return { + id: args.id as ScopeId, + parent: args.parent === null ? null : (args.parent as ScopeId), + kind: args.kind, + range: RANGE, + filePath: args.filePath, + bindings: new Map(), + ownedDefs: args.ownedDefs, + imports: [], + typeBindings: new Map(), + } as Scope; +} + +function makeMethodDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition { + return { + nodeId: args.nodeId, + filePath: args.filePath, + type: 'Function', + qualifiedName: args.name, + } as SymbolDefinition; +} + +function makeClassDef(args: { nodeId: string; filePath: string; name: string }): SymbolDefinition { + return { + nodeId: args.nodeId, + filePath: args.filePath, + type: 'Class', + qualifiedName: args.name, + } as SymbolDefinition; +} + +/** + * Build a synthetic `ParsedFile` modelling: + * + * class Outer { + * fun instanceMethod() { ... } // regular instance method + * companion object { + * fun staticMethod() { ... } // companion-promoted method + * } + * } + * + * The companion scope is registered with `markCompanionScope` so + * `populateCompanionMembersOnEnclosingClass` recognises it as the + * companion-object scope to walk for promotion + marking. + */ +function buildCompanionFixture( + filePath: string, + companionMethods: readonly string[], + instanceMethods: readonly string[], +): { + parsed: ParsedFile; + outerClassDef: SymbolDefinition; + companionMethodDefs: SymbolDefinition[]; + instanceMethodDefs: SymbolDefinition[]; +} { + const moduleScopeId = `${filePath}:module`; + const outerClassScopeId = `${filePath}:class:Outer`; + const companionScopeId = `${filePath}:class:Outer.Companion`; + + const outerClassDef = makeClassDef({ + nodeId: `${filePath}#Outer`, + filePath, + name: 'Outer', + }); + + const companionMethodDefs = companionMethods.map((name) => + makeMethodDef({ + nodeId: `${filePath}#Outer.Companion.${name}`, + filePath, + name, + }), + ); + + const instanceMethodDefs = instanceMethods.map((name) => + makeMethodDef({ + nodeId: `${filePath}#Outer.${name}`, + filePath, + name, + }), + ); + + const scopes: Scope[] = [ + makeScope({ + id: moduleScopeId, + parent: null, + kind: 'Module', + filePath, + ownedDefs: [outerClassDef], + }), + makeScope({ + id: outerClassScopeId, + parent: moduleScopeId, + kind: 'Class', + filePath, + ownedDefs: [outerClassDef], + }), + makeScope({ + id: companionScopeId, + parent: outerClassScopeId, + kind: 'Class', + filePath, + ownedDefs: [], + }), + ]; + + // Each companion method lives in its own Function scope whose parent + // is the companion-class scope — the exact shape + // `populateCompanionMembersOnEnclosingClass` iterates. + companionMethodDefs.forEach((def, idx) => { + scopes.push( + makeScope({ + id: `${filePath}:fn:companion:${idx}`, + parent: companionScopeId, + kind: 'Function', + filePath, + ownedDefs: [def], + }), + ); + }); + + // Instance methods on the outer class — Function scopes whose + // parent is the outer-class scope; populateClassOwnedMembers + // stamps these with `ownerId = Outer` but they MUST NOT be + // tagged by the companion promotion pass. + instanceMethodDefs.forEach((def, idx) => { + scopes.push( + makeScope({ + id: `${filePath}:fn:instance:${idx}`, + parent: outerClassScopeId, + kind: 'Function', + filePath, + ownedDefs: [def], + }), + ); + }); + + // Tell the companion-scope side-channel that + // `outerClassScopeId.companion` is the companion scope id — + // matches what `emitKotlinScopeCaptures` does at runtime. + markCompanionScope(filePath, companionScopeId as ScopeId); + + const parsed: ParsedFile = { + filePath, + moduleScope: moduleScopeId as ScopeId, + scopes, + parsedImports: [], + localDefs: [outerClassDef, ...companionMethodDefs, ...instanceMethodDefs], + referenceSites: [], + }; + + return { parsed, outerClassDef, companionMethodDefs, instanceMethodDefs }; +} + +describe('isKotlinStaticOnly (WeakSet-backed marker)', () => { + beforeEach(() => { + clearCompanionScopes(); + }); + + it('marks companion-object methods and leaves instance methods unmarked (round-trip)', () => { + const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture( + 'fixture-roundtrip.kt', + ['staticMethod'], + ['instanceMethod'], + ); + + populateKotlinOwners(parsed); + + expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true); + expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false); + }); + + it('keys on def identity, not on def structure (spread copy is not marked)', () => { + const { parsed, companionMethodDefs } = buildCompanionFixture( + 'fixture-identity.kt', + ['staticMethod'], + [], + ); + + populateKotlinOwners(parsed); + + const marked = companionMethodDefs[0]!; + // Spread produces a new object reference with identical fields. + // The previous enumerable-property marker would have copied through; + // the WeakSet correctly tracks identity only. + const structuralClone = { ...marked } as SymbolDefinition; + + expect(isKotlinStaticOnly(marked)).toBe(true); + expect(isKotlinStaticOnly(structuralClone)).toBe(false); + // Sanity: the clone really does have the same own-properties. + expect(structuralClone.nodeId).toBe(marked.nodeId); + expect(structuralClone.qualifiedName).toBe(marked.qualifiedName); + }); + + it('marks every companion method in a multi-method companion and leaves siblings unaffected', () => { + const { parsed, companionMethodDefs, instanceMethodDefs } = buildCompanionFixture( + 'fixture-multi.kt', + ['create', 'build', 'of'], + ['save'], + ); + + populateKotlinOwners(parsed); + + expect(isKotlinStaticOnly(companionMethodDefs[0]!)).toBe(true); + expect(isKotlinStaticOnly(companionMethodDefs[1]!)).toBe(true); + expect(isKotlinStaticOnly(companionMethodDefs[2]!)).toBe(true); + expect(isKotlinStaticOnly(instanceMethodDefs[0]!)).toBe(false); + }); + + it('returns false for a fresh def the writer never saw', () => { + const unrelated = makeMethodDef({ + nodeId: 'unrelated#foo', + filePath: 'unrelated.kt', + name: 'foo', + }); + + expect(isKotlinStaticOnly(unrelated)).toBe(false); + }); +}); + +describe('kotlinScopeResolver.loadResolutionConfig lifecycle', () => { + it('clears stale companionScopesByFile entries from a prior workspace pass', async () => { + const staleFile = 'stale-prior-pass.kt'; + const staleScopeId = `scope:${staleFile}#1:0-2:0:Class` as ScopeId; + markCompanionScope(staleFile, staleScopeId); + expect(isCompanionScope(staleFile, staleScopeId)).toBe(true); + + const { kotlinScopeResolver } = + await import('../../src/core/ingestion/languages/kotlin/scope-resolver.js'); + kotlinScopeResolver.loadResolutionConfig!('/any/repo/path'); + + expect(isCompanionScope(staleFile, staleScopeId)).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/tool-direct-cli.test.ts b/gitnexus/test/unit/tool-direct-cli.test.ts index 9ede6225b..3c5bb3b58 100644 --- a/gitnexus/test/unit/tool-direct-cli.test.ts +++ b/gitnexus/test/unit/tool-direct-cli.test.ts @@ -17,6 +17,8 @@ vi.mock('node:fs', () => ({ describe('direct CLI tool commands', () => { beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv('GITNEXUS_LANG', 'en'); vi.resetModules(); initMock.mockReset(); callToolMock.mockReset(); @@ -107,4 +109,26 @@ describe('direct CLI tool commands', () => { expect(output).toContain('proc9'); expect(output).not.toContain('proc10'); }); + + it('localizes detect_changes formatter labels for Simplified Chinese', async () => { + vi.stubEnv('GITNEXUS_LANG', 'zh-CN'); + callToolMock.mockResolvedValue({ + summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' }, + changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }], + affected_processes: [ + { name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] }, + ], + }); + const { detectChangesCommand } = await import('../../src/cli/tool.js'); + + await detectChangesCommand({}); + + const output: string = writeSyncMock.mock.calls[0][1]; + expect(output).toContain('变更:2 个文件,3 个符号'); + expect(output).toContain('受影响流程:1'); + expect(output).toContain('风险等级:MEDIUM'); + expect(output).toContain('已变更符号:'); + expect(output).toContain('受影响执行流程:'); + expect(output).toContain('Auth Flow (5 步) — 已变更:foo'); + }); });