Compare commits

...
106 Commits
Author SHA1 Message Date
Gergo Magyar 3768e3bfd0 fix(server): log skip-embedding count and table-not-found swallow path
Addresses review feedback on PR #823:
- Log count of already-embedded nodes when skipNodeIds is populated
  (aids debugging if Kuzu driver row shape changes).
- Log when the 'table does not exist' swallow path fires so ops can
  catch it if Kuzu ever changes error wording.
- Document the {} config positional argument with an inline comment
  referencing the runEmbeddingPipeline signature.
2026-04-15 07:40:58 +01:00
Gergo Magyar 3384575ac6 style: prettier format gitnexus/src/server/api.ts 2026-04-15 07:38:16 +01:00
jonasvanderhaegen-xve dd194d56b1 fix(server): narrow catch to table-not-exist errors only in POST /api/embed
Bare catch{} would silently swallow connection errors and proceed to
re-embed all nodes, hiding infrastructure issues. Now only swallows
errors where the CodeEmbedding table does not yet exist.
2026-04-14 14:27:03 +02:00
jonasvanderhaegen-xve 80a6fde2ba fix(server): skip already-embedded nodes in POST /api/embed to avoid vector-index SET error
Kuzu/LadybugDB forbids SET on a property that is part of a vector index.
The /api/embed endpoint was calling runEmbeddingPipeline without skipNodeIds,
causing it to attempt MERGE+SET on every node including those already embedded.

Fix: query existing CodeEmbedding nodeIds before running the pipeline and pass
them as skipNodeIds so only new (unembedded) nodes are processed.
2026-04-14 13:33:48 +02:00
jonasvanderhaegen-xve 8d38cc99fa fix(embeddings): use MERGE instead of CREATE for CodeEmbedding inserts
CREATE fails with duplicate PK when a CodeEmbedding node already exists,
which happens when:
- A PostToolUse hook triggers a concurrent gitnexus analyze during an
  active analyze run (git commits fire the hook)
- A partial prior run left some embeddings in the DB before a crash

Switching to MERGE makes the insert idempotent: existing embeddings are
updated in place, new ones are created, no PK violations.

Fixes: #822
2026-04-14 12:59:14 +02:00
jonasvanderhaegen-xve 41844edf88 fix(csv-generator): deduplicate all node types, not just File nodes
The pipeline can produce duplicate node IDs across all symbol types
(Class, Method, Function, etc.). Only File nodes were guarded by a
seenFileIds Set, leaving every other type unprotected. When the CSV
was COPY'd into LadybugDB, duplicate PKs caused mass "Batch execution
error: Found duplicated primary key value" warnings on gitnexus serve.

Replace the per-type seenFileIds with a single seenNodeIds Set checked
at the top of the iteration loop, before the switch, so every label is
covered by the same O(1) deduplication guard.

Fixes: #822
2026-04-14 12:32:08 +02:00
Filipe Oliveira (Redis)andGergo Magyar 9ad1984b17 fix: resolve C/C++ cross-file calls through transitive #include chains (#816)
* fix: resolve C/C++ cross-file calls through transitive #include chains

In C/C++, #include is transitive: if a.c includes b.h and b.h includes
c.h, then a.c can call any function declared in c.h. The wildcard import
synthesis only walked direct imports (1 hop), missing symbols reachable
through transitive header chains.

This is the dominant pattern in large C codebases — Redis's db.c includes
server.h which includes dict.h, so db.c should resolve calls to dictFind()
declared in dict.h and defined in dict.c. Before this fix, those cross-file
call edges were missing entirely.

The fix expands the import closure transitively for C/C++ files before
synthesizing wildcard bindings. A BFS walks ctx.importMap and graphImports
to collect all transitively reachable headers, then passes the full closure
to synthesizeForFile.

Tested on Redis (github.com/redis/redis):
- Before: dictFetchValue had 0 cross-file callers, processCommand had 0
- After: dictFetchValue has 9 callers, processCommand has 1, +1946 edges total

Fixes #813

* refactor(ingestion): dispatch wildcard synthesis by import-semantics strategy

Generalize PR #816's C/C++ transitive #include fix into a language-agnostic
strategy pattern. The `wildcard-synthesis.ts` pipeline phase no longer
references `SupportedLanguages.C` / `SupportedLanguages.CPlusPlus` — it
dispatches on `provider.importSemantics` via an exhaustive `switch`.

Also fixes a correctness bug the original BFS introduced: `queue.pop()`
(LIFO/DFS) reversed the iteration order of `#include` directives, which —
combined with first-seen-wins dedup in `synthesizeForFile` — silently
bound overloaded symbols to the wrong header. For the `cpp-calls`
fixture, `write_audit("hello")` was being resolved to `zero.h`'s arity-0
overload instead of `one.h`'s arity-1 overload, breaking arity
narrowing. Switched to FIFO (`queue.shift()`) with direct imports seeded
in declaration order.

Taxonomy (researched across 20+ languages + stack-graphs / SCIP prior art):

  | Tag                 | Traversal       | Languages                          |
  |---------------------|-----------------|------------------------------------|
  | named               | none            | TS, JS, Java, C#, Rust, PHP, Kotlin|
  | wildcard-transitive | BFS closure     | C, C++                             |
  | wildcard-leaf       | single hop      | Go, Ruby, Swift, Dart              |
  | namespace           | none at import  | Python                             |
  | explicit-reexport   | topological DAG | (scaffold; TS `export *` future)   |

Changes:
- Widen `ImportSemantics` union from 3 to 5 tags with full taxonomy JSDoc
- Retag 5 providers: c-cpp (x2) → wildcard-transitive; dart, go, ruby,
  swift → wildcard-leaf
- Move BFS closure into `wildcard-synthesis.ts` as `expandTransitiveIncludeClosure`
  (pipeline-owned; providers stay pure declarations)
- Replace `if (lang === C || CPP)` with `dispatchSynthesis` helper called
  by both Loop 1 (ctx.importMap) and Loop 2 (graphImports) so a future
  transitive language whose edges arrive via graphImports gets closure
  expansion consistently
- `never`-assertion default arm forces compile-time exhaustiveness
- `explicit-reexport` arm falls through to leaf behavior (scaffold;
  TODO: implement re-export DAG walk for TS `export *` / Rust `pub use`)
- New unit tests covering circular includes, deep chains, diamond dedup,
  graphImports-only paths, and order-preservation (the regression fix)

Verification:
- All existing C/C++ transitive tests pass unchanged
- Previously failing `cpp.test.ts > resolves run → write_audit to one.h
  via arity narrowing` now passes
- `tsc --noEmit` clean
- 225/225 tests pass across wildcard-synthesis, cross-file-binding,
  cpp resolver, and new closure unit tests

* fix(ingestion): bound closure size, O(1) dequeue, track Strategy 4 (#816 review)

Address @xkonjin's review feedback on the import-resolution strategy refactor:

1. **DoS guard**: cap transitive closures at 5,000 files via
   `MAX_TRANSITIVE_CLOSURE_SIZE`. Pathological codebases (boost-style headers,
   monoheader kernels) could previously produce closures with tens of thousands
   of entries per translation unit. BFS now stops early and returns a partial
   closure rather than risking OOM. The closest-headers-first BFS ordering
   means the partial closure still contains the files overload resolution
   cares about.

2. **Perf**: replace `Array.prototype.shift()` (O(n)) with a head-index queue
   (O(1) dequeue). Deep chains previously had quadratic BFS behavior; now
   linear in closure size.

3. **Strategy 4 tracking**: change TODO in `dispatchSynthesis` to
   `TODO(#821)` referencing the filed issue for TS `export *` / Rust
   `pub use` DAG-walk implementation, and clarify that today's leaf
   fallthrough preserves correctness for direct imports — only the extra
   re-export traversal is missing.

4. **Test**: new unit test exercising the 5,000-file cap on a 10k-file
   synthetic chain, verifying partial-closure invariants (starts from
   importer side, bounded, deep nodes excluded).

Not addressed in this commit (followups):
- Review point 3 (graphImports-only deep-chain *integration* fixture):
  unit tests already exercise the `graphImports` traversal path directly
  in isolation and combined with `importMap`. A fixture that stresses
  graphImports-only transitive resolution is valuable but requires
  understanding when the pipeline populates graphImports distinctly from
  ctx.importMap — tracking as a followup rather than blocking this PR.

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-14 09:39:17 +01:00
Copilotandmagyargergo 1a597f3cc6 Fix npm arborist crash caused by tree-sitter-dart tarball URL format (#820)
* Initial plan

* fix: change tree-sitter-dart from tarball URL to git URL to fix npm arborist crash, add error handling and troubleshooting docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/382c76c6-89c3-463a-8631-2a5d6510be4c

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refine error handler patterns and troubleshooting docs for arborist crash

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/909b319b-c367-40aa-8033-32dfb6231d4e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: run prettier on changed files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/50eb6b94-9300-4bf2-9b61-c2d78f637fc6

* fix: use github: shorthand for tree-sitter-dart to avoid SSH in CI

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ce3f4b3-4c1e-4c39-b824-c25cfe145529

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* revert: use git+https:// for tree-sitter-dart instead of github: shorthand (fixes arborist crash from PR #811)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b786b68d-6c76-4054-88eb-ad46ea9f5b81

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-14 08:57:29 +01:00
Gergő Magyar 759c983dce fix(extractors): resolve 3 silent contract mis-resolution bugs (#793) (#817)
* fix(extractors): resolve 3 silent contract mis-resolution bugs (#793)

Addresses Codex adversarial review findings for extractor contract
resolution on the new group extractor surface.

F1 (manifest-extractor): resolveSymbol passed the full "METHOD::path"
contract string through normalizeRoutePath, producing "/GET::/api/orders"
which never matches Route.name. Adds parseHttpContract() helper that
strips the METHOD:: prefix before path normalization. Contract ID
construction (buildContractId) is unchanged.

F2 (http-route-extractor): graph-assisted backfill used path-only
detections.find(), so multi-verb same-URL files attached the wrong
verb/handler to provider rows and inferred the wrong verb on FETCHES
consumer edges. Now requires path+method match when method is known,
and skips backfill when method is unknown and multiple detections tie
on path.

F3 (grpc-extractor): resolveProtoConflict seeded bestScore=-1 and only
replaced on strict >, so all-zero-score ties silently selected
candidates[0]. Now computes all scores, counts ties at the top score,
and returns null on ambiguity (caller skips contract emission and
warns with service name + candidate paths).

All three fixes are test-first; 73 tests pass across the three suites.
No schema changes, no new dependencies, contract ID wire format
(http::METHOD::path, grpc::pkg.Service/Method, http::*::path) preserved.

* fix(extractors): address PR #817 review — ambiguous symbol pick + contract id casing

Copilot + Claude review on PR #817 flagged two follow-up bugs on top of
the F1/F2/F3 fixes:

1. http-route-extractor: ambiguous multi-verb case left handlerName null
   but still ran the CONTAINS DB query. pickSymbolUid(syms, null) then
   silently picked pool[0] — reintroducing handler mis-attribution via
   a different route than the .find() bug F2 fixed. Now gates symbol
   enrichment on an ambiguousCandidates flag so the file-basename
   fallback wins instead.

2. manifest-extractor: buildContractId passed raw user casing through
   for the explicit-method form, so get::/api/orders and
   GET::/api/orders produced different contract ids even though
   parseHttpContract upper-cases during lookup. Now reuses
   parseHttpContract + normalizeRoutePath to canonicalize both method
   and path, so logically equivalent manifest inputs share a contract
   id (and share a manifestSymbolUid fallback).

Adds one regression test per bug: lowercase vs uppercase manifest
contract ids must match, and ambiguous multi-verb with CONTAINS rows
must not silently attach a real handler or call the CONTAINS query
at all. 75 tests pass across the three extractor suites.

* chore: prettier formatting
2026-04-14 08:03:02 +01:00
Abhigyan Patwari 988b905abe Merge pull request #767 from noCharger/feat/chat-scroll-pause
feat(web): add smart chat scroll
2026-04-14 01:25:09 +05:30
Gergő Magyar 3fbee2d3d2 chore: release v1.6.1 (#815) 2026-04-13 20:38:09 +01:00
26ff700e37 refactor(pipeline): DAG-based phase architecture + container-logic extraction to LanguageProvider (#809)
* Initial plan

* refactor: move language-specific container node logic into LanguageProvider

- Add resolveEnclosingOwner hook to LanguageProviderConfig
- Add staticOwnerTypes to MethodExtractionConfig
- Implement Ruby resolveEnclosingOwner (singleton_class → class/module)
- Replace hardcoded STATIC_OWNER_TYPES with config.staticOwnerTypes
- Move Ruby static types to rubyMethodConfig
- Move Kotlin static types to kotlinMethodConfig
- Remove Ruby singleton_class branch from findEnclosingClassInfo
- Collapse seqFindEnclosingClassNode/seqFindRawEnclosingContainerNode
  into single provider-aware seqFindEnclosingOwnerNode
- Update worker path to pass provider.resolveEnclosingOwner

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: add regression tests for config-driven staticOwnerTypes and resolveEnclosingOwner hook

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/bc9f9d4d-f749-4872-9ff2-17fc86e08787

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: implement DAG-based pipeline architecture with phase extraction

Restructure the ingestion pipeline from a ~1800-line monolithic orchestrator
into a DAG (Directed Acyclic Graph) of named phases with explicit dependencies.

New files under pipeline-phases/:
- types.ts: PipelinePhase, PipelineContext, PhaseResult contracts
- runner.ts: DAG runner with topological sort validation
- scan.ts, structure.ts, markdown.ts, cobol.ts: early phases
- parse.ts + parse-impl.ts: chunked parse + resolve (the core)
- routes.ts, tools.ts, orm.ts: post-parse enrichment phases
- cross-file.ts + cross-file-impl.ts: cross-file binding propagation
- mro.ts, communities.ts, processes.ts: graph analysis phases
- index.ts: barrel export

pipeline.ts reduced from ~1960 lines to ~184 lines:
- DAG phase array declaration
- runPipelineFromRepo as thin orchestrator
- topologicalLevelSort retained for backward compat

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c

* test: add DAG runner unit tests, update ARCHITECTURE.md with phase DAG docs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c

* fix: address code review - pass resolutionContext through parse output, fix worker URL path

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c

* fix: declare transitive parse dependency explicitly in mro/communities/processes phases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136bf9c3-2f4f-449b-9fff-001332c8371c

* refactor: improve pipeline-phases clean code and folder structure

- Extract synthesizeWildcardImportBindings to wildcard-synthesis.ts
- Extract extractORMQueriesInline to orm-extraction.ts
- Create shared constants.ts for AST_CACHE_CAP
- Fix inline type import in orm.ts (use proper top-level import)
- Add comprehensive JSDoc to getPhaseOutput explaining type safety
- Move isDev to module level in cross-file.ts (consistency)
- Improve module-level documentation across files
- Organize barrel exports in index.ts with section comments

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2bd6d4aa-6271-4009-8dd2-332ea8ec73ab

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* address review feedback: fix circular dep, allFetchCalls mutation, progress bugs, remove DAG naming, extract isDev, fix _item naming, fix O(n²) line calc

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* improve JSDoc on lineNumberAtOffset binary search

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6cf53c9b-d55d-4c6f-bf3d-7bfb82d512b6

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* address review: filter deps in runner, move totalFiles to ctx, fix cycle JSDoc, centralize isDev, remove DAG naming

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix doc consistency in graph-sort.ts module-level and function-level JSDoc

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b388424f-b939-4a94-97de-3855f9465564

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(pipeline): wrap phase errors with phase name and emit terminal error progress event

Restores phase diagnostics at CLI/MCP boundary. runPipeline now wraps
phase.execute() in try/catch and rethrows with 'Phase <name> failed: ...'
preserving the original via { cause }. Also emits a terminal
{ phase: 'error' } progress event so subscribers see the failure before
the rejection propagates. Handler errors during error reporting are
swallowed to keep the original cause authoritative.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U1)

* fix(pipeline): move bindingAccumulator dispose into crossFile try/finally; make single-use

crossFile.execute() now wraps its body in try/finally so the accumulator
is released on both the happy path and when runCrossFileBindingPropagation
throws. Dev-mode telemetry stays inside the try block before dispose (all
three counters return 0 after dispose clears internal maps).

BindingAccumulator becomes single-use: appendFile after dispose now throws
'BindingAccumulator: use after dispose' instead of silently re-animating
via the old _disposed auto-clear. Docs updated; the only production
construction site (parse-impl) always creates a fresh instance per run,
so no caller relied on the re-use contract.

Residual risk documented in crossFile module JSDoc: a future phase
inserted between parse and crossFile that throws would still leak the
accumulator. Any such phase must manage accumulator lifetime explicitly.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U2)

* docs(pipeline): explain why importCtx teardown is safe before crossFile

Investigation (plan U3) confirms: `importCtx` (ImportResolutionContext)
is a scratch workspace with no downstream consumer after parse.
`resolutionContext` (returned to crossFile) is a distinct object that
owns importMap / namedImportMap / packageMap / moduleAliasMap / model,
and never closes over importCtx. cross-file-impl consumes only that
ctx via processCalls. The two confusingly-similar "context" names
were the root of the adversarial reviewer's concern — comment locks
in the invariant so the next reader sees it.

No behavioral change.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U3)

* refactor(pipeline): remove ctx.totalFiles side-channel; promote to ParseOutput

totalFiles was a hidden mutable field on PipelineContext written by
parse and read by mro/communities/processes — five reviewers flagged
this as a violation of the immutable-context invariant. Removed from
PipelineContext, which is now fully readonly, and made the implicit
temporal dep explicit: mro/communities/processes now declare 'parse'
as a dep and read totalFiles via getPhaseOutput<ParseOutput>(...).

No behavior change. Topo-sort unchanged because parse was already a
transitive dep through crossFile.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U4)

* feat(method-extractor): runtime staticOwnerTypes guard at factory chokepoint

createMethodExtractor now rejects MethodExtractionConfigs that list
companion_object / singleton_class / object_declaration in
typeDeclarationNodes but omit the matching entry from staticOwnerTypes.
Fails loudly at provider construction time instead of producing
silent isStatic=false on the 50000th file analyzed.

Opt-out convention preserved: an explicit `new Set()` (empty Set)
signals intentional exclusion and passes the guard (memory obs #30588).

All 13 existing language configs pass the guard; the new negative test
fails without it. Test-first.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U5)

* fix(pipeline): wrap sequential-fallback in try/finally so cleanup survives throws

The sequential-fallback block in runChunkedParseAndResolve now runs
inside a try/finally that guarantees astCache.clear(), accumulator
finalize, and enrichExportedTypeMap execute even if readFileContents
or processCalls throws mid-fallback. Cleanup failures are caught
inside the finally so they can't mask the original error.

Accumulator disposal ownership remains with crossFile (U2) — U6 only
adds astCache cleanup and preserves finalize ordering on the error
path.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U6)

* test(pipeline): direct unit coverage for wildcard-synthesis and cross-file-impl

Both modules previously had zero direct unit coverage — branches were
exercised only through integration tests' happy paths.

wildcard-synthesis.test.ts covers: Go graph-IMPORTS fallback, Python
moduleAliasMap build, MAX_SYNTHETIC_BINDINGS_PER_FILE cap, dedup
against existing namedImportMap entries, and empty-exportedSymbols
early return.

cross-file-impl.test.ts covers: gapRatio below threshold no-op,
MAX_CROSS_FILE_REPROCESS cap, graph-only exportedTypeMap fallback,
and empty namedImportMap short-circuit.

Tests assert current behavior — any future regression flips them.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U7)

* test(pipeline): golden-file graph-parity regression guard on mini-repo fixture

Pins the current post-P1/P2 graph output (57 symbols, 92 relationships,
4 processes, deterministic edge digest) so future silent refactors
cannot drift behavior unnoticed. If any count changes or any edge
rewires, the test fails with a readable diff listing what changed
and a copy-pasteable UPDATE_GOLDEN=1 regen command.

Edge digest keyed by symbolic (label, name, filePath) triples rather
than raw generateId output — stays meaningful across id-encoding
refactors while still catching real semantic rewiring.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U8)

* fix(pipeline): minimal cycle reporting + resolveEnclosingOwner loop safeguards

U9: runner cycle detection now reports only the SCC members via DFS
back-edge trace ('Cycle detected: A -> B -> C -> A') rather than
everything with inDegree > 0 (which mixed cycle members with blocked
dependents). Also emits the 'error' progress event for graph-
validation failures, symmetric with U1's runtime-error path.

U16: findEnclosingClassInfo now defends against language-provider
hooks that return non-container nodes — visitedContainers Set breaks
repeat-visit loops, MAX_ENCLOSING_WALK_ITERATIONS is belt-and-braces.
Documented the hook contract invariant so future provider authors
know the walk-continues-upward expectation.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U9, U16)

* refactor(pipeline): type hygiene, dead code cleanup, shared allPathSet, graph-sort naming

Bundles plan units U10, U11, U12, U14, U15:

U10 — Type hygiene: readonly ParseOutput arrays (allExtractedRoutes,
allDecoratorRoutes, allToolDefs, allORMQueries, allPaths); removed
redundant 'as string[] | undefined' cast in routes.ts and 'as URL' in
parse-impl.ts; WorkerPool is now 'import type'. Readonly contract
propagated into processORMQueries (only iterates).

U11 — Dead code & shims: deleted constants.ts shim (AST_CACHE_CAP
inlined into its sole real consumer cross-file-impl.ts; isDev
consumers now import directly from ../utils/env.js). Removed internal
utility re-exports from pipeline-phases/index.ts (no external
consumers). Removed topologicalLevelSort re-export from pipeline.ts;
updated topological-sort.test.ts to import from the canonical
utils/graph-sort.js. Stripped 'Phase 3+4:' stale JSDoc from
parse-impl.ts.

U12 — Perf: StructureOutput now carries allPathSet (ReadonlySet<string>)
built once; cobol, markdown, and cross-file-impl consume the shared
set instead of allocating their own. Parse forwards it via
ParseOutput.allPathSet; processCobol/processMarkdown widened to
ReadonlySet<string>.

U14 — graph-sort.ts: renamed local 'inDegree' to
'pendingImportsPerFile' with expanded JSDoc explaining the reverse-
graph Kahn's formulation and warning future maintainers not to
'correct' it to standard in-degree semantics. Added self-edge test.

U15 — Unconditional worker-fallback logging: removed isDev guard on
the worker-pool-creation-failure console.warn so operators can
diagnose perf degradations in production.

No behavior change. U8 golden-file test confirms pipeline output is
byte-identical.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U10, U11, U12, U14, U15)

* docs: fix ARCHITECTURE.md table integrity; bump AGENTS.md/CLAUDE.md to 1.3.0

U13 — documentation fixes:

ARCHITECTURE.md: the prior insertion of the 'Pipeline Phase DAG'
section orphaned 7 rows from the 'Where to change what' header.
Moved those 7 rows back up under their header so the table reads
contiguously; DAG section now follows the completed table.

AGENTS.md + CLAUDE.md: bumped version 1.2.0 -> 1.3.0, updated Last
reviewed to 2026-04-13, added matching Changelog row documenting
the GitNexus index stats refresh after the DAG refactor. Stat
bumps (symbols/relationships/execution flows) that were sitting
uncommitted in the working tree are now landed under a proper
changelog entry per each file's own documented schema.

Plan: docs/plans/2026-04-13-001-fix-pipeline-dag-refactor-review-findings-plan.md (U13)

* refactor(pipeline): drop spurious parse deps, true-readonly ParseOutput.exportedTypeMap, skip redundant wildcard synth

- mro/communities/processes: switch redundant `parse` dep to `structure` —
  totalFiles originates in structure, so depending on parse for it was a
  spurious data dep that obscured the real DAG.
- ParseOutput.exportedTypeMap: typed as truly ReadonlyMap<...,ReadonlyMap>>;
  graph→exports enrichment moved into parse-impl so the snapshot is
  fully populated at parse return. crossFile builds its own local mutable
  working copy for per-file re-resolution writes — no cast at the boundary.
- parse-impl: hasSynthesized flag guards the unconditional final
  synthesizeWildcardImportBindings call when per-chunk/fallback synthesis
  already ran (graph-global + idempotent across chunks).
- cross-file-impl: documented the intentional `phase: 'parsing'` progress
  label so telemetry bucketing stays consistent with the parse phase.
- cross-file-impl test: replaced the now-moved fallback-enrichment
  assertion with a stronger one — crossFile must not mutate the
  parse-supplied map.

Addresses PR #809 review pass 5 carry-overs.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-13 20:31:05 +01:00
Copilotandmagyargergo 6388113e10 fix: prevent stack overflow and memory exhaustion on large repo analysis (#814)
* Initial plan

* fix: prevent stack overflow and memory issues on large repo analysis

- Convert c3Linearize from recursive to iterative (explicit work stack)
  to handle deep class hierarchies without stack overflow
- Replace push(...arr) spread patterns with safe loops in parse-worker.ts
  and lbug-adapter.ts to prevent stack overflow on large arrays
- Stream relationship CSV lines directly to per-pair temp files in
  lbug-adapter.ts instead of accumulating millions of lines in memory
- Add test for deep 500-level inheritance chain

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: add stack size flag and enhanced error messages for large repos

- Auto-set --stack-size=4096 alongside --max-old-space-size in analyze
  command to prevent stack overflow on deep class hierarchies
- Add helpful error guidance for known large-repo failure modes
  (stack overflow, heap OOM, Map size limits)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address code review feedback

- Add error handling for write stream close in lbug-adapter.ts
- Handle backpressure when writing relationship CSV lines to disk
- Clarify ENTER/MERGE phase transition comment in resolve.ts
- Fix inconsistent stack size in error message (4096 not 8192)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9cb2eed2-adc7-4fa4-9216-e7ac3facb9b5

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address PR review — resource leak, redundant guard, Set, test depth

- Fix write-stream resource leak on readline error by destroying all
  open WriteStreams before rejecting (lbug-adapter.ts)
- Switch failedPairCsvPaths from array to Set for O(1) lookup
- Remove redundant MERGE-phase empty-parents guard in resolve.ts
  (unreachable — ENTER phase already handles that case)
- Increase deep inheritance test DEPTH from 500 to 2000 for
  reliable regression coverage across platforms

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cf1f3e22-3864-454a-a3a5-2bded9ebfdba

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: fix prettier formatting in lbug-adapter.ts

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unintended package.json/lock changes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b5ca33c4-bb03-402f-a206-21ea7e1e310e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: strip NODE_OPTIONS in skip-git-cli test child processes

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: don't put --stack-size in NODE_OPTIONS (rejected by Node 24)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: pass --stack-size as CLI arg only, not in NODE_OPTIONS (Node 24 compat)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fc59cd11-348b-4e22-b9ea-98787300de48

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-13 20:08:49 +01:00
Copilotandmagyargergo c672697012 fix: replace tree-sitter-dart git URL with tarball to fix npm install crash (#811)
* Initial plan

* fix: replace tree-sitter-dart git URL with tarball URL to fix npm install crash

The `github:` git URL for tree-sitter-dart caused npm's arborist to
create a dependency node with a null target during the rebuild phase,
crashing global installs with:
  Cannot destructure property 'package' of 'node.target' as it is null.

Using a GitHub archive tarball URL instead avoids the arborist bug while
still installing from the exact same commit (80e23c0).

Fixes #805

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c15109ae-0865-4d69-bd08-9972dcfe18f9

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-13 18:57:56 +01:00
Deepak Chauhan d786e692af [cli] Preserve Ruby singleton_class context in sequential parsing (#774)
* fix(parsing): preserve ruby singleton class context

* refactor(parsing): clarify singleton class helpers
2026-04-13 13:12:52 +01:00
Arkh74278andarkh a6421b3b1b [dart] Add call patterns for await, cascade, lambda, and widget-tree contexts (#801)
* feat(dart): add call patterns for await, cascade, lambda, and widget-tree contexts

* fix(dart): address review feedback — await member-chain, cascade comment, static_final comment, add to query-compilation smoke test

* test(dart): add integration tests for await and widget-tree call patterns

* style: apply prettier formatting to dart integration tests

---------

Co-authored-by: arkh <local@localhost>
2026-04-13 11:21:11 +01:00
Copilotandmagyargergo 9f4109a33f fix: remove file:../gitnexus-shared from runtime dependencies (#803)
* Initial plan

* fix: remove file:../gitnexus-shared from dependencies to fix npm install outside monorepo

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2ea9c1da-1b0c-4ab0-b370-f3970cc54ffa

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-13 10:17:30 +01:00
Copilotandmagyargergo 79e1d933fa fix: resolve generic TypeScript awaited function calls missing from call graph (#804)
* Initial plan

* fix: resolve generic TypeScript function callers missed by impact analysis

When a generic function call is combined with `await` (e.g. `await fn<T>(args)`),
tree-sitter-typescript parses it as a `call_expression` whose `function` field is
an `await_expression` rather than a bare `identifier`. The existing queries only
matched `call_expression { function: identifier }`, so these calls produced no
`@call.name` capture and were silently dropped from the call graph.

Fix: add two new tree-sitter query patterns to `TYPESCRIPT_QUERIES` that handle:
1. `await fn<T>(args)` — awaited generic free call
2. `await obj.fn<T>(args)` — awaited generic member call

Both patterns require the `(type_arguments)` child to be present (which is what
causes tree-sitter to parse the `function` field as an `await_expression`).
Non-generic awaited calls (`await fn(args)`) are unaffected: tree-sitter parses
them as `await_expression { call_expression { identifier } }`, which is still
captured by the existing first pattern.

Also adds a new test fixture `typescript-generic-calls` with two callers of a
generic `verifyToken<T>` function using `await` and three new integration tests.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: clean up test fixture interface ordering and imports

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4cf75290-900b-4cea-8a65-2a245ff86970

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: add coverage for awaited generic member-call form (await obj.fn<T>())

Address review feedback: the member-call query pattern was untested.

Adds service.ts (TokenService with generic verify<T> method) and guest.ts
(calls await svc.verify<GuestPayload>()) to the typescript-generic-calls
fixture, plus a new integration test asserting the CALLS edge resolves.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* revert: undo accidental ladybugdb version bump in package files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fcbf8d99-8dbc-40ce-b2a3-60b8d63c095a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style: run prettier on changed files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8c7d8291-74bb-4a86-ae47-7c79e2cbb57e

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-04-13 10:16:42 +01:00
ivkondandClaude 4d4756fe86 feat(group): extractor expansion + manifest extractor (2/4 of #606 split) (#796)
* feat(group): extractor expansion + manifest extractor

Part 2 of 4 in the split of #606 (ticket: #792). Follows #795
(bridge.lbug storage foundation, already merged), but this PR has no
code-level dependency on #795 — it only imports types and the
ContractExtractor interface that existed on upstream main before
either PR. It could have been reviewed in parallel with #795.

## What changed

Expands the 3 existing contract extractors with substantially more
language/framework coverage, and adds a new `manifest-extractor`
that resolves `group.yaml`-declared cross-links against the per-repo
graph via exact-name lookups.

### New file (228 LOC)

- `gitnexus/src/core/group/extractors/manifest-extractor.ts` —
  exact graph lookup for `group.yaml`-declared cross-links. HTTP
  paths are canonicalized before Route.name matching; gRPC is
  resolved by service/method name (NO `.proto`-filename fallback);
  topic and lib use exact-name match. Falls back to a synthetic
  `manifest::<repo>::<contractId>` uid when the graph has no
  matching symbol, so cross-impact traversal still has a stable
  anchor for the contract.

### Modified extractors (+958 LOC prod)

- `extractors/grpc-extractor.ts` (+522) — `.proto` parser with
  comment and string-literal sanitization (braces inside strings no
  longer truncate service bodies); package/service/method canonical
  IDs; server/client detection across Go (`grpc.NewServer`,
  `RegisterXxxServer`, `XxxGrpc.XxxImplBase`), Java (`@GrpcService`,
  `BlockingStub`), Python (`servicer_to_server`, `XxxStub`), and
  TypeScript/Node (`@GrpcMethod`, `ClientGrpc`, `loadPackageDefinition`).
- `extractors/http-route-extractor.ts` (+174) — Go gin/echo/stdlib
  `HandleFunc`, NestJS `@Controller`+`@Get`/etc, Python FastAPI
  decorators, Java Spring `@RequestMapping`/`@GetMapping`,
  restTemplate / WebClient / OkHttp consumers.
- `extractors/topic-extractor.ts` (+98) — sarama `ProducerMessage{}`
  struct literal detection (replaces a constructor-anchored regex
  that missed topics inside producer loops), kafka-go Writer/Reader,
  Python NATS (`await nc.subscribe`/`await nc.publish`), JetStream
  helpers.

### Modified and new tests (+1264 LOC)

- `grpc-extractor.test.ts` (+539) — full coverage of the new proto
  parser (strings-with-braces regression, comments-with-braces
  regression), per-language server/client detection
- `http-route-extractor.test.ts` (+240) — per-framework route
  extraction + normalization edge cases
- `topic-extractor.test.ts` (+177) — the sarama in-loop regression,
  JetStream, Python NATS, kafka-go Writer/Reader
- `manifest-extractor.test.ts` (+308 NEW) — HTTP path normalization,
  gRPC exact lookup with proto-fallback regression, lib and topic
  exact matching, synthetic-uid fallback behavior

### Self-review fixes folded in

Carried forward from the #606 self-review (commit `d15b8cb`):

- **HIGH #1** — `manifest-extractor.resolveSymbol` was too fuzzy.
  Previously used `CONTAINS` on route/name fields plus an
  unconditional `filePath ENDS WITH '.proto'` fallback for gRPC.
  Consequences: `/orders` matched `/suborders`, and any repo with
  any `.proto` file returned a random proto symbol for a gRPC
  manifest entry. Replaced with exact equality + deterministic
  `ORDER BY` + synthetic-uid fallback for unresolved manifests.
  Regression tests included.
- **MED #3** — gRPC proto parser brace-depth counting now sanitizes
  strings and comments first (`stripProtoCommentsAndStrings`). A
  valid proto with `option deprecated_reason = "use NewService {
  instead"` used to have its service body closed early by the `"{"`
  inside the literal, silently dropping methods after the offending
  string. Regression tests for both string-with-brace and
  comment-with-brace cases.
- **MED #4** — sarama Kafka regex changed from
  `sarama.NewSyncProducer[\s\S]{0,300}?Topic:` (anchored on
  constructor, caught only first topic in a loop) to
  `sarama.ProducerMessage{...Topic:}` (matches every struct literal
  directly). Regression test with a for-loop that constructs
  multiple `ProducerMessage`s.
- **MED #7** — `manifest-extractor.resolveSymbol` no longer has a
  silent `catch { /* fall through */ }`. Errors from the graph
  executor are logged via `console.warn` with link type, contract
  name, repo key, and error message before falling through to the
  synthetic-uid path.

## Why

Reviewer focus here is pure regex / parser correctness — no
storage, no Cypher queries, no algorithmic changes to the cross-link
algorithm. Separating this from the bridge foundation PR (#795)
meant reviewers could stay in a single mental mode (parsing logic)
instead of context-switching between DDL, Cypher, and regex.

## How to verify

- `cd gitnexus && npx tsc --noEmit`
- `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/http-route-extractor.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/topic-extractor.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/manifest-extractor.test.ts --pool=forks`

Local pre-push: typecheck clean, all 99 extractor unit tests pass
(grpc 43, http 18, topic 30, manifest 8).

## Risk / rollback

**Low.** Extractors have no user-facing surface in this PR — they
produce `ExtractedContract[]` that is consumed by `sync.ts` in the
next split (#793). No existing behavior changes for users who don't
run a `group sync`. Rollback = `git revert` of the merge commit;
the modifications to `grpc-extractor.ts` / `http-route-extractor.ts`
/ `topic-extractor.ts` revert to the pre-PR versions that still
work (they're subsets of the new functionality).

## Scope discipline (per GUARDRAILS.md)

- Only the 8 files above are touched; no drive-by refactors
- No CI/release/security config changes
- No secrets or machine-specific paths
- Content lifted from #606 (CI 11/11 green on `d15b8cb`)

## Dependencies

- **Base:** `main` (upstream already includes #795 as `1ff324c`)
- **Blocks:** sync pipeline (#793) and the cross-impact feature (#794)
- **Tracker issue:** #792
- **Parent PR:** #606

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(group): migrate topic-extractor from regex to tree-sitter queries

Addresses @magyargergo's feedback on #796 that regex-based lookups
should use tree-sitter nodes instead, and that the top-level
extractors must NOT carry language dependencies. This is phase 1 of
a multi-step migration — topic-extractor first because its patterns
are the most uniform (16 "call/annotation with first-arg string
literal" variants), which makes it a clean proof of the approach
before grpc-extractor and http-route-extractor get the same treatment.

## Architecture: language-agnostic orchestrator + per-language plugins

The top-level extractor is a thin orchestrator that never imports a
tree-sitter grammar or a query string. Per-language knowledge lives
in a new `topic-patterns/` folder with one file per language plus a
registry that maps file extensions to compiled plugins:

```
src/core/group/extractors/
├── tree-sitter-scanner.ts         # shared, language-agnostic scanning utilities
├── topic-extractor.ts              # thin orchestrator (no grammar imports)
└── topic-patterns/
    ├── types.ts                    # TopicMeta, Broker
    ├── index.ts                    # registry: extension → compiled provider
    ├── java.ts                     # tree-sitter-java + JAVA_TOPIC_PROVIDER
    ├── go.ts                       # tree-sitter-go + GO_TOPIC_PROVIDER
    ├── python.ts                   # tree-sitter-python + PYTHON_TOPIC_PROVIDER
    └── node.ts                     # tree-sitter-javascript + tree-sitter-typescript
                                    # → JAVASCRIPT_/TYPESCRIPT_/TSX_TOPIC_PROVIDER
```

**Shared scanner (`tree-sitter-scanner.ts`)** — defines
`PatternSpec<TMeta>`, `LanguagePatterns<TMeta>`, `CompiledPatterns<TMeta>`
and the `scanFile(parser, plugin, content)` helper. Plugins compile their
queries eagerly at module load via `compilePatterns()`, so a broken
pattern fails loudly at import time instead of silently at scan time.
`unquoteLiteral()` handles single/double/template quotes, Python
triple-quoted strings, and Go raw backtick strings.

**Per-language plugins** own:
- the tree-sitter grammar import (this is the ONLY place in
  `src/core/group/` where tree-sitter grammars are imported),
- the query S-expressions,
- the `TopicMeta` payload (role, broker, confidence, symbolName) that
  the orchestrator receives back on every match.

Each plugin uses a `@value` capture name to bind the topic literal node.
The JavaScript and TypeScript grammars share AST node names for every
construct we query, so `node.ts` defines the pattern sources once and
compiles them against `JavaScript`, `TypeScript.typescript`, and
`TypeScript.tsx` — exporting three providers because `Parser.Query`
objects are NOT portable across grammar instances.

**Registry (`topic-patterns/index.ts`)** — maps `.java` → Java provider,
`.go` → Go, `.py` → Python, `.js`/`.jsx` → JS, `.ts` → TS, `.tsx` → TSX.
Also exports `TOPIC_SCAN_GLOB` so adding a new language is a single
file-level edit (drop `topic-patterns/<lang>.ts`, import + register it
here — zero edits required in `topic-extractor.ts`).

**Orchestrator (`topic-extractor.ts`)** — ~110 lines, no grammar or
query imports. Per file: `getProviderForFile(rel)` → `scanFile(parser,
provider, content)` → `unquoteLiteral(valueText)` → `makeContract(...)`.
Reuses one `Parser` instance across files; the scanner calls
`setLanguage` per plugin.

## Why this is better than regex

1. **Comments and strings are respected for free.** The old regex
   would match `// kafkaTemplate.send("fake.topic")` as a real
   producer; tree-sitter never visits comments or string literals as
   code nodes, so false positives from commented-out code are
   eliminated.
2. **Struct/object literal patterns are structural, not textual.**
   `sarama.ProducerMessage{Topic: "..."}` no longer needs a 300-char
   lookahead (which was a known cross-match bug partly mitigated by a
   loop regression test in the self-review). The new query matches a
   specific `composite_literal` with a specific `qualified_type` and
   `keyed_element` — exactly one struct literal per match.
3. **No order-of-operations fragility.** Regex for
   `channel.publish` vs `channel.consume` was independent and
   file-wide; the AST scopes matches to the specific `call_expression`.
4. **Language-agnostic extension.** Adding Ruby, Rust, or C# topic
   detection later means dropping one file in `topic-patterns/` — no
   changes to shared scanner or orchestrator, and no tree-sitter
   imports leak into top-level code.

## Per-file fault tolerance

- Malformed files that tree-sitter can't parse are silently skipped
  (`parser.parse` is wrapped by `scanFile`). The ingestion pipeline
  already logs unparseable files at index time.
- A syntactically invalid query is caught at `compilePatterns` time,
  not scan time — broken plugins fail loudly at import.
- Per-pattern `matches()` failures are swallowed so one broken query
  in a plugin doesn't block the rest.

## Tests

All 30 existing `topic-extractor.test.ts` tests pass **without any
changes to the test file** — they were written as input/output contract
tests (given this source file, expect these `ExtractedContract` objects)
and that contract is unchanged. Regression coverage includes:

- Kafka: Java `@KafkaListener` + `kafkaTemplate.send`; Node
  `producer.send` + `consumer.subscribe`; Go sarama producer/consumer
  (sync and async); kafka-go Writer/Reader; Python `KafkaConsumer` +
  `producer.send/produce`
- RabbitMQ: Java `@RabbitListener` + `rabbitTemplate.convertAndSend`;
  Node `channel.consume/publish/sendToQueue`; Python `basic_consume/
  basic_publish` with keyword args
- NATS: Go and Node `nc.Subscribe/Publish`; Go and Node JetStream
  `js.Subscribe/Publish`; Python `await nc.subscribe/publish`

Including the regression test for the sarama `ProducerMessage`
in-loop case — the AST-based query captures every literal in the
file independently, not just the first one after `NewSyncProducer`.

## Neighbor regression check

- `topic-extractor.test.ts` — 30/30 pass (rewritten extractor)
- `http-route-extractor.test.ts` — 18/18 pass (untouched)
- `grpc-extractor.test.ts` — 43/43 pass (untouched)
- `manifest-extractor.test.ts` — 8/8 pass (untouched)
- Full `npx tsc --noEmit` clean

## Scope discipline (per GUARDRAILS.md)

- Only files under `src/core/group/extractors/` are touched; no
  changes to other extractors, tests, MCP surface, or pipeline.ts.
- No CI/release/security config changes, no secrets.
- New tree-sitter imports all reference grammars that are already
  installed as dependencies (`tree-sitter`, `tree-sitter-javascript`,
  `tree-sitter-typescript`, `tree-sitter-python`, `tree-sitter-java`,
  `tree-sitter-go` — all in `package.json` for the existing pipeline).

## Phase 2 / phase 3 plan

- **Phase 2 (next commit):** rewrite `http-route-extractor.ts`
  Strategy B (regex fallback) on the same plugin pattern. Graph-assisted
  Strategy A stays as-is (already uses pipeline-built tree-sitter data
  via `HANDLES_ROUTE` Cypher queries).
- **Phase 3 (commit after):** rewrite `grpc-extractor.ts` for Java /
  Go / Python / TypeScript detection. `.proto` files are the one
  outstanding question — there is no `tree-sitter-proto` grammar
  installed; the in-tree string-sanitizing parser stays as a pragmatic
  exception with a comment, alternative being to add
  `tree-sitter-proto` as a dep (open for the maintainer).

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(group): migrate http-route-extractor Strategy B to tree-sitter plugins

Phase 2 of the extractor refactor requested by @magyargergo on #796.
Same architecture as the phase 1 topic-extractor rewrite: a thin,
language-agnostic orchestrator plus per-language plugins that own
tree-sitter grammars and query sources. The top-level extractor file
no longer imports any tree-sitter grammar or query string.

## Architecture

```
src/core/group/extractors/
├── tree-sitter-scanner.ts          # shared, language-agnostic primitives
├── http-route-extractor.ts         # thin orchestrator (no grammar imports)
└── http-patterns/
    ├── types.ts                    # HttpDetection, HttpLanguagePlugin, HttpRole
    ├── index.ts                    # registry: ext → plugin + HTTP_SCAN_GLOB
    ├── java.ts                     # tree-sitter-java: Spring + RestTemplate/WebClient/OkHttp
    ├── go.ts                       # tree-sitter-go: gin/echo/HandleFunc + http/resty consumers
    ├── python.ts                   # tree-sitter-python: FastAPI + requests
    ├── php.ts                      # tree-sitter-php: Laravel Route::get/...
    └── node.ts                     # tree-sitter-javascript + tree-sitter-typescript:
                                    #   NestJS controllers, Express, fetch, axios
```

**Shared scanner (`tree-sitter-scanner.ts`)** — generalised from phase 1:
- `ScanMatch<TMeta>.captures` is now a full `CaptureMap` (every named
  capture the query binds, not just a single `@value`). Topic extractor
  updated to read `match.captures.value` accordingly.
- New `runCompiledPatterns(plugin, tree)` helper lets plugins run
  multiple query bundles against the same pre-parsed tree. This is
  needed for HTTP plugins that combine a class-prefix query with a
  method-route query (Spring, NestJS).
- `scanFile` becomes a thin wrapper over `parser.parse + runCompiledPatterns`.

**HTTP plugin shape** — unlike topic plugins, HTTP plugins expose a
`scan(tree)` function rather than a flat pattern list. This reflects
HTTP's more complex extraction: each detection needs method + path +
handler name, and framework patterns like Spring `@RequestMapping` /
NestJS `@Controller` require cross-referencing a class-level prefix
with method-level annotations. Plugins internally use
`compilePatterns` + `runCompiledPatterns` and walk the AST to resolve
the class/method relationships.

**Per-framework coverage:**

- **Java (`java.ts`)**
  - Spring: `@RequestMapping("/api/v2")` class prefix + `@(Get|Post|Put|
    Delete|Patch)Mapping("/sub")` method routes, joined via the
    enclosing `class_declaration` node id.
  - `RestTemplate.getForObject/postForEntity/put/delete/patchForObject` →
    method derived from API name.
  - `WebClient.method(HttpMethod.X, "/path")` → method from
    `HttpMethod.X` capture.
  - `new Request.Builder().url("/path")` → OkHttp consumer.

- **Go (`go.ts`)**
  - gin / echo / chi frameworks: `\w+.GET("/path", handler)` captures
    upper-case verb + handler identifier.
  - `net/http.HandleFunc("/path", handler)` → provider (default GET).
  - `http.Get/Post/Head` consumer, `http.NewRequest("METHOD", ...)`,
    resty `client.R().Get/Post/...`.

- **Python (`python.ts`)**
  - `@app.get("/path")` FastAPI decorators.
  - `requests.get/post/...` and `requests.request("METHOD", "url")`.

- **PHP (`php.ts`)**
  - Laravel `Route::get/post/.../patch('/path', ...)` via
    `scoped_call_expression`. Uses `PHP.php_only` to match the
    existing ingestion pipeline's grammar selection.

- **Node (`node.ts`) — JS + TS + TSX**
  - Pattern sources defined once, compiled against three grammar
    variants (`JavaScript`, `TypeScript.typescript`, `TypeScript.tsx`)
    because `Parser.Query` objects are not portable across grammars.
    Exports three plugins sharing the same `scan` logic.
  - NestJS: `@Controller('prefix')` decorators are siblings of the
    class in `export_statement` / `program`; `@Get(':id')` decorators
    are siblings of the method in `class_body`. The plugin walks
    decorator → next named sibling to find the decorated class /
    method, then combines the class prefix with the method path.
    Only emits NestJS detections when the enclosing class has a real
    `@Controller` decorator — prevents false positives from generic
    classes that happen to use `@Get` from another library.
  - Express: `(router|app).<verb>('/path', ...)`.
  - `fetch(url)` (default GET) + `fetch(url, { method: 'X' })`
    (uses two queries + a SyntaxNode-id dedupe set so URL literals
    aren't double-emitted by the options variant).
  - `axios.get/post/...`.

## Orchestrator changes

`http-route-extractor.ts` drops every `scanXxxProviders` / `scanXxxConsumers`
regex method and replaces them with a single source-scan loop that
delegates to `getPluginForFile(rel).scan(tree)`. The orchestrator
still owns:

- **Path normalization** (`normalizeHttpPath`, `normalizeConsumerPath`)
  — language-agnostic string processing shared by both strategies.
- **Graph-assisted Strategy A** (`HANDLES_ROUTE` / `FETCHES` / `CONTAINS`
  Cypher queries) — unchanged in spirit. The only regex helpers it
  used (`inferMethodFromFileScan`, `pickJavaHandlerName`) are now
  replaced by a lookup against the plugin's detections for the same
  file: for each route row, find the detection whose normalized path
  matches, and pull the HTTP method + handler name from it.
- **Per-file parse cache** — the orchestrator parses each relevant
  file at most once per `extract()` call. Both the graph-assisted
  enrichment loop and the source-scan fallback share the same
  `cachedDetections` map, so we never run the plugin twice for the
  same file.

## Why this is better than the regex version

1. **Comments and strings for free.** The old regex would match
   `// router.get('/fake')` as a real Express route; tree-sitter
   never visits string/comment nodes.
2. **Structural controller-prefix.** Spring and NestJS class-prefix
   joining is now scoped to the enclosing class via `class_declaration`
   node ids, eliminating file-wide state that broke when a file had
   multiple controllers.
3. **Precise NestJS disambiguation.** The plugin only emits a NestJS
   detection when the enclosing class has a real `@Controller`
   decorator — the old regex would fire on any `@Get(...)` in the
   file regardless of surrounding context.
4. **Language-agnostic extension.** Adding Ruby / Rust / Kotlin HTTP
   detection later means dropping one file in `http-patterns/` — no
   changes to the shared scanner, the orchestrator, or the Strategy A
   Cypher queries.

## Tests

- `http-route-extractor.test.ts` — **18/18 pass** (tests unchanged;
  they're contract-style input/output tests and the contract shape is
  unchanged). Covers Spring class prefix, Express, gin/echo, stdlib
  HandleFunc, NestJS, Laravel, FastAPI for providers and
  fetch/axios/python-requests/rest-template/webClient/okhttp/go-stdlib/
  resty for consumers, plus graph-first Strategy A for both.
- `topic-extractor.test.ts` — **30/30 pass** after the `captures.value`
  API migration.
- `grpc-extractor.test.ts` — 43/43 pass (untouched; phase 3).
- `manifest-extractor.test.ts` — 8/8 pass (untouched).
- `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass.
- `npx tsc -p tsconfig.json --noEmit` clean.

## Scope discipline (per GUARDRAILS.md)

- Only files under `src/core/group/extractors/` are touched.
- No changes to pipeline.ts, MCP surface, ingestion, or tests.
- No CI / release / security / secrets changes.
- Tree-sitter grammars imported by plugins (`tree-sitter-java`,
  `tree-sitter-go`, `tree-sitter-python`, `tree-sitter-php`,
  `tree-sitter-javascript`, `tree-sitter-typescript`) are all already
  in `package.json` for the existing ingestion pipeline.

## Phase 3 plan

- **grpc-extractor** gets the same treatment: plugin-per-language under
  `grpc-patterns/` for Java / Go / Python / TS detection. `.proto`
  files remain an open question — no `tree-sitter-proto` grammar is
  installed, so the in-tree string-sanitizing parser from PR #796's
  self-review stays as a pragmatic exception unless the maintainer
  wants us to add `tree-sitter-proto` as a new dep.

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(group): migrate grpc-extractor source scans to tree-sitter plugins

Phase 3 (final) of the extractor refactor requested by @magyargergo on
#796. Same architecture as phase 1 (topic) and phase 2 (http): thin
language-agnostic orchestrator + per-language plugins that own
tree-sitter grammars and query sources. With this commit the top-level
extractors under `src/core/group/extractors/` import ZERO tree-sitter
grammars and ZERO query strings — every grammar import lives in a
`*-patterns/<lang>.ts` plugin file, and the orchestrators go through
the registry indirection.

## Architecture

```
src/core/group/extractors/
├── tree-sitter-scanner.ts         # shared primitives (unchanged)
├── grpc-extractor.ts               # orchestrator (only `.proto` parser left)
└── grpc-patterns/
    ├── types.ts                    # GrpcDetection, GrpcLanguagePlugin, GrpcRole
    ├── index.ts                    # registry: ext → plugin + GRPC_SCAN_GLOB
    ├── go.ts                       # tree-sitter-go: RegisterXxxServer, Unimplemented, NewXxxClient
    ├── java.ts                     # tree-sitter-java: @GrpcService + XxxImplBase + newBlockingStub
    ├── python.ts                   # tree-sitter-python: add_XxxServicer_to_server + XxxStub
    └── node.ts                     # tree-sitter-javascript + tree-sitter-typescript:
                                    #   @GrpcMethod, @GrpcClient field type,
                                    #   .getService<X>('Svc'), new XxxServiceClient,
                                    #   loadPackageDefinition dynamic constructors
```

## Per-language coverage

**Go (`go.ts`)**
- Provider: `\w+.RegisterXxxServer(...)` via `call_expression →
  selector_expression → field_identifier` + JS regex filter
  `^Register(\w+)Server$`.
- Provider: `pb.UnimplementedXxxServer` embedded in a struct via
  `struct_type → field_declaration_list → field_declaration →
  qualified_type → type_identifier` + JS filter.
- Consumer: `\w+.NewXxxClient(...)` via the same call_expression
  query + JS filter `^New(\w+)Client$`.

**Java (`java.ts`)**
- Provider: `class X extends YyyGrpc.YyyImplBase` — two queries
  handle the scoped and plain forms. `scoped_type_identifier`'s
  children are positional (no `scope:`/`name:` fields), so the
  query matches the two `type_identifier` children by position.
- `#match? @inner "ImplBase$"` restricts matches at query time.
- Whether the class has `@GrpcService` or not controls only the
  `source` metadata label — the plugin walks the class_declaration's
  `modifiers` child in JS to detect the marker_annotation.
- Consumer: `YyyGrpc.newStub(ch)` / `newBlockingStub(ch)` via a
  `method_invocation` query with `#match? @method
  "^new(Blocking)?Stub$"`, service name extracted via
  `^(\w+)Grpc$` on the object identifier.

**Python (`python.ts`)**
- Single call-expression query covers both bare identifier and
  `obj.method` attribute forms:
  `(call function: [(identifier) @fn (attribute attribute: (identifier) @fn)])`.
- Plugin filters `@fn.text` against two JS regexes:
  `^add_(\w+)Servicer_to_server$` (provider) and `^(\w+)Stub$`
  (consumer), with a reserved-names ignore list for the Stub case
  (Mock / Test / Fake / Stub).

**Node — JavaScript + TypeScript + TSX (`node.ts`)**
- Pattern sources defined once, compiled three times (one per grammar)
  because `Parser.Query` objects are not portable across grammars.
  Exports three `GrpcLanguagePlugin`s sharing the same `scan`.
- `@GrpcMethod('Service', 'Method')`: decorator query captures the
  two string literals. Confidence is hard-coded 0.8 regardless of
  proto map resolution (matches the original regex version's
  behaviour).
- `@GrpcClient(...) field: XxxServiceClient`: decorator query
  captures the decorator node, plugin walks up to find the enclosing
  `public_field_definition` (decorators on fields are CHILDREN of
  the field definition in tree-sitter-typescript, not siblings) and
  reads its first `type_annotation → type_identifier`, then runs the
  `^(\w+Service)Client$` JS filter.
- `client.getService<X>('AuthService')`: call-expression query on
  `member_expression.property = "getService"` + string literal arg.
- `new XxxServiceClient(...)`: `new_expression` with a bare
  identifier constructor, filtered by `^(\w+Service)Client$` so
  generic `new AuthClient(...)` (missing the `Service` infix) does
  NOT falsely register as a consumer. Preserves the regression test
  `test_extract_ts_non_service_client_constructor_is_ignored`.
- `loadPackageDefinition` dynamic loader: gated on
  `tree.rootNode.text.includes('loadPackageDefinition')`. When set,
  `new foo.bar.Xxx(...)` qualified constructors with a capitalised
  property name register as consumers.

## Orchestrator changes

`grpc-extractor.ts` loses every `scanGoProviders` / `scanJavaProviders`
/ ... helper and replaces them with a single source-scan loop that:

1. Parses each file with the plugin's grammar (one shared `Parser`
   instance across all files, `setLanguage` called per plugin).
2. Calls `plugin.scan(tree)` to get `GrpcDetection[]`.
3. Converts each detection to an `ExtractedContract` via the private
   `detectionToContract` helper, which:
   - Looks the short service name up in the proto map (filled by
     the `.proto` parser).
   - Picks confidence = `confidenceWithProto` if resolved, else
     `confidenceWithoutProto`.
   - Builds a method-level contract id (`grpc::pkg.Svc/Method`) when
     the detection carries a `methodName` (TS `@GrpcMethod` only),
     otherwise a service-level id (`grpc::pkg.Svc/*`).

Everything else — the `.proto` parser, `buildProtoContext`,
`buildProtoMap`, `resolveProtoConflict`, `serviceContractId`,
`stripProtoCommentsAndStrings`, `extractServiceBlocks`, the dedupe
function — stays exactly as before. The `.proto` parser is kept as a
pragmatic exception to the "no regex in extractors" rule because no
`tree-sitter-proto` grammar is installed in the repo; a comment at the
top of the file explains this and flags the maintainer option of
adding `tree-sitter-proto` as a dependency.

## Why this is better than the regex version

1. **Comments and strings are respected for free.** Matched node types
   are only code constructs, never text inside comments or string
   literals.
2. **No false positives on partial names.** The old `(\w+?)Grpc`-style
   regexes would cross-match unrelated identifiers; structural queries
   restrict matches to the exact AST shape (`scoped_type_identifier →
   type_identifier` pairs, `method_invocation → identifier` etc.).
3. **NestJS `@GrpcClient` is structural, not regex-based.** The old
   regex required a specific textual layout
   (`@GrpcClient(...) private readonly foo!: XxxServiceClient`); the
   plugin now walks the AST, so modifier order / optional modifiers /
   multi-line formatting don't break it.
4. **Language-agnostic extension.** Adding Kotlin / Rust / C# gRPC
   detection later is a one-file edit in `grpc-patterns/index.ts` —
   no touches to the shared scanner, the orchestrator, or the proto
   parser.

## Tests

- `grpc-extractor.test.ts` — **43/43 pass** (tests unchanged; the
  contract shape is identical). Covers .proto parsing (including the
  brace-inside-string regression), Go provider/consumer,
  Java @GrpcService / plain ImplBase provider + newBlockingStub
  consumer, Python servicer + stub, TS @GrpcMethod + @GrpcClient +
  .getService + new XxxServiceClient + loadPackageDefinition + the
  `AuthClient` vs `AuthServiceClient` discrimination, dedupe across
  multiple patterns in one file, proto-aware confidence, and the
  inherited-package resolution for split proto definitions.
- `topic-extractor.test.ts` — 30/30 pass.
- `http-route-extractor.test.ts` — 18/18 pass.
- `manifest-extractor.test.ts` — 8/8 pass.
- `service.test.ts`, `sync.test.ts`, `storage.test.ts` — 41/41 pass.
- `npx tsc -p tsconfig.json --noEmit` clean.

## Scope discipline (per GUARDRAILS.md)

- Only files under `src/core/group/extractors/` are touched.
- No pipeline.ts, MCP surface, ingestion, CI / release / security, or
  test changes.
- New tree-sitter grammar imports (`tree-sitter-go`, `tree-sitter-java`,
  `tree-sitter-python`, `tree-sitter-javascript`, `tree-sitter-typescript`)
  are all already installed for the ingestion pipeline.

## End of phase series

This commit completes the three-phase extractor refactor:
  - **Phase 1** (`ea06d11`): topic-extractor → `topic-patterns/`
  - **Phase 2** (`b6015f6`): http-route-extractor → `http-patterns/`
  - **Phase 3** (this commit): grpc-extractor → `grpc-patterns/`

Every remaining regex-based extractor helper under the `src/core/group/
extractors/` directory is either (a) language-agnostic string
processing (path normalization, dedupe keys) or (b) the `.proto`
parser, which is documented as an explicit exception.

Co-authored-by: Claude <noreply@anthropic.com>

* feat(group): add tree-sitter-proto for .proto file parsing

Addresses @magyargergo's suggestion on #796 to replace the manual
string-sanitizing .proto parser with a tree-sitter grammar.

- **Vendored `tree-sitter-proto`** in `vendor/tree-sitter-proto/`.
  Grammar source from [coder3101/tree-sitter-proto](https://github.com/coder3101/tree-sitter-proto)
  (latest `grammar.js`), parser.c regenerated with `tree-sitter-cli
  0.24` to produce ABI version 14 — compatible with the project's
  `tree-sitter 0.25` runtime (which supports ABI ≤ 14). Added as
  `optionalDependency` with `file:./vendor/tree-sitter-proto`.

- **New `grpc-patterns/proto.ts` plugin** — uses the same
  `compilePatterns` + `runCompiledPatterns` infrastructure as every
  other plugin. Two queries:
  - `(package (full_ident) @pkg)` — package declaration
  - `(service (service_name) @service_name (rpc (rpc_name) @rpc_name))`
    — one match per (service, rpc) pair

- **Graceful fallback** — `tree-sitter-proto` is an optional
  dependency. If it fails to install (platform incompatibility) or
  fails the runtime smoke-test (`setLanguage` + `parse` on a trivial
  proto), `PROTO_GRPC_PLUGIN` stays `null` and the orchestrator
  uses the existing manual parser. The smoke-test catches the
  `SyntaxNode` TDZ error that occurs in vitest's fork-based test
  runner.

- **Orchestrator updated** — when `hasProtoPlugin` is true, `.proto`
  files are handled by the plugin loop (they're included in
  `GRPC_SCAN_GLOB`), and the manual `parseProtoFile` loop is
  skipped. `buildProtoContext` still runs to build the proto map
  for cross-referencing source-file detections.

1. **No manual comment/string stripping.** The old parser needed
   `stripProtoCommentsAndStrings` (110 lines) to avoid counting
   braces inside comments and string literals. tree-sitter handles
   this natively.
2. **No brace-depth tracking.** `extractServiceBlocks` used a manual
   depth counter to find service boundaries. tree-sitter's AST gives
   us `service` → `service_name` + `rpc` → `rpc_name` directly.
3. **Performance.** tree-sitter's C-based parser is faster than
   character-by-character JS scanning + regex on large proto files.

- `grpc-extractor.test.ts` — **43/43 pass** (unchanged)
- All other extractor tests — 99/99 pass
- `npx tsc -p tsconfig.json --noEmit` clean

Co-authored-by: Claude <noreply@anthropic.com>

* chore: add .gitignore for vendored tree-sitter-proto build artifacts

https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU

* fix: correct .gitignore paths for vendored tree-sitter-proto

Patterns should be relative to the .gitignore file's directory.

https://claude.ai/code/session_01SFUCxgKMMQ8EgRHYw91xPU

* refactor(group): address Copilot review feedback on #796

Six fixes suggested by the Copilot AI review:

1. **`normalizeHttpPath` root-path edge case** — stripping trailing
   slashes on the input `/` produced an empty string, yielding
   malformed contract ids like `http::GET::`. Now preserves `/` for
   the root handler/fetch case.

2. **Dedupe `scanFiles` call** — `extract()` was globbing the
   source-scan file list twice (once for the provider fallback, once
   for the consumer fallback). Moved to a single lazy call that
   memoizes the result for the rest of the method.

3. **HTTP `scanFiles` now ignores `**/vendor/**`** — every other
   extractor's glob already ignored vendored sources; the HTTP one
   didn't. Fixed for consistency.

4. **`loadPackageDefinition` check is now structural** — was calling
   `tree.rootNode.text.includes('loadPackageDefinition')` which forces
   materialization of the entire file text from the parse tree
   (expensive on large files). Replaced with a dedicated compiled
   query on `(call_expression function: [(identifier) | (member_expression)])`
   so the check stays in the AST domain.

5. **`grpc-extractor.ts` header docstring updated** — still claimed
   ".proto parsing is not tree-sitter-based because no grammar is
   installed". Now describes the actual behaviour: tree-sitter when
   `tree-sitter-proto` is available (optionalDependency), manual
   fallback otherwise.

6. **Eliminated the double proto file parse on the fallback path** —
   `buildProtoContext` already globs + parses every `.proto` file to
   build `servicesByName`. On the `!hasProtoPlugin` branch the
   extractor was globbing + parsing again via the now-removed
   `parseProtoFile` helper. The fallback branch now iterates the map
   that `buildProtoContext` already produced to emit provider
   contracts directly — single pass per proto file.

## Tests

- `topic-extractor.test.ts` — 30/30 pass
- `http-route-extractor.test.ts` — 18/18 pass
- `grpc-extractor.test.ts` — 43/43 pass
- `manifest-extractor.test.ts` — 8/8 pass
- `npx tsc -p tsconfig.json --noEmit` clean

Co-authored-by: Claude <noreply@anthropic.com>

* refactor(group): address Claude review feedback (bugs + dedup + hygiene) on #796

Follows up `2f28bfc` with the remaining items from the Claude AI review:

## Bugs

**Bug 2 — Label-unaware Cypher queries in `resolveSymbol`.**
The manifest-extractor's lookup queries were `MATCH (n) WHERE n.name = $x`
with no label filter, so a topic/service/package name could silently match
any node type (File, Variable, Import, Folder, …). Added label filters:
- `topic` → `(n:Function|Method|Class|Interface)` (topics are best-effort
  symbol-name matches against listener/publisher symbols)
- `grpc` method → `(n:Function|Method)`
- `grpc` service → `(n:Class|Interface)`
- `lib` → `(n:Package|Module)`

All 8 manifest-extractor tests still pass (mock executor is
label-agnostic, but the production LadybugDB graph now gets correctly
scoped queries).

**Bug 8 — Tautological `!handlerName` condition.**
`http-route-extractor.ts:extractProvidersGraph` had
`let handlerName = null; if (!method || !handlerName) { ... }` — the
`!handlerName` clause was always true since there was no intervening
assignment. Simplified to always run the plugin-scan lookup (we need
the handler name even when `methodFromRouteReason` already resolved
the method).

## Clean code / dedup

**Design 7 — `readSafe` was copy-pasted in all three orchestrators.**
Extracted to `extractors/fs-utils.ts` as the single source of truth
for the path-traversal guard. Dropped the three local copies and the
now-unused `fs`/`path` imports from topic-extractor.

**Style 10 — Language-specific `_test.go` skip in the topic orchestrator.**
Was `if (rel.endsWith('_test.go')) continue;` inside the language-
agnostic extraction loop. Pushed into the glob's ignore list
(`'**/*_test.go'`) alongside the existing `node_modules`, `vendor`,
`dist`, `build` entries, with a comment explaining that other
languages' test file conventions either live in separate directories
(Python `tests/`, Java `src/test/`) or are already covered by the
existing ignores.

## Already addressed in `2f28bfc` (mentioned again in Claude review)

- Bug 3: `normalizeHttpPath('/')` returns `''` — fixed
- Bug 4: double glob + double parse of `.proto` — fixed
- Bug 5: `scanFiles` called twice in HTTP — fixed
- Bug 6: missing `**/vendor/**` in HTTP glob — fixed
- Design 9 partially: `tree.rootNode.text.includes('loadPackageDefinition')`
  replaced with a dedicated structural query

## Deferred

- Bug 1 (`http::*::path` vs `http::GET::path` matching) — out of scope;
  sync.ts matching logic lands in #793, manifest extractor already
  emits correct synthetic uids for unresolved HTTP contracts.
- Design 9 full (change plugin `scan(tree)` → `scan(tree, source)`) —
  the only real use case (`loadPackageDefinition` gate) is already
  fixed via a structural query, so the interface change would be
  cosmetic churn without a concrete consumer.

## Tests

- `topic-extractor.test.ts` — 30/30 pass
- `http-route-extractor.test.ts` — 18/18 pass
- `grpc-extractor.test.ts` — 43/43 pass
- `manifest-extractor.test.ts` — 8/8 pass
- `npx tsc -p tsconfig.json --noEmit` clean

Co-authored-by: Claude <noreply@anthropic.com>

* docs+fix(group): address remaining Claude review items + add pipeline flow chart

## Fixes

**Remaining 🔴 — HTTP contract id wildcard format.** Documented the
`http::*::<path>` format as an intentional wildcard for manifest links
that omit the HTTP method, alongside the explicit-method form
(`GET::/path` → `http::GET::/path`). The docblock on `buildContractId`
now states both forms, notes that wildcard-aware matching is the
responsibility of the sync / cross-impact layer (#793), and
recommends the explicit-method form whenever the author knows the
method (it round-trips through exact equality without needing
wildcard logic downstream). Tests unchanged — the wildcard format is
what they've always asserted.

**Minor 1 — stale comment at `manifest-extractor.ts:124-126`.** The
comment claimed "creates a contract with an empty symbolUid/ref" but
the code switched to `manifestSymbolUid(repo, contractId)` a few
commits back. Updated to describe the actual synthetic-uid fallback
semantics and the cross-impact path that relies on both sides of the
join deriving the same uid.

**Minor 2 — exhaustiveness guard on `buildContractId`.** The
`switch(type)` covered all five current `ContractType` variants but
silently returned `undefined` if a new variant was added. Added a
`default: const _exhaustive: never = type; throw new Error(...)`
clause so the build fails loudly on an unhandled variant.

**Minor 3 — `tree.rootNode.text` in `grpc-patterns/node.ts`.** Already
fixed in `2f28bfc` via a dedicated structural query
(`LOAD_PACKAGE_DEFINITION_SPEC`). No action needed.

## New: pipeline flow chart (per @magyargergo's request)

Added `src/core/group/PIPELINE.md` with four Mermaid diagrams:
1. **High-level overview** — `group.yaml` → extractors + manifest →
   contract matching → `bridge.lbug` → `runGroupImpact`.
2. **Per-repo extractor two-strategy shape** — graph-assisted
   Strategy A vs. source-scan Strategy B.
3. **Plugin architecture** — orchestrator → registry →
   per-language `*-patterns/<lang>.ts` → `tree-sitter-scanner.ts` →
   `ExtractedContract`.
4. **Manifest extraction** — label-scoped `resolveSymbol` with the
   synthetic-uid fallback.
5. **Cross-impact query (#606)** — local impact → bridge join →
   cross-repo fan-out.

Each diagram is annotated with which PRs own which stage (this PR:
extractors + manifest; #795: bridge storage; #606: cross-impact
runtime) and points at the concrete files/functions involved.

## Tests

- 99/99 extractor tests pass
- `npx tsc -p tsconfig.json --noEmit` clean

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 08:49:30 +01:00
Gergő Magyar b10d25bbca chore: release v1.6.0 — update CHANGELOG and package-lock (#798) 2026-04-12 12:31:21 +01:00
CopilotandGergo Magyar a94d6ef80b Extract registries into model/ module with SemanticModel interface (#786)
* Initial plan

* feat(SM-20): extract registries into model/ module with SemanticModel interface

- Create model/type-registry.ts — TypeRegistry interface + factory
- Create model/method-registry.ts — MethodRegistry interface + factory
- Create model/field-registry.ts — FieldRegistry interface + factory
- Create model/semantic-model.ts — SemanticModel interface + factory
- Create model/heritage-map.ts — re-export HeritageMap types
- Create model/binding-accumulator.ts — re-export BindingAccumulator types
- Create model/resolve.ts — move lookupMethodByOwnerWithMRO from call-processor
- Update symbol-table.ts — delegate to SemanticModel for registry ops
- Update call-processor.ts — re-export lookupMethodByOwnerWithMRO from model/resolve

No circular dependencies: model/resolve.ts does NOT import resolution-context.ts.
All 775 related unit tests pass with no regressions.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* fix: clarify re-export comment per code review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* refactor(SM-20): wire up SemanticModel as first-class resolution input

PR #786 extracted TypeRegistry/MethodRegistry/FieldRegistry into model/
behind SemanticModel, but consumers still routed through SymbolTable
delegates. This change completes Phase 6 of the fuzzy-lookup elimination
roadmap by making call-processor, resolution-context, type-env, and
heritage-map query the model directly via `table.model.{types,methods,fields}`.

Also absorbs the open PR #786 review findings so the branch lands clean:
- Removed duplicate JSDoc block on lookupMethodByOwner (symbol-table.ts)
- Added model/index.ts barrel for the public model/ surface
- Fixed O(n) buildParentMapFromHeritage BFS via head-pointer queue
- Clarified re-export facade framing on binding-accumulator.ts and
  heritage-map.ts inside model/
- Refined @internal JSDoc on lookupMethodByOwnerWithMRO

Changes:
- symbol-table.ts: expose `readonly model: SemanticModel` on the
  SymbolTable interface. SymbolTable delegate wrappers (lookupClassByName
  etc.) stay as thin pass-throughs for backward compat; deletion is a
  follow-up once all internal callers are migrated.
- model/resolve.ts: lookupMethodByOwnerWithMRO now takes SemanticModel
  instead of SymbolTable, removing the last SymbolTable import from the
  model/ module. Preserves circular-dependency firewall.
- call-processor.ts: 6 call sites in D0 member resolution, field
  resolution, ctor override, and ctor disambiguation migrated to
  model.types/methods/fields.
- resolution-context.ts: tier 3 class+impl lookup migrated.
- type-env.ts: 5 sites across lookupClassDefsByName, resolveFieldType,
  and resolveMethodReturnType migrated.
- heritage-map.ts: parent/child class-name resolution migrated.

Tests:
- symbol-table.test.ts: +10 parity and feeding-audit tests covering
  every model.{types,methods,fields} path (Class, Method, Property,
  Impl, Function-with-ownerId, Property-without-ownerId skip, arity
  filtering, clear cascade).
- call-processor.test.ts: classLookupSpy now targets
  ctx.symbols.model.types since the wrapper is bypassed.
- type-env.test.ts: createMockSymbolTable and the destructured-call
  makeSymbolTable helpers gained a model shim that forwards to the
  (possibly overridden) top-level lookup stubs.

Validation: full suite 5603 passed / 159 skipped, resolver integration
suite (19 files, 1766 tests) clean, tsc --noEmit clean.

* refactor(SM-21): invert ownership — SemanticModel contains SymbolTable

Follow-up to SM-20. Previously SymbolTable owned a `model` subfield;
this commit turns the ownership direction around so the SemanticModel
is the top-level container and SymbolTable is nested as `.symbols`:

    SemanticModel (top-level, passed everywhere)
      ├── types   (TypeRegistry)
      ├── methods (MethodRegistry)
      ├── fields  (FieldRegistry)
      └── symbols (SymbolTable — file-indexed + callable-name index)

The owner-scoped registries live directly on the model; file and
callable-name lookups go through `.symbols`. Consumers receive a
`SemanticModel` and reach into the appropriate field — no more
`table.model.types.X` double-hop.

Core changes:
- symbol-table.ts: createSymbolTable now takes injected
  TypeRegistry/MethodRegistry/FieldRegistry via a SymbolTableDeps
  argument. When omitted (test fallback), it creates standalone
  registries locally and clears them in clear() — production callers
  always inject. The five registry convenience delegates
  (lookupClassByName, lookupMethodByOwner, lookupFieldByOwner,
  lookupClassByQualifiedName, lookupImplByName) remain as thin
  forwards to the injected registries so standalone SymbolTable use
  (chiefly tests) stays ergonomic.
- model/semantic-model.ts: createSemanticModel() now creates the
  three registries AND a SymbolTable wired to them, exposing the
  SymbolTable as `.symbols`. clear() cascades through all four.
- resolution-context.ts: `readonly symbols: SymbolTable` field is
  replaced with `readonly model: SemanticModel`. Internal factory
  builds a SemanticModel and keeps a local `symbols` alias for
  backward-compatible inner body.

Consumer migrations (src/):
- call-processor.ts: ctx.symbols.add/.lookupExactAll/
  .lookupCallableByName → ctx.model.symbols.*; ctx.symbols.model.X →
  ctx.model.X. buildTypeEnv option key renamed symbolTable → model.
- type-env.ts: symbolTable parameter renamed model (type
  SemanticModel), all internal call sites rewritten to use
  model.types.*, model.methods.*, model.fields.*,
  model.symbols.lookupExactAll / .lookupCallableByName.
- heritage-map.ts: 2 class-lookup sites migrated.
- pipeline.ts: ctx.symbols → ctx.model.symbols throughout.

Test migrations:
- symbol-table.test.ts: parity tests (which validated the old
  table.model.X hop) replaced with direct SemanticModel coverage via
  createSemanticModel(). New tests exercise types/methods/fields/
  symbols feeding end-to-end.
- type-env.test.ts: createMockSymbolTable rebuilt as a
  SemanticModel-shaped mock that still accepts the legacy flat
  override bag for backward compat; inline `makeSymbolTable` helpers
  for destructured-call and importedReturnTypes suites rewritten to
  match the new shape; buildTypeEnv options `symbolTable: X` and
  `{ symbolTable }` shorthand renamed to `model:`; one real
  createSymbolTable-based test rewritten to use createSemanticModel.
- call-processor.test.ts, heritage-map.test.ts,
  heritage-processor.test.ts, symbol-resolver.test.ts: bulk sed
  `ctx.symbols.` → `ctx.model.symbols.`. call-processor.test.ts spy
  updated to target `ctx.model.types.lookupClassByName`.

Validation: full test suite 5589 passed / 169 skipped / 0 failed;
tsc --noEmit clean; pre-commit eslint + prettier + typecheck all
green. CLAUDE.md / AGENTS.md stats bumped from an earlier `npx
gitnexus analyze` refresh (3965 symbols / 10012 edges / 243 flows).

* refactor(SM-22/SM-23): dispatch table + DAG rearchitecture

SM-22: Extract registration dispatch table into model/registration-table.ts.
Replaces the if/else ladder inside SymbolTable.add() with an O(1)
Map<NodeLabel, RoutingDecision> fan-out. SemanticModel wires the table
per-instance so hooks close over the correct registries.

SM-23: DAG rearchitecture. symbol-table.ts is now a pure 2-index leaf
(fileIndex + callableByName) with zero imports from model/. All
type/method/field routing lives in the model/ layer. Tests migrated to
createSemanticModel() + model.symbols access pattern.

Tests: 5632 passed, 0 failures.

* refactor: delete dead code (skipCallableIndex + model/ facades)

Removes the unused skipCallableIndex flag from the registration dispatch
table and deletes two facade files that had zero consumers.

skipCallableIndex was declared on RoutingDecision and populated for all
10 entries but never read at runtime — semantic-model.ts explicitly
documented that the flag was NOT consulted. The callable-index gate
lives inside SymbolTable.add() via CALLABLE_TYPES.has(type), which is
the single source of truth. Deleting the flag keeps SymbolTable as the
sole decision point and removes documentation-as-data.

model/binding-accumulator.ts and model/heritage-map.ts were facade
pass-throughs of their parent-directory counterparts. Grep confirms no
consumer imports either from the model/ path — all usage goes through
../binding-accumulator.js and ../heritage-map.js directly. model/index.ts
was the only "user" and re-exported them with a note about unifying the
import boundary, but that boundary has no actual consumers today.

Resolves review findings M-01 and M-03 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures (1 less than pre-Unit-1: the
skipCallableIndex-specific assertion was removed).

* refactor: remove lookupMethodByOwnerWithMRO backward-compat shim

call-processor.ts re-exported lookupMethodByOwnerWithMRO from
./model/resolve.js as a backward-compat shim for symbol-table.test.ts.
The function already lives in model/resolve.ts and is re-exported
properly from model/index.ts (the barrel) — the call-processor shim
was a duplicate export path with no durable reason to exist.

Migrated the test import from call-processor.js to model/index.js
(the canonical barrel). Deleted the re-export statement and the stale
"re-exported for backward compatibility" comment block. Hoisted the
remaining import to the top of the file with the other imports; the
bottom-of-file position was a relic of the shim pattern.

Resolves review finding M-02 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures.

* refactor: harden registration dispatch runtime safety

Two hardening changes in semantic-model.ts, both closing silent-failure
paths in the SM-series dispatcher-bypass failure mode.

1. model.symbols.clear() now cascades to the owner-scoped registries.
   Previously, the SymbolTable facade exposed rawSymbols.clear directly,
   which only emptied fileIndex + callableByName — the types/methods/
   fields registries stayed populated. Any caller holding a SymbolTable
   reference that invoked .clear() left the model in a split state where
   subsequent .add() calls double-registered in the registries. No
   current caller exercises this path, but it was a latent phantom-
   resolution risk that didn't belong in a public API. Extracted the
   cascade into a single cascadeClear closure wired into both
   model.clear() and the facade's clear field.

2. runExhaustivenessGuard now throws instead of console.warn on drift.
   The production short-circuit via NODE_ENV === 'production' is
   preserved, so real users never see the throw — but CI and dev runs
   now fail loudly if a NodeLabel is added to gitnexus-shared without
   being placed in one of the three registration-table allowlists. The
   previous warn-only behavior was silent in test output volume; SM-19
   already documented dispatcher-bypass as the dominant silent-failure
   mode in this codebase.

Test-first: added test/unit/model/semantic-model.test.ts covering
model.symbols.clear() cascade (4 registries × clear = 4 tests), the
existing model.clear() cascade (regression guard), and a happy-path
construction test that verifies the current allowlists have zero drift.

Resolves correctness P2 finding (symbols.clear() partial clear),
correctness P3 (exhaustiveness warn-only), and kieran-typescript KT-03
(same exhaustiveness finding, agreement boost).

Tests: 5638 passed (+7 new), 0 failures.

* docs: fix stale JSDoc references in resolveStaticCall

call-processor.ts:2215-2216 referenced SymbolTable.lookupClassByName
and SymbolTable.lookupMethodByOwner via {@link}. Both methods were
removed from SymbolTable during SM-20 — they now live on TypeRegistry
and MethodRegistry respectively, accessible via model.types and
model.methods.

Other SymbolTable.* references in the codebase (lookupExactFull, add,
lookupCallableByName in call-processor.ts:593, symbol-table.ts:86,
type-extractors/types.ts:57) target methods that are still on
SymbolTable and remain valid.

Resolves correctness P3 and kieran-typescript KT-02 (same finding,
agreement boost).

* refactor: deduplicate ALL_NODE_LABELS constant

ALL_NODE_LABELS was private in semantic-model.ts and duplicated
verbatim in registration-table.test.ts. Two hardcoded lists meant a
new NodeLabel added to gitnexus-shared could land in one copy but not
the other, silently drifting the exhaustiveness invariant.

Exported ALL_NODE_LABELS from semantic-model.ts, re-exported through
model/index.ts for barrel consistency, and switched the test to import
it instead of redeclaring. The explanatory comment now describes the
single-source-of-truth contract.

Resolves maintainability M-04.

Tests: 5638 passed, 0 failures.

* refactor: add compile-time NodeLabel exhaustiveness check

The runtime exhaustiveness guard in semantic-model.ts caught drift at
test time. Added a type-level check in registration-table.ts that
catches drift at BUILD time — if a new NodeLabel is added to
gitnexus-shared without being classified into one of the three
allowlists, TypeScript fails the _exhaustiveCheck assignment and
names the missing label.

The runtime guard stays as belt-and-suspenders: if a future contributor
bypasses the type check with @ts-ignore, the runtime guard still fires
in dev/test.

Implementation: converted the three allowlist Set<NodeLabel> initializers
to use `as const` tuples, then derived a union type from the tuples and
asserted `Exclude<NodeLabel, union> extends never`. Zero runtime impact
— the exported Sets are unchanged, Map.get hot-path performance is
unchanged, the test API is unchanged.

Resolves kieran-typescript KT-04.

Tests: 21/21 registration-table tests pass with zero modifications.

* refactor(test): restore type safety to createMockSymbolTable

createMockSymbolTable was widened to (overrides: any = {}): any with an
eslint-disable-next-line, and every buildTypeEnv call site passed the
mock as `model: mockSymbolTable as any`. The widening masked silent
false-green tests: buildTypeEnv accesses model.types/methods/fields,
and a flat any-typed override could silently return undefined from a
path that TypeScript should have caught at compile time.

Defined LegacyMockOverrides interface with typed stubs for each method
the mock can override (SymbolTable reads + TypeRegistry/MethodRegistry/
FieldRegistry lookups). Return type is now SemanticModel, so the mock
object is compile-checked against the real interface — a missing
registry method is a type error, not a silent runtime undefined.

Removed the eslint-disable and all 9 `as any` casts at call sites
(lines 1287, 1300, 1307, 2124, 2138, 5823, 5835, 5850, 5870). The
mock's return value now flows through buildTypeEnv's typed `model`
option without coercion.

Resolves kieran-typescript KT-01 and testing gap TG-02. This was the
highest-value cleanup in the plan — the only finding representing real
hidden test weakness.

Tests: 360 passed | 7 skipped (type-env.test.ts), typecheck clean.

* test: close coverage gaps in model/ registries

Added direct unit tests for the three owner-scoped registries that
previously had only transitive coverage via symbol-table.test.ts and
registration-table.test.ts. These new tests pin behaviors that were
flagged by the testing reviewer as untested or undertested.

method-registry.test.ts (14 tests):
- T-01: arity-fallback branch — when argCount matches no overload,
  fall back to the full pool so fuzzy resolution still has candidates.
  Previously untested and would have returned undefined instead of
  a valid candidate if the branch regressed.
- T-02: requiredParameterCount range filtering — methods with default
  parameters accept any argCount in [requiredParameterCount,
  parameterCount]. Previously untested at the registry level.
- Variadic fallback (parameterCount=undefined is retained during arity
  narrowing, bypassing range check).
- Return-type dedup paths: shared returnType → first wins, differing
  returnTypes → undefined, firstReturnType=undefined → undefined,
  single-overload skips dedup entirely.

type-registry.test.ts (9 tests):
- classByName homonym accumulation (two User classes in different
  packages both returned).
- classByQualifiedName disambiguation — same simple name, different
  FQNs resolve independently.
- Partial classes with identical simple + qualified name accumulate
  in both indexes.
- registerImpl stores Rust impl blocks separately from classes.
- Multiple impl blocks per type accumulate.

field-registry.test.ts (6 tests):
- register/lookup round-trip, owner-scope isolation, last-wins on
  duplicate key (flat map, not overload list).
- clear + re-register round-trip.

Extended symbol-table.test.ts cascade test (renamed from "both
registries" to "all three registries and the nested symbol table") to
also assert model.methods and model.fields are cleared — the test
name previously implied full coverage but only asserted types + symbols.

Resolves testing findings T-01, T-02, T-03, T-05.

Tests: 5667 passed (+29 new), 0 failures.

* refactor(test): replace brittle reference-equality tests + add intent comments

Two cleanups flagged as low-severity P3 by the testing reviewer:

1. registration-table.test.ts: Replaced three reference-equality tests
   (hook identity via toBe) with behavioral tests that survive a future
   refactor to per-label closures. The new "class-like behavior group"
   describe iterates Class/Struct/Interface/Enum/Record/Trait and
   verifies each one writes to types.registerClass. Same pattern for
   Method/Constructor. A separate "behavior group isolation" describe
   verifies class-like hooks don't leak into methods/fields and Impl
   never pollutes registerClass. Strictly more coverage than the
   reference-equality tests provided and implementation-independent.

2. symbol-resolver.test.ts: Added a comment above the lookupExactFull
   and SM-16: getFiles() describes explaining why they intentionally
   use createSymbolTable() directly instead of createSemanticModel().
   The DAG leaf-only behaviors they test do not involve registries, so
   testing the bare SymbolTable keeps the unit isolated. Prevents a
   future reader from "fixing" the inconsistency.

3. qualified-class-lookups.test.ts: Added a comment above
   `const symbolTable = model.symbols` explaining that processParsing
   writes still reach the owner-scoped registries via SemanticModel's
   fan-out — the alias is convenience, not a leaf in isolation.

Resolves testing T-04, kieran-typescript KT-05, kieran-typescript KT-06.

Tests: affected files all green (112 passed in registration-table +
symbol-resolver + qualified-class-lookups).

* refactor(model): collapse RoutingDecision wrapper and trim barrel surface

Two cleanups against the advanced-review findings on post-Unit-9 state:

S2 (cross-reviewer agreement — architecture-strategist + code-simplicity):
Delete the RoutingDecision single-field wrapper interface. Post-Unit-1
it held exactly one field (hook: RegistrationHook) and added pure
ceremony at every call site — `dispatchTable.get(key)!.hook(name, def)`
vs the now-direct `dispatchTable.get(key)!(name, def)`. Change the Map
type from Map<NodeLabel, RoutingDecision> to Map<NodeLabel,
RegistrationHook>, drop the interface, and update 17 test call sites.

A3 (architecture-strategist): Trim model/index.ts barrel surface.
createRegistrationTable, RegistrationHook, and RegistrationTableDeps
were re-exported from the barrel despite having zero legitimate
consumers outside model/ itself. The only callers (semantic-model.ts
and registration-table.test.ts) import directly from
./registration-table.js. Barrel exposure invited external callers to
construct orphan dispatch tables with independent registries,
weakening the SM-21 ownership inversion where SemanticModel is the
composition root. Kept CALLABLE_ONLY_LABELS, INERT_LABELS,
DISPATCH_LABELS exported since those remain useful for downstream
resolution logic and have no construction risk.

Resolves review findings:
- S2 (code-simplicity P3, 0.85) + architecture-strategist residual
- A3 (architecture-strategist P3, 0.82)

Tests: 5674 passed, 0 failures. Typecheck clean.

* refactor(model): replace runtime exhaustiveness guard with compile-time bijection

Replace the three-layer drift protection (hardcoded ALL_NODE_LABELS
array + 3 tuple consts + _ExhaustiveLabelCheck type + runExhaustivenessGuard
runtime + CI taxonomy test) with a single Record<NodeLabel, LabelBehavior>
map that structurally proves every invariant at compile time.

## Before

- ALL_NODE_LABELS hardcoded in semantic-model.ts (36 entries, could drift)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private tuples (36 more entries total, could overlap or miss)
- _ClassifiedLabel / _UncoveredLabel type-level check (caught missing
  labels but NOT duplicates across tuples)
- runExhaustivenessGuard runtime throw (only defense against duplicates)
- NodeLabel taxonomy coverage test in CI (same check as runtime guard)

Four defenses for invariants that the type system can express directly.

## After

```ts
type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';

const LABEL_BEHAVIOR = {
  Class: 'dispatch',
  // ...36 entries...
  Tool: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior>;
```

The `as const satisfies Record<NodeLabel, LabelBehavior>` combo enforces:

1. **Every NodeLabel must be a key** — Record requires all K keys.
   Adding a NodeLabel to gitnexus-shared without classifying it here
   fails with "Property 'X' is missing in type ..." naming the drifted label.
2. **No non-NodeLabel keys allowed** — `satisfies` with object literals
   triggers excess-property checking. A typo'd key fails to compile.
3. **No duplicate classification** — impossible by construction; object
   keys are unique at the source level.
4. **Valid category** — LabelBehavior is a narrow union, typos caught.

`ALL_NODE_LABELS`, `DISPATCH_LABELS`, `CALLABLE_ONLY_LABELS`, and
`INERT_LABELS` are now derived via `Object.keys(LABEL_BEHAVIOR)` and
`filter(l => LABEL_BEHAVIOR[l] === ...)` — single source of truth,
structurally impossible to drift.

## Deleted

- runExhaustivenessGuard() function in semantic-model.ts (~18 lines)
- ALL_NODE_LABELS hardcoded array in semantic-model.ts (~38 lines)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private consts in registration-table.ts (~30 lines)
- _ClassifiedLabel / _UncoveredLabel / _exhaustiveCheck type machinery
  (~20 lines)

## Kept named proofs: none

The `as const satisfies` on the object literal already catches all four
drift modes. Named type-level proofs (_MissingFromMap / _ExtraKeysInMap)
are pure duplication and were removed per review.

## Also in this commit

- S6: trim wrappedAdd narration comments in semantic-model.ts
  (Step 1/2/3 block comments removed; kept the Function+ownerId WHY note)
- A3: tighten model/index.ts barrel — createRegistrationTable,
  RegistrationHook, RegistrationTableDeps remain direct-imports only;
  ALL_NODE_LABELS and LabelBehavior re-exported from the new home in
  registration-table.ts

## Resolves

- Advanced-review S4 (runtime guard per-call cost) — guard no longer exists
- Advanced-review S1 (tuple three-defenses indirection) — single Record replaces all tuples
- Correctness P3 (exhaustiveness warns-only) — structurally impossible to drift
- Unit 6 type-level check — subsumed by the Record type
- Unit 3 runtime throw — no longer needed

Tests: 5674 passed, 0 failures. Typecheck clean.

* test(model): delete duplicate closure-isolation spy tests

S5 (code-simplicity P3): The 'closure isolation — each hook can only
write to its registry' describe block duplicated the 'behavior group
isolation' block's coverage via a different mechanism.

Behavioral tests (lines 151-174, kept):
  table.get('Class')!('User', def);
  expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined();
  expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined();

Spy tests (deleted, ~55 lines):
  vi.spyOn(deps.methods, 'register')
  table.get('Class')!('User', def);
  expect(methodsSpy).not.toHaveBeenCalled();

Both assert the same invariant — classHook does not touch the methods or
fields registries. The behavioral form observes the END STATE of the
registry (lookup returns undefined), which is the actual contract.
The spy form asserts the IMPLEMENTATION (a specific method was not
called), which couples to internal wiring — a refactor to a different
register function name would break the spy test while the behavioral
test would still pass.

Also dropped the now-unused `vi` import from vitest.

Tests: 24/24 registration-table.test.ts pass (-4 from spy deletion).

* refactor(model): compile-time cross-invariant between CLASS_TYPES and dispatch classHook

A1 (architecture-strategist P2, 0.90): CLASS_TYPES in symbol-table.ts
and the class-like entries of the dispatch table were two independent
hardcoded sets. Adding a new class-like label (e.g. Swift 'Extension')
to one but not the other would silently degrade qualifiedName
population — the symptom is subtle (partial qualified-name lookups)
and no test asserted the co-extensive invariant.

Fixed with a single source of truth and a two-layer compile-time
enforcement:

## symbol-table.ts

- Add `CLASS_TYPES_TUPLE` as `readonly [...] as const satisfies
  readonly NodeLabel[]`. The `satisfies` forces every tuple entry to
  be a valid NodeLabel at compile time.
- Export derived type `ClassLikeLabel = typeof CLASS_TYPES_TUPLE[number]`.
- Derive `CLASS_TYPES` Set from the tuple — same runtime shape as
  before, now typed `ReadonlySet<NodeLabel>`.

## registration-table.ts

- Import `CLASS_TYPES_TUPLE` and `ClassLikeLabel` from symbol-table.ts.
- Narrow the `satisfies` on `LABEL_BEHAVIOR` via intersection:
      Record<NodeLabel, LabelBehavior> & Record<ClassLikeLabel, 'dispatch'>
  This forces every class-like label to have value 'dispatch' at
  compile time. Adding a label to CLASS_TYPES_TUPLE without
  classifying it as dispatch in LABEL_BEHAVIOR fails to compile with
  a type error naming the drifted label.
- Build the class-like entries of the dispatch Map by iterating
  `CLASS_TYPES_TUPLE` at factory time. Adding a label to the tuple
  automatically wires it to classHook — no second place to update.

## What the design prevents

1. Drift scenario A (A1 original): 'Extension' added to CLASS_TYPES_TUPLE
   but not to LABEL_BEHAVIOR → compile error on LABEL_BEHAVIOR's
   satisfies.
2. Drift scenario B: 'Extension' added to CLASS_TYPES_TUPLE but not
   wired to classHook → impossible because the Map is derived from the
   tuple.
3. Drift scenario C: class-like label classified as something other
   than 'dispatch' in LABEL_BEHAVIOR → compile error on the narrowed
   intersection.

Runtime behavior unchanged: same 6 labels in CLASS_TYPES, same 6
class-like entries in the dispatch Map. Tests pin the behavior via
the existing behavior-group tests in registration-table.test.ts.

DAG unchanged: registration-table.ts already imported from symbol-table.ts
(the allowed upward direction). symbol-table.ts still imports nothing
from model/.

Tests: 5670 passed, 0 failures. Typecheck clean.

* test(field-extraction): use SemanticModel facade instead of raw SymbolTable

A6 (architecture-strategist P3, 0.85): field-extraction.test.ts created
its FieldExtractorContext fixture with `symbolTable: createSymbolTable()` —
a raw SymbolTable leaf, not the facade. In production, the context's
symbolTable field is always `model.symbols` (the SemanticModel-wrapped
facade where .add() dispatches through the owner-scoped registries).

The current field extractors don't call symbolTable.add() at all, so
this change is behavior-neutral today. The value is architectural
consistency — matching the test fixture to the production shape
prevents silent drift if a future field extractor starts registering
dynamically-discovered properties via the context. Without the fix,
such writes would hit the raw leaf and skip the fan-out, and tests
would pass even though the symptom (empty FieldRegistry) would
manifest in production.

Tests: 50/50 field-extraction.test.ts pass. Production tsc --noEmit
clean. Test-tsconfig error count unchanged (634 pre-existing errors
in unrelated test files, out of scope).

* refactor(A5): decouple model/resolve.ts from language registry

Move the MroStrategy type into gitnexus-shared and replace the
language: SupportedLanguages parameter on lookupMethodByOwnerWithMRO
with a direct mroStrategy: MroStrategy literal. Callers derive the
strategy from their language provider before invoking the resolver.

model/resolve.ts no longer imports from ../languages/index.js, so the
model/ layer is free of cross-layer coupling with the language
registry — this closes finding A5 from the SM-20/21/22/23 advanced
review (plan 006).

* feat(A4): add MethodRegistry.lookupMethodByName flat-by-name index

Add a secondary `methodsByName: Map<string, SymbolDefinition[]>` index
on MethodRegistry that returns every method with a given unqualified
name, accumulated across owners and overloads. The new index shares
SymbolDefinition references with methodByOwner — no duplication.

This is step 1 of the A4 double-index removal (plan 006). Tier 3
global resolution will switch to this index in Unit 3 so Method and
Constructor can be removed from CALLABLE_TYPES in Unit 4.

* refactor(A4): extend Tier 3 + memberCallByFile to consult method registry

Add model.methods.lookupMethodByName to Tier 3 global resolution in
resolution-context.ts and to the callable-pool build in
call-processor.ts (resolveMemberCallByFile + D2 widen path).

Intentionally behavior-preserving: Method and Constructor are still
in CALLABLE_TYPES so the new lookup returns identical candidates that
already reach Tier 3 through callableByName. Both paths dedup by
nodeId during this intermediate state — Unit 4 shrinks CALLABLE_TYPES
and the dedup is removed.

Part of plan 006 A4 step 2.

* refactor(A4): shrink CALLABLE_TYPES to free callables only

CALLABLE_TYPES = {Function, Macro, Delegate}. Method and Constructor
are no longer double-indexed in callableByName — they reach resolvers
through model.methods.lookupMethodByName instead.

Companion changes:
- Introduce CALL_TARGET_TYPES = CALLABLE_TYPES ∪ {Method, Constructor}
  for the resolver's kind filter (filterCallableCandidates,
  countCallableCandidates). Separates registration semantics (narrow)
  from the resolver's acceptable-target set (wide).
- type-env.ts for-loop return-type inference consults both indexes,
  treating the union as the authoritative call pool.
- resolveMemberCallByFile + D2 widen path keep the nodeId dedup in
  place: Python/Rust/Kotlin class methods emitted as Function+ownerId
  still land in both indexes until Unit 5 unblocks the normalization.
- Tier 3 global resolution (resolution-context.ts) keeps the same
  dedup for the same reason.

Test updates reflect the new contract: Method/Constructor live in
methodsByName, not callableByName. Orphan Method-without-ownerId now
lives only in the file index (no registry coverage).

Part of plan 006 — closes A4 for strictly-labeled methods. Python/
Rust/Kotlin Function+ownerId normalization is tracked as Unit 5
(blocked).

* refactor: rename CALLABLE_TYPES → FREE_CALLABLE_TYPES

Pure rename. The constant's meaning changed in Unit 4 (free callables
only — no methods, no constructors) so the name now reflects that
scope: "callables that have no owner scope". Updates the constant
declaration and every consumer in src/ and test/.

Closes plan 006 Unit 6.

* refactor(A2): strict SymbolTableReader (pure reads) + SymbolTableWriter (+add)

Split the SymbolTable interface into three strictly layered surfaces:

- SymbolTableReader: lookups + iteration. NO add, NO clear. Holders
  cannot mutate the table in any way.
- SymbolTableWriter extends Reader: + add. NO clear. Holders can
  register new symbols but cannot trigger a leaf-index reset.
- InternalSymbolTable (private, not exported): + clear. The cascading
  reset capability is reachable only through createSymbolTable's
  return type, held exclusively by SemanticModel.rawSymbols.

SemanticModel.symbols is now typed as SymbolTableWriter — external
consumers (workers, processors, pipelines) can register symbols and
query them, but cannot reach .clear(). The A2 LSP fix holds: callers
holding any public reference cannot desync the leaf indexes from the
owner-scoped registries.

Delete the transitional `type SymbolTable = SymbolTableReader` alias
and migrate every consumer (src + test) to the explicit names:
- Field and parameter annotations use SymbolTableReader by default;
  only code that calls .add() uses SymbolTableWriter.
- parsing-processor (workers + sequential paths) takes
  SymbolTableWriter so it can register extracted symbols.
- field-types, call-processor, named-binding-processor,
  workers/parse-worker: use SymbolTableReader (query-only).
- Tests: drop the stale `clear` fields from mock factories and
  migrate the semantic-model cascade tests from the removed
  model.symbols.clear() path to model.clear().

Closes plan 006 Unit 7. Industry sources: TypeScript compiler API
builder pattern, Salsa ParallelDatabase, .NET IReadOnlyList. See the
a2-lsp-clear-contract-research artifact for full citations.

* feat(A2): add SemanticModel.resetFileIndex() partial-reset entry point

Add a named method that clears only the leaf file and callable
indexes without cascading to the three owner-scoped registries
(types, methods, fields). Replaces the rare partial-reset use case
that was previously reachable via the now-removed symbols.clear()
path from A2 (plan 006 Unit 7).

JSDoc makes the semantic difference with model.clear() explicit so
future readers don't have to guess which method to call for a given
reingestion scenario.

Test-first: three scenarios cover the partial-vs-full semantics,
re-add after reset, and idempotency.

Closes plan 006 Unit 8.

* docs(S7): trim registration-table module JSDoc

Remove the ~24 lines of design-provenance citations from the module
JSDoc. The rust-analyzer, TypeScript-compiler, and Fowler references
are preserved in git history via the original SM-22 commits and in
plan 006 Unit 9.

Keep the ownership diagram, behavior-group table, and the
'How to add a new NodeLabel' checklist — those are load-bearing for
future contributors.

Closes plan 006 Unit 9 (S7 advanced-review finding).

* test(S3): migrate type-env.test.ts off LegacyMockOverrides

Replace the createMockSymbolTable bridge and LegacyMockOverrides
interface with real createSemanticModel() + add() calls across all
14 call sites. Where a test needs a specific registry lookup that
can't be pre-populated cleanly, use vi.spyOn on the real registry
instead.

Pattern breakdown:
- Pattern A (pre-populate via model.symbols.add): 13 sites
- Pattern B (vi.spyOn on registry lookup): 1 site

Deletes LegacyMockOverrides + createMockSymbolTable entirely. The
real MethodRegistry arity/returnType semantics match the hand-rolled
mock behavior in every migrated case, and no 'as any' casts remain
in the file.

Closes plan 006 Unit 10 (S3 advanced-review finding).

* refactor: remove unused MroStrategy type exports from language-provider and resolve modules

* refactor: relocate symbol-table, heritage-map, resolution-context into model/

Use git mv so blame and history follow each file:
- gitnexus/src/core/ingestion/symbol-table.ts → model/symbol-table.ts
- gitnexus/src/core/ingestion/heritage-map.ts → model/heritage-map.ts
- gitnexus/src/core/ingestion/resolution-context.ts → model/resolution-context.ts

These three files are part of the SemanticModel layer (file/callable
indexes, heritage parent map, tiered resolver) and now sit alongside
the registries they collaborate with. Updates every consumer import
path across src/ and test/ to the new locations.

* refactor(model): enforce pure-leaf DAG + delete legacy re-exports

model/ is now a pure leaf: zero upward imports and zero compat
shims in its parent processors. Completes the DAG cleanup started
in the previous commit.

1. walkBindingChain — moved into model/resolution-context.ts;
   named-binding-processor.ts deleted.

2. NamedImportMap + NamedImportBinding + isFileInPackageDir —
   moved into model/resolution-context.ts. Every consumer now
   imports from the canonical location directly. Legacy re-exports
   in import-processor.ts deleted.

3. c3Linearize + gatherAncestors — moved into model/resolve.ts.
   mro-processor.ts imports them back for computeMRO. Legacy
   c3Linearize re-export from mro-processor.ts deleted.

4. ExtractedHeritage type — moved into model/heritage-map.ts.
   call-processor.ts, parsing-processor.ts, pipeline.ts,
   heritage-processor.ts, and the test files now import it from
   the canonical location. Legacy re-exports in parse-worker.ts
   and heritage-processor.ts deleted.

5. resolveExtendsType — rewritten in model/heritage-map.ts to
   take an explicit HeritageResolutionStrategy (A5-style DI).
   buildHeritageMap accepts an optional getHeritageStrategy
   callback; production uses getHeritageStrategyForLanguage from
   heritage-processor.ts. Legacy resolveExtendsType re-export
   from heritage-processor.ts deleted.

Verified:
- grep 'from "..' gitnexus/src/core/ingestion/model → empty
- grep 'Re-export for legacy' gitnexus/src/core/ingestion → empty
- npx tsc --noEmit → clean
- npx vitest run → 5686 passing

* docs(model): strip phase/plan references from module comments

Remove SM-20/21/22/23, A2/A4/A5, plan 006, Unit N labels and historical
phrasing ("previously", "legacy", "model-leaf DAG cleanup") from all 10
files in src/core/ingestion/model/. Preserve domain vocabulary (Tier
1/2/3), invariants, and caveats — only the plan archaeology is gone.

* refactor(model): tighten interface segregation + compile-time invariants

Apply four gated findings from branch-wide code review:

- SemanticModel.symbols now typed as SymbolTableReader; MutableSemanticModel
  widens it back to SymbolTableWriter. ResolutionContext.model is typed as
  MutableSemanticModel since it owns the lifecycle. Resolvers that only
  query symbols can annotate their own fields as SemanticModel to drop
  write access at the type level.

- Lookup methods (lookupExactAll, lookupCallableByName, lookupClassByName,
  lookupClassByQualifiedName, lookupImplByName) now return
  readonly SymbolDefinition[]. The returned arrays are live views into
  the internal indexes; the readonly marker prevents accidental caller
  mutation. walkBindingChain return type narrowed to match.

- FREE_CALLABLE_TUPLE + FreeCallableLabel exported from symbol-table.ts
  as the single source of truth for free-callable labels. LABEL_BEHAVIOR
  now satisfies Record<FreeCallableLabel, 'callable-only'> as a second
  cross-invariant alongside Record<ClassLikeLabel, 'dispatch'>. Adding a
  label to the tuple without classifying it as 'callable-only' fails at
  build time. CALLABLE_ONLY_LABELS is now a re-export alias of
  FREE_CALLABLE_TYPES so the two sets cannot drift.

- walkBindingChain fast-exits before allocating its cycle-detection Set
  when the caller's file has no named bindings. Skips ~200k transient
  Set allocations per large-repo resolution pass.

Also fixes five stale comments flagged by the review: duplicate JSDoc
block on RegistrationHook merged; resolve.ts "delegates to mro-processor"
direction corrected; RegistrationTableDeps JSDoc names
createRegistrationTable (not createSymbolTable); mro-processor.ts
"re-exported at top" stale comment removed; gatherAncestors export
comment matches reality.

tsc --noEmit clean, full test suite green (5786 tests).

* refactor(model): resolve four deferred P2 review findings

Address the four gated items from the branch-wide review that needed
design decisions before applying:

F#3 — Method/Constructor without ownerId fallback to callable index.
The dispatch hook silently skips owner-scoped labels that lack an owner
(an extractor contract violation — AST-degraded parse, or a buggy
language extractor). Pre-dispatch-table code let such defs fall through
to callableByName and stay reachable at Tier 3 global resolution. This
restores that fallback in SymbolTable.add so orphaned Methods and
Constructors don't silently vanish. Property deliberately does NOT
participate in the fallback to avoid polluting common names like
id / name / type.

F#4 — Delete MutableSemanticModel.resetFileIndex. The method had zero
production callers (only three tests), documented a "rare partial-
reingestion flow" that was never implemented, and contained the
adversarial-reviewer's double-populate trap: calling resetFileIndex
followed by re-adding the same class symbol would push a duplicate
SymbolDefinition into TypeRegistry.classByName without ever clearing
the first one. If incremental reingestion is ever needed, it can be
designed properly with per-file TypeRegistry invalidation. For now,
deleting the footgun is safer than documenting it.

F#5 — Compile-time dispatch-table completeness check. `LABEL_BEHAVIOR`
already enforces "every NodeLabel is classified" via
`Record<NodeLabel, LabelBehavior>`, but the dispatch-table factory
populated its Map with manual `table.set(...)` calls that TypeScript
could not correlate back to the `'dispatch'` classification. Add a
type-level `DispatchLabel` extracted from `LABEL_BEHAVIOR` via a
conditional mapped type, and build the table from an object literal
that satisfies `Record<DispatchLabel, RegistrationHook>`. Adding a new
dispatch-classified label without wiring it to a hook now fails the
build with a named-key error — no more silent no-op hooks.

F#7 — Tier 3 dedup fast-path via MethodRegistry.hasFunctionMethods.
The Set-based dedup between callableDefs and methodDefs is only needed
when a Python/Rust/Kotlin class method (emitted as Function+ownerId by
the worker) lands in both indexes. For TS/Java/C#/C++/Ruby-only repos
— where the two indexes are disjoint by construction — the dedup was
pure overhead on every global-tier hit. MethodRegistry now tracks
whether any Function-typed def was ever registered, and resolution-
context branches Tier 3 into a concat-only fast path when that flag
is false. Slow path with dedup survives unchanged for mixed-language
repos.

New tests pin the invariants: hasFunctionMethods flag transitions,
Method/Constructor orphan fallback, Property non-fallback, and the
MethodRegistry clear() reset. Full test suite green (5756 tests).

* refactor(model): close remaining P3 review findings + coverage gaps

Address the remaining review items in one batch.

Production refactors:

- Rename classHook → classLikeHook (M05). The hook handles Class /
  Struct / Interface / Enum / Record / Trait; the vocabulary used in
  surrounding docs and the behavior-group table is "class-like". The
  rename makes the code match the taxonomy without forcing readers
  through a mental glossary.

- Extract MAX_BINDING_CHAIN_DEPTH constant in resolution-context.ts
  and document it as a known silent false-negative source (ADV-003).
  Five hops cover the common TypeScript monorepo pattern; raising the
  cap is a one-line change if a real repo exceeds it. walkBindingChain
  consumes the constant so the 5 magic number no longer floats free.

- Replace defs.filter() allocation in MethodRegistry.lookupMethodByOwner
  with a two-pass streaming count + conditional materialization
  (PERF-04). Pure-match and pure-reject arity paths now skip the
  filtered-array allocation entirely; only the discriminating case
  (at least one match AND at least one rejection) pays it.

- Rewrite NOOP_SYMBOL_TABLE in parse-worker.ts and NOOP_SYMBOL_TABLE_SEQ
  in parsing-processor.ts to implement all six SymbolTableReader
  methods (ADV-005). The `as unknown as SymbolTableReader` cast is
  removed in favor of a direct SymbolTableReader annotation, so future
  additions to the interface surface as compile errors on the stubs
  instead of silently falling through.

- type-env.ts getCallableUnionCount and getFirstCallable now take
  `model: SemanticModel` as an explicit argument instead of reaching
  into the enclosing `model!` non-null assertion (KT-003). Callers
  enter via an `if (model)` guard and pass the narrowed reference, so
  the non-null precondition is visible at the type level and the
  closures cannot be accidentally extracted into a context without
  the guard.

- Tier 3 dedup in resolution-context.ts now covers all four index reads
  (classDefs, implDefs, callableDefs, methodDefs) via a pushUnique
  helper (C-03). Previously classDefs and implDefs were spread directly
  without dedup; any theoretical nodeId collision would have produced
  duplicates in globalDefs.

Test infrastructure:

- Extract makeDef / makeMethod factory helpers into
  test/unit/model/helpers.ts (T-07). The four registry/table test
  files now import the shared helper and specialize with overrides,
  removing ~25 lines of duplicated boilerplate and creating a single
  point of maintenance.

New test coverage:

- T-01: c3 BFS fallback — cyclic Python hierarchy that fails c3
  linearization and must fall back to heritageMap.getAncestors() BFS
  order. Added to the lookupMethodByOwnerWithMRO describe block.

- T-02: Tier 2a-named precedence — verifies the binding chain walker
  fires before Tier 2a import-scoped when an aliased import
  `import { User as U } from B` competes with a raw same-name Tier 2a
  hit. Also pins Tier 1 same-file precedence over Tier 2a-named.

- T-03: Tier 3 Function+ownerId dedup — end-to-end test that a Python
  class method emitted as `Function + ownerId` yields exactly ONE Tier
  3 candidate (not two). Companion test pins the fast-path branch for
  hasFunctionMethods === false repos.

- T-06: walkBindingChain guards — circular re-export detection,
  depth-cap exceeded drop, and boundary case at exactly
  MAX_BINDING_CHAIN_DEPTH hops resolving successfully.

All tests added to a new test/unit/model/resolution-context.test.ts
dedicated to ResolutionContext.resolve() tier-precedence invariants.

Full suite: 5708 passing (minus the known Windows LBUG lock flake
that passes in isolation).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-12 01:06:55 +01:00
ivkondandClaude 1ff324ca16 feat(group): bridge.lbug storage + contract matching expansion (1/4 of #606 split) (#795)
* feat(group): bridge.lbug storage + contract matching expansion

Part 1 of 4 in the split of #606 (ticket: #791, closes #790 with a
revised plan per @magyargergo's request).

## What changed

Adds the LadybugDB-backed bridge storage infrastructure and extends
the contract matching algorithm with wildcard support. All changes are
additive: storage.ts, sync.ts, service.ts, cli/group.ts, mcp/tools.ts
are left on their upstream main versions and will migrate to the new
bridge in follow-up PRs (#792, #793, #794).

### Files

**New (844 LOC prod):**
- `gitnexus/src/core/group/bridge-db.ts` — atomic write-to-temp with
  `retryRename` for Windows EBUSY/EPERM, per-item write tolerance via
  `WriteBridgeReport`, `findContractNode` with three-tier symbol
  lookup (uid → filePath+name → filePath)
- `gitnexus/src/core/group/bridge-schema.ts` — schema DDL
- `gitnexus/src/core/group/normalization.ts` — contract ID
  canonicalization + `dedupeContracts` / `dedupeCrossLinks` helpers
  used by both matching and bridge write

**Modified (+137 LOC prod):**
- `gitnexus/src/core/group/matching.ts` — adds `runWildcardMatch` for
  `grpc::Service/*` wildcard consumers, `buildProviderIndex` helper,
  and canonical gRPC ID handling in `normalizeContractId`
- `gitnexus/src/core/group/types.ts` — `MatchType` gains `'wildcard'`;
  new `BridgeHandle` and `BridgeMeta` interfaces

**New tests (658 LOC):**
- `gitnexus/test/unit/group/bridge-db.test.ts` — core write/read round
  trip, `WriteBridgeReport` shape, dropped-links counter, retryRename
  behavior on EBUSY/ENOENT/EPERM/EACCES
- `gitnexus/test/unit/group/bridge-db-edge.test.ts` — edge cases
  (malformed meta, missing contract nodes, concurrent access)

**Modified tests (+225 LOC):**
- `gitnexus/test/unit/group/matching.test.ts` — wildcard consumer
  matching, gRPC canonical ID handling, same-service guard

### Self-review fixes folded in

Carried forward from the original #606 self-review:
- `writeBridge` try/finally handle lifecycle + `handleClosed` sentinel
- `openBridgeDbReadOnly` partial-handle cleanup
- `writeBridgeMeta` uses `retryRename` for Windows consistency
- `retryRename` unit tests (was zero coverage)
- Per-item try/catch around every CREATE loop so one malformed contract
  doesn't abort the whole write
- Dropped cross-link counter (`linksDroppedMissingNode`)

### Why now

magyargergo asked for the #606 PR to be split so we can iterate with
confidence (https://github.com/abhigyanpatwari/GitNexus/pull/606#issuecomment-4229612271).
This is the foundational layer — pure infra, no user-facing surface,
no callers of the new APIs in this PR. Later PRs wire it in.

### How to verify

- `cd gitnexus && npx tsc --noEmit`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/bridge-db-edge.test.ts --pool=forks`
- `cd gitnexus && npx vitest run test/unit/group/matching.test.ts --pool=forks`
- Pre-commit hook runs clean

### Risk / rollback

**Low.** All new code sits under `src/core/group/` in new files plus a
minimal `+16/-1` diff to `types.ts` and a `+136/-0` diff to `matching.ts`
(both purely additive). No existing callers reference the new APIs
(bridge-db, openBridgeOrFallback, runWildcardMatch) — the PRs that wire
them in come later in the split chain. Rollback = `git revert` of the
merge commit; no state introduced, no schema migration triggered.

### Scope discipline (per GUARDRAILS.md)

- Only the 8 files listed above are touched; no drive-by refactors
- No CI/release/security config changes
- No secrets, tokens, or machine-specific paths
- Content is lifted from the #606 branch which already passed CI 11/11
  green on `d15b8cb` (before the split)

### Dependencies

- **Base:** `main` (no dependencies on other split PRs)
- **Blocks:** extractor expansion (#792), sync pipeline (#793),
  cross-impact feature (#794)
- **Related ticket:** #791

Co-authored-by: Claude <noreply@anthropic.com>

* fix(group): address @claude review on #795

Addresses the findings from the automated review on PR #795
(https://github.com/abhigyanpatwari/GitNexus/pull/795#issuecomment-4229770000
— posted by @magyargergo / claude-code Action run).

### Medium severity (reviewer flagged as blockers)

- **bridge-db.ts `openBridgeDbReadOnly` bak recovery** — the `.bak`
  recovery path used bare `fsp.rename(bakPath, dbPath)`, which is
  exactly the scenario most likely to hit Windows EBUSY/EPERM (an
  interrupted writer still holding the handle for a few ms). Switched
  to `retryRename` for consistency with the rest of the file's
  Windows-safe rename path.
- **bridge-db.ts `ensureBridgeSchema` error detection** — the inline
  `msg.includes('already exists')` substring match has been lifted
  into a named constant `LBUG_ALREADY_EXISTS_MSG` with a comment
  documenting the coupling to LadybugDB's error message wording and
  why we can't use `IF NOT EXISTS` (LadybugDB DDL doesn't support it)
  or typed errors (LadybugDB's JS driver doesn't expose error codes).
  Also tightened the `catch (err: any)` to `catch (err: unknown)`.
- **bridge-db.ts `findContractNode` — extracted out of writeBridge**
  — the 35-line async closure living inside `writeBridge` has been
  lifted to three module-level functions: `createContractLookupIndex`,
  `indexContract`, and `findContractNode`. `findContractNode` is now
  a pure synchronous function taking a prebuilt index instead of
  doing its own DB queries. The `writeBridge` cross-link loop is now
  ~25 lines instead of ~100.
- **bridge-db.ts `findContractNode` — N+1 query elimination** — the
  old inner-closure version issued up to 6 DB round-trips per
  cross-link (2 endpoints × up to 3 tiers of fallback queries). For a
  group with 1000 cross-links, that's up to 6000 DB queries just to
  resolve endpoints. The new version consults an in-memory
  `ContractLookupIndex` built incrementally as contracts are inserted
  (`indexContract` called AFTER each successful insert so failed
  inserts don't poison the index). Cross-link resolution is now
  O(1) per link instead of O(3) DB queries per link, with zero DB
  round-trips during the cross-link loop.

### Minor severity

- **bridge-db.ts `queryBridge` empty-array guard** — if LadybugDB
  ever returns an empty `QueryResult[]` at the top level (shouldn't
  happen with single-statement calls, but driver contract isn't
  explicit), the old code would call `.getAll()` on `undefined` and
  crash with a confusing stack. Added an `unwrapQueryResult` helper
  that throws an explicit `'empty QueryResult array'` error instead,
  making a potential driver regression visible immediately.
- **normalization.ts `contractRichness` weights** — added a
  block-level comment documenting the weight ordering (+3 for
  symbolUid, +2 for each symbol-identifying field, +1 for service
  tag or non-manifest origin) and explicitly noting that the
  absolute numbers don't matter, only the relative ordering. Matches
  the "comment for contributors" suggestion in the review.
- **bridge-schema.ts `BRIDGE_SCHEMA_VERSION` migration comment** —
  added a 4-point contract explaining what bumping the constant
  means ("discard and re-sync" strategy for V1, no in-place
  migration yet, new migration logic should live in a separate
  `bridge-migrations.ts` module when it becomes necessary).
- **test/unit/group/fixtures.ts** — extracted the `makeContract`
  helper previously copy-pasted between `bridge-db.test.ts` and
  `bridge-db-edge.test.ts` into a shared fixtures module. Both test
  files now import from `./fixtures.js`. Kept the scope minimal:
  fixtures is NOT a general-purpose factory module, just the shared
  baseline contract builder.

### New tests

Added 9 pure-function unit tests for the now-extracted
`findContractNode` in `bridge-db.test.ts`:
  - returns null on empty index
  - tier 1 (symbolUid) match, including repo-scope and role-scope
    isolation
  - tier 2 (filePath + symbolName) fallback when symbolUid is empty
    or mismatches
  - tier 3 (filePath only) when exactly one contract lives in the
    file, and refusal when multiple do
  - priority ordering when multiple tiers could resolve

These are fully isolated — no DB, no temp directories, no native
LadybugDB binding — so they run in &lt;10ms total and are
immediately trustworthy as a regression safety net.

### Deliberately deferred (reviewer marked as "fine for now")

- `BridgeHandle._db` / `._conn` typing to `unknown` with casts in
  `bridge-db.ts` — reviewer's note: "The typing is fine for now."
- Batch inserts via `UNWIND` — needs LadybugDB support confirmation,
  tracked as a follow-up; the per-item pattern remains.
- `queryBridge` prepared-statement lifecycle — the current pattern
  (prepare → execute → GC) relies on LadybugDB's internals, worth
  verifying against their docs in a separate audit.

### Scope discipline (per `GUARDRAILS.md`)

- Only files touched by this PR (`bridge-db.ts`, `bridge-schema.ts`,
  `normalization.ts`, both bridge test files, new `fixtures.ts`) —
  no drive-by refactors
- No CI/release/security config changes
- No secrets

### Test + typecheck status

- `npx tsc --noEmit` clean
- `bridge-db.test.ts`: added 9 `findContractNode` tests, all pass in
  isolation. The full-file run still hits the pre-existing native
  LadybugDB cleanup segfault that flakes the reported count — same
  as every prior commit on this branch, not a regression.
- `bridge-db-edge.test.ts`: 4/4 pass
- `matching.test.ts`: 28/28 pass
- `types.test.ts`: 5/5 pass
- `retryRename` tests (4/4) and `findContractNode` tests (9/9)
  verified in isolation via `-t` filter

Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 19:46:12 +01:00
Dave Brophy 9364739fb4 fix: restore tree-sitter-swift postinstall patch for macOS ARM64 (#788)
* fix: restore tree-sitter-swift postinstall patch for macOS ARM64

PR #516 (77dcb06) deleted `scripts/patch-tree-sitter-swift.cjs` and
the `postinstall` script entry when bumping to `tree-sitter-swift@0.7.1`,
since 0.7.1 ships prebuilt darwin-arm64 binaries and no longer needs the
patch. PR #538 (01ddc3e) then had to revert `tree-sitter-swift` back to
`^0.6.0` (and `tree-sitter` back to `^0.21.1`) because `npm overrides`
doesn't apply when gitnexus is installed via `npx -y` (gitnexus isn't the
root project, so overrides are silently ignored, producing ERESOLVE errors).

PR #538 reverted the grammar package changes but did not restore the patch
script, leaving `tree-sitter-swift@0.6.0` unable to build its native
binding on macOS ARM64. The symptom is `gitnexus analyze` printing
"Skipping swift" or "swift parser not available".

`Dockerfile.test` still references `node scripts/patch-tree-sitter-swift.cjs`
(added in the same PR #516), confirming the regression — the test image
build is also broken.

This commit restores the patch script from commit `0c8ec95` (the last
revision before it was deleted) and re-adds the `postinstall` entry to
`package.json`. No logic changes — it is an exact restoration.

The TODO comment in the script ("Remove this script when tree-sitter is
upgraded to ^0.22.x") still applies.

* style: run prettier on patch-tree-sitter-swift.cjs
2026-04-11 18:24:02 +01:00
smTheApexandProta100 75635638b1 feat(csharp): capture interface-to-interface heritage (#789)
The C# tree-sitter query set only matched `base_list` on
`class_declaration`, so interfaces extending other interfaces
(`interface IFoo : IBar`) were never captured as heritage edges.

This broke transitive interface implementation chains. For example,
given:

    interface IBase { }
    interface IFoo : IBase { }
    class MyClass : IFoo { }

only `MyClass -> IFoo` was emitted, and the `IFoo -> IBase` edge was
silently dropped. Any analysis that relies on walking the full
interface inheritance chain (e.g. "which classes implement IBase?")
therefore returned incomplete results.

This patch adds two new query patterns mirroring the existing
class_declaration heritage patterns, but targeting
`interface_declaration`:

    (interface_declaration name: (identifier) @heritage.class
      (base_list (identifier) @heritage.extends)) @heritage
    (interface_declaration name: (identifier) @heritage.class
      (base_list (generic_name (identifier) @heritage.extends))) @heritage

The existing heritage-processor pipeline already handles these
captures correctly once the query emits them, so no changes are
needed outside of tree-sitter-queries.ts.

Testing:
- New fixture `csharp-interface-heritage/` covering:
    * interface : interface  (single base)
    * interface : interface, interface  (multiple bases)
    * class : interface (where that interface derives from others)
- 6 new test cases in test/integration/resolvers/csharp.test.ts
  asserting exactly 4 IMPLEMENTS edges and 0 EXTENDS edges for the
  fixture.
- Full C# resolver suite: 175/175 passing, no regressions.

Co-authored-by: Prota100 <Prota100@users.noreply.github.com>
2026-04-11 17:41:51 +01:00
JWWD | ModusOpandClaude Opus 4.6 5be0537ce4 Fix stack overflow on large PHP files — iterative AST traversal (#783)
* Fix: replace recursive AST traversal with iterative stack to prevent stack overflow on large files

Fixes #752

Large PHP files (2000+ lines) with deeply nested AST structures (closures,
array literals, chained method calls) cause "Maximum call stack size exceeded"
during analysis. This converts three recursive tree traversal functions to
iterative loops using explicit stacks:

1. `walk()` in type-env.ts — the main AST walker that processes every node.
   On a 2,462-line PHP controller, this recurses through 5,000-10,000+ nodes.

2. `findRelationCall()` in languages/php.ts — recursive search for Eloquent
   relationship calls within method bodies.

3. `findDescendant()` in utils/ast-helpers.ts — generic recursive utility
   used by PHP property extraction and other parsers.

All three now use a while loop with an array-based stack instead of function
call recursion, eliminating V8's ~10K frame call stack limit as a constraint.

Tested against a production Laravel codebase with 373 PHP files (87,723 lines
total, largest file 2,462 lines) — indexes successfully in 17.4s with zero
errors, where the recursive version would crash with stack overflow.

* Fix: reverse child push order in findRelationCall iterative traversal

The iterative stack-based traversal pushed children in forward order,
causing the last child to be processed first (LIFO). This reversed the
original recursive left-to-right DFS order. Push children in reverse
so the first child ends up on top of the stack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: move stack declaration before processNode, rename walk to processNode

Move the stack initialization above the function that pushes onto it,
making the data-flow order match the code order. Rename walk to
processNode since it now processes a single node rather than
recursively traversing the tree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 15:32:06 +01:00
Louis Chu a162f66254 fix(web): keep chat pinned on async content growth 2026-04-11 05:51:29 -07:00
Mr. WorldwideBrown 6d9ec1009e fix: load VECTOR extension during DB init for semantic search (#782)
* fix: load VECTOR extension during DB init for semantic search

The VECTOR extension was only loaded inside the embedding generation

pipeline (createVectorIndex). On a fresh gitnexus serve session,

semantic and hybrid search failed because QUERY_VECTOR_INDEX was

unknown.

Now loads the VECTOR extension alongside FTS during database

initialization in both the single-connection and pool-based paths.

Fixes #766

* fix: reset vectorExtensionLoaded on DB close and retry paths

The vectorExtensionLoaded flag was not being reset in closeLbug() or
the busy-retry cleanup path in withLbugDb(). This caused the VECTOR
extension to not be re-loaded after a close+re-init cycle, breaking
semantic search on reconnection.

Also resets shared.ftsLoaded and shared.vectorLoaded in the pool
adapter closeOne() for external DB entries, preventing stale extension
state when the pool is re-opened.

Adds integration tests covering vector extension loading, idempotency,
and state reset on both close and busy-retry paths.

* fix: set ftsLoaded flag in initLbugWithDb to avoid redundant extension reloads

* fix: set shared.vectorLoaded flag in initLbugWithDb to avoid redundant reloads
2026-04-11 12:17:40 +01:00
Mr. WorldwideBrown 4911201664 fix: map diff hunks to symbol line ranges in detect_changes (#779)
* fix: map diff hunks to symbol line ranges in detect_changes

The detect_changes tool previously used `git diff --name-only` and
picked the first 20 arbitrary symbols from each changed file. This
produced false positives (unchanged symbols reported as modified) and
false negatives (actually changed symbols dropped by the LIMIT).

Now uses `git diff -U0` to get unified diff with hunk headers, parses
the @@ line ranges, and queries for symbols whose [startLine, endLine]
range overlaps the diff hunks. Only truly touched symbols are reported.

Also fixed the CONTAINS path match to ENDS WITH to prevent cross-file
false positives from substring matching.

Fixes #758

* fix: address review feedback - variable shadowing, batch queries, tests

- Rename `params` to `queryParams` in detectChanges hunk-mapping loop
  to avoid shadowing the outer method parameter
- Replace N+1 per-symbol process lookup with a single batched query
  using WHERE n.id IN $ids (same pattern as impact BFS traversal)
- Add unit tests for parseDiffHunks covering single/multi file,
  single/multi hunk, omitted count, pure-deletion, and empty input

* style: fix prettier formatting in parse-diff-hunks test
2026-04-11 11:29:52 +01:00
Mr. WorldwideBrown 08541e2857 Fix HTTP client vs Express route detection and Spring interface attribution (#780)
* fix: correctly identify HTTP client calls vs Express routes in receiver extraction

* fix: skip Spring route extraction for Feign client interfaces

* fix: address review feedback - receiver walk edge case, regex anchoring, add tests

* style: fix prettier formatting in route extractor and test files
2026-04-11 11:24:47 +01:00
d9960c62bf SM-19: Delete resolveCallTarget — replace with thin dispatcher (#770)
* Initial plan

* SM-19: Replace resolveCallTarget with thin dispatcher

Delete the monolithic resolveCallTarget function (~200 lines) and replace it
with a 15-line thin dispatcher that routes to resolveMemberCall,
resolveStaticCall, or resolveFreeCall. Extract module-alias resolution and
file-based member-call fallback into dedicated helper functions.

- resolveCallTarget body reduced from ~200 lines to ~15 lines
- Extract resolveModuleAliasedCall helper (Python/Ruby module imports)
- Extract resolveMemberCallByFile helper (trait dispatch, overload disambiguation)
- Extract singleCandidate helper (constructor alias fallback, name-based fallback)
- Update unit tests for new dispatcher semantics
- Update doc comments referencing deleted D0-D4 paths

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* SM-19: Add singleCandidate tail fallback for member calls with unresolvable receiver type

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/469eac38-b0c0-4a26-a2ff-3eb06299730b

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-19): address all PR #770 review findings + fix CI

Fixes all 5 test failures (2 unit + 3 integration) and addresses 10
review findings from comment 4225312416.

Critical fix — singleCandidate null-route guard
The SM-19 dispatcher chained singleCandidate as an unconditional tail
fallback for member calls with receiverTypeName. This bypassed the
SM-10 R3 null-route contract: when the receiver type IS in the index
but file/owner filtering produced zero matches, the old code returned
null (genuine miss), but the new code fell through to singleCandidate
(false-positive CALLS edge).

Root cause: resolveMemberCallByFile returns null for two semantically
different reasons — (1) type not found in the index at all, and
(2) type found but no candidate matched after narrowing. The dispatcher
treated both as "try the next fallback." The old resolveCallTarget
exited the entire function on case 2.

Fix: after the scoped resolvers both return null, check whether the
receiver type resolves in the index. If it does (case 2), null-route
— the scoped resolvers made the right decision. If it doesn't (case 1,
e.g. PHP 'mixed', dynamic types), singleCandidate is the correct last
resort. ctx.resolve is cached so the check is free.

This fixes:
- Unit: no heritageMap null-route test (was getting 1 edge, expects 0)
- Integration: Rust c.trait_only() negative test
- Integration: 3 PHP heritage + alias tests (singleCandidate correctly
  fires when the receiver type is not in the index)

Performance (findings #1, #2, #3)
- Thread pre-computed tiered result into resolveModuleAliasedCall via
  new tieredOverride parameter — eliminates the duplicate ctx.resolve
  call on every module-alias path.
- Add countCallableCandidates helper that short-circuits at threshold
  without allocating an intermediate array — replaces the
  filterCallableCandidates(...).length > 1 allocation in skipMember.
- resolveMemberCallByFile lookupCallableByName caching deferred to a
  follow-up (finding #2) — the fix requires threading widenCache
  through the file-scoped resolver which is a larger change.

Code quality (findings #4, #5)
- Remove dead code: redundant conditional in resolveMemberCallByFile
  where both branches returned null.
- Move WidenCache type declaration from mid-file (between JSDoc blocks)
  to adjacent to CONSTRUCTOR_TARGET_TYPES with other type declarations.

Formatting
- Applied prettier to call-processor.ts (CI format check was failing).

Verification
- tsc --noEmit clean
- 3188 unit tests pass (0 skipped real tests)
- 1766 resolver integration tests pass
- Zero regressions — all PHP, Rust, and no-heritageMap tests green

Review: https://github.com/abhigyanpatwari/GitNexus/pull/770#issuecomment-4225312416

* fix(SM-19): restore module-alias narrowing and constructor disambiguation

Codex adversarial review on PR #770 surfaced two silent regressions in the
SM-19 thin dispatcher:

Finding 1 [high] — Typed member calls bypassed module-alias narrowing.
When two homonym receiver types are both imported by the caller, the
import-scoped tier no longer narrows and the owner/file resolvers see
genuine ambiguity. The dispatcher null-routed silently, dropping valid
CALLS edges. Fix: consult `resolveModuleAliasedCall` at the top of the
typed-member branch so an active alias on `call.receiverName` picks the
aliased file before the generic resolvers run.

Finding 2 [medium] — Constructor dispatch lost overload disambiguation.
When `resolveStaticCall` bails (ambiguous or ownerless Constructor pool)
and the caller supplied `overloadHints` / `preComputedArgTypes`, the
branch fell straight through to `singleCandidate` — which also bails on
multiple same-arity survivors. Fix: between `resolveStaticCall` and
`singleCandidate`, run constructor-filtered overload disambiguation on
the tiered pool. Only engages when a narrowing signal is present;
preserves SM-10 R3 null-route for genuinely ambiguous cases.

Tests:
- call-processor.test.ts: 3 new dispatcher-level regression tests
  covering real-homonym alias narrowing, constructor overload
  disambiguation with `argTypes`, and null-route control
- symbol-table.test.ts: update `module alias homonyms` test which
  previously codified the Finding 1 regression; now asserts resolution
  to the aliased file's method

Verification: 3191 unit + 2398 integration tests pass; tsc --noEmit
clean; prettier clean.

* refactor(SM-19): address code review findings with clean-code pass

Code review on commit f424685e surfaced one P1 correctness regression and
two P2 maintainability concerns. This commit closes all ten findings:

P1 — Alias helper placement regression
  - resolveModuleAliasedCall now runs as a FALLBACK in the typed-member
    branch, after resolveMemberCall/resolveMemberCallByFile return null.
    Previously it short-circuited BEFORE scoped resolvers, leaking unrelated
    homonyms from the aliased file when a local var coincidentally matched
    a module alias.
  - Added type-file verification guard: alias narrowing only fires when the
    alias target file is among the receiver type's defining files. Prevents
    cross-type false positives and hardens SM-10 R3.

P2 — Thin-dispatcher drift (roadmap Phase 3)
  - Extracted disambiguateByOverloadOrArgTypes shared helper. Centralizes
    the overloadHints → preComputedArgTypes precedence rule used by both
    member and constructor resolvers.
  - Folded constructor overload disambiguation into resolveStaticCall as
    step 4.5 (between the ambiguous-pool bail and the instantiable-class
    fallback). resolveStaticCall now accepts optional overloadHints /
    preComputedArgTypes symmetric with resolveMemberCallByFile.
  - Dispatcher's constructor branch returns to a 2-line delegation.
  - resolveMemberCallByFile now calls the shared helper instead of inlining
    the ternary.

P2 — Missing test coverage
  - owner-scoped wins over alias narrowing (alias with unrelated target
    class must not override unique owner-scoped answer)
  - alias narrowing rejects unrelated target type (type-file guard)
  - alias fallthrough: receiverName not in alias map
  - alias fallthrough: alias target file has no matching method
  (overloadHints-for-constructor variant transitively covered via the
   extracted helper's member-path tests; direct dispatcher test deferred
   as it requires real OverloadHints fixture parsing)

P3 — Clarity and durability
  - Stripped "Codex SM-19 Finding N" prefixes from comments. Replaced with
    durable explanations of WHY each guarded branch exists.
  - Added cross-reference comment at the tail-branch resolveModuleAliasedCall
    call site pointing to the typed-member branch usage.

Verification: 3195 unit + 1766 resolver integration + 2398 full integration
tests pass. tsc --noEmit clean. prettier clean.

Plan: docs/plans/2026-04-11-002-fix-sm19-code-review-findings-plan.md

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-11 10:20:57 +01:00
Mr. WorldwideBrown 7c983d798f Fix OpenCode config path, FTS extension load order, error messages, and CLAUDE.md stats (#781) 2026-04-11 06:18:14 +01:00
Yogesh Singh d87744fffc fix: resolve false 404 errors and stale repo context during multi-repo switching on Windows (#633)
* fix: resolve false 404s and stale repo context during multi-repo switching on Windows

* test(e2e): add repo-switching tests — hold-queue 503, ?project= URL, Windows path normalization

* test(e2e): fix repo-switching specs — use live backend with ?server= param
2026-04-10 19:59:16 +01:00
100858f8c8 feat(SM-18): Delete lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex (#769)
* Initial plan

* Update test files for SymbolTable interface changes

Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, and callableIndex
references from all test files. Replace lookupFuzzyCallable with
lookupCallableByName. Update getStats assertions to only expect
{ fileCount }. Remove tests that exclusively tested removed methods.

Files updated:
- symbol-table.test.ts: Remove lookupFuzzy describe block and all
  globalIndex/callableIndex tests, update callable method references
- symbol-resolver.test.ts: Remove SM-16 lookupFuzzy test block,
  update Tier 3 describe title
- type-env.test.ts: Update all mock SymbolTable objects and spy
  variable names
- call-form.test.ts: Update ownerId propagation test

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(SM-18): Remove lookupFuzzy, lookupFuzzyCallable, globalIndex, callableIndex

Remove from SymbolTable interface and implementation:
- lookupFuzzy method
- lookupFuzzyCallable method
- globalIndex Map
- callableIndex Map (renamed to callableByName, backing lookupCallableByName)

Add lookupCallableByName as the targeted replacement for fuzzy callable
lookups. Migrate all production callers:
- resolution-context.ts: lookupFuzzyCallable → lookupCallableByName
- type-env.ts: lookupFuzzyCallable → lookupCallableByName
- call-processor.ts: lookupFuzzy → lookupCallableByName (D2 widen paths)

Remove fuzzyCallCount/fuzzyCallableCallCount stats and globalSymbolCount
from getStats(). Update pipeline.ts logging accordingly.

Memory savings: globalIndex stored every non-Property symbol (typically
the largest index by entry count). Removing it eliminates one Map plus
all its per-name arrays — net savings proportional to unique symbol
count in the project.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/4a658c69-41a9-4d57-8527-50ca544ca967

* fix(SM-18): address all PR #769 review findings

1. type-env.test.ts mock: add missing lookupImplByName + getFiles methods.

2. Macro/Delegate tests: 2 new tests confirm C/C++ Macro and C# Delegate
   are indexed in callableByName.

3. D2 widen path test: module-alias scenario verifying lookupCallableByName
   resolves methods in aliased files that shadow same-file definitions.

4. CALLABLE_TYPES unified: exported from symbol-table.ts (single source of
   truth), imported in call-processor.ts. Removed duplicate
   CALLABLE_SYMBOL_TYPES constant.

5. getStats() observability restored: tier hit counters (tierSameFile,
   tierImportScoped, tierGlobal, tierMiss) replace the removed
   fuzzyCallCount diagnostic.

* chore(SM-18): remove unnecessary `as any` casts on valid NodeLabel types

Macro, Delegate, TypeAlias, Const, and Variable are all valid NodeLabel
values in gitnexus-shared. The casts suppressed type checking without
purpose and signaled false uncertainty.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 14:56:24 +01:00
e5dafce9f2 feat(SM-16): Restructure resolveUncached — replace lookupFuzzy data source for all tiers (#764)
* Initial plan

* chore: initial plan for SM-16 resolveUncached refactor

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(SM-16): restructure resolveUncached — replace lookupFuzzy with targeted index lookups

- Remove single lookupFuzzy call that fed all Tier 2a/2b/3 in resolveUncached
- Tier 2a: iterate importedFiles with lookupExactAll per file (O(imports) × O(1))
- Tier 2b: iterate symbols.getFiles() filtered by isFileInPackageDir + lookupExactAll
  (O(files) × O(1), avoids global name scan)
- Tier 3: replace with lookupClassByName + lookupImplByName + lookupFuzzyCallable
  (three O(1) index lookups covering class-like, Rust impl blocks, and callables)
- Add getFiles() to SymbolTable interface (exposes fileIndex.keys() for Tier 2b)
- Add lookupImplByName() to SymbolTable — dedicated Rust Impl index kept separate
  from classByName to preserve correct heritage-map resolution
- Remove allDefs parameter from walkBindingChain; always use lookupExactAll directly
- Add 29 new unit tests covering SM-16 changes and per-language fixtures
- fuzzyCallCount in getStats() is now 0 for all resolve() calls (acceptance criterion)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-16): clean up — readable Tier 3 if-else, correct doc comment, remove unused import

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/0f505332-25be-46a7-b78e-fde58c1fc6fd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-16): address all PR #764 review findings

1. Eager callableIndex — maintained on add() like classByName/implByName,
   removing the O(globalIndex) lazy rebuild on the Tier 3 hot path.

2. Tier 2b inverted index — packageDirSuffix→Set<filePath> built lazily on
   first Tier 2b hit. Changes O(allFiles×packages) per resolution to
   O(packages×filesInPackage).

3. Tier 3 type exclusion documented — TypeAlias, Const, Variable are
   intentionally not reachable at Tier 3. 4 negative/positive tests added.

4. Tier 3 allocation guard simplified — single spread replaces 4-way if-else.

5. getFiles() live iterator documented with safety contract.

6. Remaining lookupFuzzy callers in call-processor.ts documented in the
   Tier 3 comment block.

7. Tier 2b language fixtures — added Rust, Kotlin, PHP tests (3 new).

Also merges origin/main (SM-15 accumulator fixes).

* fix(SM-16): address Codex adversarial review — Tier 2b cache lifecycle + Macro/Delegate at Tier 3

1. Tier 2b packageDirIndex now invalidated in clearCache() and clear(),
   preventing stale snapshots when symbols/packages are added between
   chunk processing phases.

2. Macro (C/C++) and Delegate (C#) added to CALLABLE_TYPES in the eager
   callableIndex, restoring Tier 3 reachability for these call targets
   that the old lookupFuzzy returned.

* fix(SM-16): address ce:review findings — Tier 2b cache lifecycle + Tier 3 perf + test gaps

1. packageDirIndex no longer invalidated in clearCache() — the index
   persists across file boundaries since packageMap and symbols are
   append-only during the calls phase. Only clear() (pipeline reset)
   invalidates. Prevents O(files×dirs) rebuild per-file.

2. Tier 3 short-circuit: return null before spread when all three
   indexes are empty, avoiding allocation on the common miss path.

3. Add Macro (C/C++) and Delegate (C#) Tier 3 regression tests —
   the only newly-added CALLABLE_TYPES were completely untested.

4. Add packageDirIndex invalidation regression test — verifies clear()
   resets the index and newly-added symbols are visible.

* fix(SM-16): address final review — deduplicate NamedImportMap + doc fixes

1. NamedImportMap: removed duplicate definition from resolution-context.ts,
   now imported directly from import-processor.ts (no re-export needed —
   no consumers imported it from resolution-context).

2. packageDirIndex build cost documented accurately in comment.

3. fuzzyCallCount scope documented in test comment.

4. Tier 2a test suite: added comment about Go/Kotlin/PHP coverage.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 13:00:33 +01:00
CopilotandGergo Magyar ab956f113c feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763)
* Initial plan

* Initial setup - Phase 9 BindingAccumulator cross-file return type wiring

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a

* fix(SM-15): address all PR #763 review findings

Performance (R1)
- Changed _fileScopeByFile from Map<string, [string,string][]> to
  Map<string, Map<string,string>>. fileScopeGet(filePath, name) is
  now O(1) — replaces the O(n) linear scan + defensive-copy alloc
  that ran once per ConstructorBinding entry. fileScopeEntries()
  reconstructs tuples from Map.entries() for backward compat.
- Updated finalize() dev-mode invariant to compare deduplicated Map
  size rather than raw array length (Map.set deduplicates same-name).

Lifecycle (R2)
- Documented that Phase 9 intentionally reads pre-finalize because
  finalize() cannot move before both the worker consumer (line 984)
  AND the sequential-path writer (line 1061). Pre-finalize reads are
  safe because finalize() is write-lock-only with no side effects.
  Replaced the ambiguous "populated but not yet finalized" comment
  with the full lifecycle ordering explanation.

Sequential-path parity (R3)
- Wired bindingAccumulator into processCalls at line 797 (sequential
  path) so verifyConstructorBindings gets the Phase 9 fallback.
- Added bindingAccumulator parameter to processAssignmentsFromExtracted
  signature and wired it at the pipeline.ts call site (line 1026).
- Both paths now produce identical Phase 9 behavior for the same code.

Tracking comments (R4)
- Added "Overlapping mechanism (N of 3)" cross-references at:
  1. buildImportedReturnTypes (~line 109)
  2. collectExportedBindings (~line 168)
  3. Phase 9 fallback in verifyConstructorBindings (~line 563)
  Each links to the other two and notes future unification.

Language coverage (R5)
- Added 5 new Phase 9 integration test suites in cross-file-binding.test.ts:
  JavaScript, C++, C#, PHP, Ruby. Each uses the existing fixture
  directories and asserts getUser() → User → user.save() resolves.
  Total cross-file binding tests: 52 (was 37).

Quality asymmetry (R6)
- Added inline comment at the Phase 9 fallback noting worker-path
  entries are Tier 0/1 only and that binding accuracy is structurally
  lower for large repos where the worker path dominates.

Tests (+21 new)
- 6 fileScopeGet unit tests (happy path, unknown file/name, mixed
  scopes, post-dispose, duplicate varName last-write-wins)
- 15 integration tests across 5 new language suites

Verification
- tsc --noEmit clean
- 3147 unit tests pass (+6 new)
- 52 cross-file binding integration tests pass (+15 new)
- 1766 resolver integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-10-001-fix-sm15-review-findings-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/763#issuecomment-4220354242

* fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency

Two Codex adversarial reviews identified medium-severity bugs in the Phase 9
BindingAccumulator fallback:

1. Local-first violation: the fallback fired regardless of whether ctx.resolve()
   found same-file candidates, letting an imported callee shadow a local one
   and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file'
   and callableDefs.length <= 1.

2. Sequential file-order dependency: processCalls flushed and verified per-file,
   so consumer files processed before their providers missed accumulator bindings.
   Fixed by splitting into a flush pre-pass (all files) then a resolution loop,
   mirroring the worker path's "all appends before any reads" pattern.

Also adds 11 consumer-before-provider integration test fixtures (one per
supported language) and 4 unit tests for tier gating edge cases.

* refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split

Replace the duplicated pre-pass + legacy-path code (parse → query → heritage
→ TypeEnv → exports) with a single preparation loop followed by a resolution
loop. Both paths now share the same preparation code — the only conditional
is the accumulator flush.

Side benefit: globalParentMap is now fully populated before any resolution
runs, improving cross-file isSubclassOf accuracy regardless of file order.

Net -118 lines (226 removed, 108 added).

* fix(SM-15): address PR #763 third-pass review findings

1. Update stale dispose() JSDoc — remove forward-reference to Phase 9
   wiring that is now complete; document actual consumers.

2. Add processAssignmentsFromExtracted Phase 9 unit test — verifies the
   accumulator fallback produces ACCESSES write edges when the SymbolTable
   has no returnType for the callee.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-10 10:29:31 +01:00
Louis Chu ad2a397137 feat(web): add smart chat scroll 2026-04-10 00:06:24 -07:00
Mr. WorldwideBrown 6147579e54 Fix security issues and critical bugs found in code review (#709) 2026-04-10 05:20:29 +01:00
Abhigyan Patwari 4a1f912aee feat(sm-14): add BindingAccumulator — collect TypeEnv outputs across files (#743) 2026-04-09 21:03:25 +01:00
d09078925e Extract resolveFreeCall from resolveCallTarget (SM-13) (#756)
* Initial plan

* feat(SM-13): extract resolveFreeCall from resolveCallTarget

Extract the free-function call resolution path into a dedicated
`resolveFreeCall(calledName, filePath, ctx)` function that uses
`lookupExact` + import-scoped resolution via `ctx.resolve()`.

- Free function calls (foo()) now route through `resolveFreeCall`
- Swift/Kotlin implicit constructors (User()) delegate to
  `resolveStaticCall` within `resolveFreeCall`
- `resolveCallTarget` dispatches `callForm === 'free'` early,
  removing the inline freeFormHasClassTarget logic
- S0 block simplified to only handle `callForm === 'constructor'`
- Global (Tier 3) fallthrough preserved via ctx.resolve() until Phase 5
- 9 new unit tests for resolveFreeCall
- All 163 unit tests pass, all 1199 integration resolver tests pass

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unrelated package-lock.json change

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c5f2e73a-259a-438c-b5c8-286b82e3c215

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-13): address PR #756 review findings on resolveFreeCall

Addresses all 7 findings from the PR #756 review comment.

Code (R1, finding #1)
- Replace the literal `'Class' | 'Struct' | 'Record'` check in
  `hasClassTarget` with `INSTANTIABLE_CLASS_TYPES.has(c.type)`. Converts
  an invariant that was previously comment-enforced ("keep this list
  aligned with INSTANTIABLE_CLASS_TYPES") into one enforced structurally.
  Any future extension of the set propagates here automatically. The
  narrower Swift extension dedup block below still uses literal
  `'Class' | 'Struct'` by design — Swift extensions only produce Class
  duplicates in practice, Record is deliberately excluded there, and
  the inline comment now documents that asymmetry.

Tests (+12 regression scenarios)

Finding #2 — language coverage
- Go free function (doStuff())
- Python free function (def helper(): ... helper())
- Rust free function outside any impl block
- Java statically-imported function
- JavaScript module-level function
Each exercises `_resolveCallTargetForTesting` with `callForm='free'`
and the language-specific file extension. `resolveFreeCall` has no
file-extension branching, so these guard the dispatch chain per
language without assuming extractor-specific symbol shapes.

Finding #3 — argCount threading
- 2-arg overload selected when argCount=2
- 0-arg overload selected when argCount=0

Finding #5 — Tier 3 (global) resolution
- Function globally visible but not imported. Asserts exact
  `TIER_CONFIDENCE.global === 0.5` and `reason === 'global'` to catch
  silent drift if the tier table is ever refactored.

Finding #6 — preComputedArgTypes worker path
- String overload matched via preComputedArgTypes=['String']
- Int overload matched via preComputedArgTypes=['int'] (lowercase,
  mirroring the parse-worker's inferred-literal shape; stored 'Int' is
  normalized via normalizeJvmTypeName at comparison time)

Finding #7 — Enum null-route documentation
- Enum-only free call asserts `toBeNull()` with an explanatory comment
  linking to the INSTANTIABLE_CLASS_TYPES rationale. NOT marked skipped
  — current behavior is intentional, not broken.

Finding #4 — Swift extension dedup guard
- Two same-name Class entries at different path lengths; exercises the
  full dispatch chain:
    1. filterCallableCandidates with 'free' strips Class → length 0
    2. hasClassTarget triggers resolveStaticCall
    3. Homonym ambiguity null-routes per SM-12 round-1 contract
    4. Constructor-form retry repopulates with both Classes
    5. Dedup block sorts by filePath.length → shortest path wins

Verification
- `tsc --noEmit` clean
- 3064 unit tests pass (+12)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-003-fix-sm13-resolve-free-call-review-findings-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4213879002

* refactor(SM-13): extract dedupSwiftExtensionCandidates shared helper

Follow-up to the PR #756 review fix. SM-13 duplicated the Swift
extension same-name collision dedup block between `resolveCallTarget`
and `resolveFreeCall` — two copies of identical 15-line logic with the
same heuristic (`filePath.length` sort, Class/Struct-only, `length > 1`
guard). Extract a single shared helper so the two sites cannot drift.

Changes
- New `dedupSwiftExtensionCandidates(candidates, tier)` helper defined
  alongside `tryOverloadDisambiguation`, with JSDoc documenting:
  - The Swift extension scenario it addresses
  - Why it is intentionally narrower than INSTANTIABLE_CLASS_TYPES
    (Class/Struct only, not Record — C#/Kotlin records don't exhibit
    the multi-file definition pattern, widening risks accidental
    dedup of legitimately distinct record types)
  - The return-null-on-no-match contract so callers can fall through
- `resolveCallTarget` tail dedup (was lines 1593-1610): replaced with
  a single `dedupSwiftExtensionCandidates` call
- `resolveFreeCall` tail dedup (was lines 1994-2012): same replacement
- Net line count: -32 insertions, -9 deletions in the consumer sites,
  +36 for the shared helper + JSDoc

Verification
- `tsc --noEmit` clean
- 3064 unit tests pass (including the R7 Swift dedup guard test added
  in the previous commit that exercises the full free-form retry
  chain through this helper)
- 1766 integration tests pass
- Zero regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756

* docs(SM-13): address PR #756 final review — comment cleanup only

Three documentation-only findings from the approval review. No
behavior change, no new tests, no code path modifications.

Finding #1 — stale line-number comment
- The comment inside `resolveFreeCall` at the `hasClassTarget` site
  referenced "lines ~1994-2008" for the Swift extension dedup block.
  Those lines were the inlined pre-SM-13 version; the block has since
  been extracted to `dedupSwiftExtensionCandidates`. Replaced the line
  reference with the helper name so future readers don't chase dead
  line numbers.

Finding #2 — fuzzy-widening asymmetry undocumented
- `resolveFreeCall` intentionally has no `widenCache` parameter and no
  D2 fuzzy-widening pass (unlike `resolveCallTarget`'s member-call
  path). Added an explicit "Asymmetry vs `resolveCallTarget`" paragraph
  to the JSDoc so a caller comparing the two signatures knows the
  skipped pass is deliberate and tied to Phase 5.

Finding #3 — constructor-form retry reasons undocumented
- `resolveStaticCall` can return null for three distinct reasons
  (empty instantiable pool, homonym ambiguity, ownerless Constructor
  nodes). The retry below it unconditionally re-filters with
  `'constructor'` form, which is correct for all three but not
  obvious. Added a structured three-case comment enumerating each
  reason and linking (a) to the SM-12 null-route contract, (b) to
  the R7 dedup test, and (c) to the currently-uncovered ownerless-
  Constructor path (noted as a future test candidate).

Verification
- `tsc --noEmit` clean
- 175 `resolveFreeCall` + `resolveStaticCall` + sibling tests pass
  (sanity check — no behavior change expected)
- No regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052

* test(SM-13): cover ownerless-Constructor retry + PHP free function

Two low-severity test gaps from PR #756 review comment 4215739052 —
previously addressed doc-only, now have concrete test coverage.

Finding #3 low — ownerless-Constructor retry path (previously comment-only)
- The retry after resolveStaticCall returns null handles three distinct
  null-return reasons. Cases (a) and (b) were already tested (Interface/
  Trait null-route from SM-12, Swift shadowing dedup from R7). Case (c) —
  resolveStaticCall step-4 bailout when the tiered pool contains
  ownerless Constructor nodes — was only covered by a comment.
- New test: Class + ownerless Constructor in tiered pool, callForm='free'.
  Exercises the full chain:
    1. resolveStaticCall step 3 walks classCandidates via
       lookupMethodByOwner — ownerless Constructor not in methodByOwner,
       nothing found.
    2. Step 4 detects Constructor in tiered pool, bails with null.
    3. resolveFreeCall retry re-runs filterCallableCandidates with
       'constructor' form, which prefers Constructor over Class per
       CONSTRUCTOR_TARGET_TYPES ordering.
    4. Single survivor returned.
- Asserts the Constructor node (not the Class) is the resolved target.

Low — PHP free function coverage gap
- The language coverage table in the same review flagged PHP free
  functions (top-level `function helper()` outside any class) as
  uncovered. Added a test mirroring the existing Go/Python/Rust/Java/
  JS language tests — exercises the `.php` dispatch path for free
  calls. Ruby and C/C++ remain uncovered; deferred to a future round
  since those languages also have other gaps in the broader test file.

Verification
- `tsc --noEmit` clean
- 3066 unit tests pass (+2 new regression tests)
- 1766 integration tests pass
- Zero regressions

Follows-up on: https://github.com/abhigyanpatwari/GitNexus/pull/756#issuecomment-4215739052

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 17:41:28 +01:00
JaysonAlbertandgfwangjie 338cb01ee0 [codex] fix large repository graph loading (#732)
* fix(web): stream large graph responses

* fix(server): harden graph streaming

* fix(ci): stabilize graph loading coverage

---------

Co-authored-by: gfwangjie <gfwangjie@gf.com.cn>
2026-04-09 17:40:24 +01:00
4450a14b98 feat(SM-12): Extract resolveStaticCall from resolveCallTarget (#754)
* Initial plan

* feat(SM-12): extract resolveStaticCall from resolveCallTarget

- Add resolveStaticCall(className, methodName, currentFile, ctx, argCount?) using
  lookupClassByName + lookupMethodByOwner for O(1) constructor/static resolution
- Add S0 fast path in resolveCallTarget for constructor/free-form class calls
- Export resolveStaticCall from call-processor.ts
- Add 11 unit tests covering constructor resolution, confidence tiers,
  arity disambiguation, and resolveCallTarget delegation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unrelated package-lock.json change

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor: shorten verbose test name per code review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c9471ca9-57ff-4dae-956e-e7ffdc326bc4

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-12): address PR #754 review findings

Addresses Claude's review comments on PR #754:

Performance
- Pass pre-computed `tiered` result into `resolveStaticCall` as optional
  `tieredOverride` parameter, eliminating the duplicate `ctx.resolve(className,
  currentFile)` on every constructor call path.
- Cache `freeFormHasClassTarget` in `resolveCallTarget` so the S0 fast path
  and the free-form constructor retry share a single `.some()` scan.

Architecture
- Reconcile `CLASS_LIKE_TYPES` (call-processor) with `CLASS_TYPES`
  (symbol-table): `CLASS_LIKE_TYPES = [...CLASS_TYPES, 'Impl']`. This makes
  the relationship explicit — the call resolver's set is a strict superset
  of the heritage-index set, guaranteeing anything reachable via
  `lookupClassByName` also passes the resolver filter. Trait is now included
  (harmless: traits have no Constructor nodes, so step-3 returns undefined
  and step-5 still returns the class-like node when unique). Documented
  the Interface inclusion rationale (static methods + MRO walker).
- Collapse `resolveStaticCall`'s `methodName` parameter into `className` —
  all call sites passed identical values. Named constructors (Dart
  `User.fromJson()`) arrive as member calls and go through
  `resolveMemberCall`. Documented the reserved path for when a language
  surfaces a static-method-shaped call with a distinct member name.
- Document the known gap: `callForm === 'member'` constructor patterns
  (e.g. Python `models.User()`) are handled by the tail fallback, not S0.

Tests
- Add tiered-override test asserting `ctx.resolve` is not re-invoked when
  a pre-computed result is passed in.
- Add language-specific `_resolveCallTargetForTesting` integration tests
  for Java (`new User()`), Python (`User()`), and Kotlin (`User()`).

Verification: 3031 unit + 1766 integration tests pass, zero regressions.

* fix(SM-12): restrict resolveStaticCall fallback to instantiable kinds

Addresses the high-severity finding from the Codex adversarial review of
PR #754: `resolveStaticCall`'s step-5 "return the class itself when no
Constructor node is found" fallback reused `CLASS_LIKE_TYPES`, which —
after SM-11 and PR #754's reconciliation — now includes `Interface`,
`Trait`, and `Impl`. That is the method-dispatch set, not the
instantiable set, so constructor-shaped calls could resolve to
non-instantiable nodes and emit false `CALLS` edges.

Concrete failure: Rust same-file `impl User { ... }` alongside
`struct User { ... }` — both land at same-file tier, the Impl is not
filtered out, and the step-5 fallback produces a `CALLS` edge to the
`Impl` block instead of the `Struct`. The same widening exposed
Interface / Trait targets in Java / C# / PHP / Scala.

Fix
- Introduce `INSTANTIABLE_CLASS_TYPES = {'Class', 'Struct', 'Record'}`
  as a sibling to `CLASS_LIKE_TYPES`, documenting the contract
  explicitly and cross-referencing `CONSTRUCTOR_TARGET_TYPES`.
- Update `CLASS_LIKE_TYPES` JSDoc to clarify it is the method-dispatch
  set and add an anti-pattern warning against reusing it for
  constructor-fallback filtering.
- Tighten `resolveStaticCall` step 5: filter `classCandidates` through
  `INSTANTIABLE_CLASS_TYPES` before the `length === 1` check. This
  strips `Impl` from the Rust shadowing scenario (leaving `Struct` as
  the sole instantiable target) and null-routes Interface / Trait /
  `Impl`-alone scenarios, matching the SM-10 R3 null-route precedent.
- Step 3 (explicit Constructor lookup via `lookupMethodByOwner`) is
  intentionally unchanged — its `def.type === 'Constructor'` check is
  the correct contract, and legitimate Constructor nodes attached to
  `Impl` owners still resolve correctly.

Tests (+10 regression scenarios)
- Positive guards: Struct, Record fallback paths.
- Null-route: Interface (Java/C#/TS), PHP Trait, Rust Trait.
- Rust same-file shadowing: Struct wins over Impl.
- Rust Impl-alone: null-routes (no Struct present).
- Step-3 preservation: Constructor owned by Impl still resolves to the
  Constructor node, proving step-5 tightening doesn't leak into step 3.
- Full cascade via `_resolveCallTargetForTesting` for Interface and
  Trait — confirms no downstream path silently re-introduces the edge.

Verification
- `tsc --noEmit` clean
- 3041 unit tests pass (+10)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md
Codex review job: review-mnrao7fr-nv9y0e

* fix(SM-12): address PR #754 second review round

Addresses the 9 findings from the follow-up review on PR #754.

Performance
- Align `freeFormHasClassTarget` with `INSTANTIABLE_CLASS_TYPES`: drop
  `Enum` (S0 would always return null for it — wasted lookup work) and
  add `Record` (C# records and Kotlin data classes were bypassing S0
  entirely). The trigger set and the fallback filter set now agree by
  construction, documented inline.

Documentation
- Remove stale single-line JSDoc on `CLASS_LIKE_TYPES` (line 57) that
  duplicated the full multi-line block immediately below it — tooling
  picks up the first block so the old one-liner was shadowing the
  current explanation.
- Rewrite the `resolveStaticCall` JSDoc step list to match the actual
  step boundaries in the implementation (steps 3, 4, 5 were blurred in
  the old description).
- Add inline comment on step 3 documenting the same-name lookup
  assumption (`${candidate.nodeId}\0${className}`) and the symmetric
  miss case for Python `__init__`-style constructors.
- Add inline comment on step 4 documenting that it also catches the
  ambiguous-step-3 case, and warning against removing the check
  without handling that path explicitly.
- Add inline comment on step 5 enumerating the three length outcomes
  (0 / 1 / >1) so future readers see the dominant null-route case.
- Document Ruby `User.new` as a known gap alongside Python
  `models.User()` in the S0 header comment.

Tests (+2 scenarios)
- Record free-form constructor call via `_resolveCallTargetForTesting`
  exercises the aligned `freeFormHasClassTarget` trigger end-to-end,
  closing the gap where the direct `resolveStaticCall` test passed
  but the integration path was silently bypassing S0.
- Arity threading via `_resolveCallTargetForTesting` asserts that
  `call.argCount` flows through resolveCallTarget → S0 →
  resolveStaticCall → lookupMethodByOwner, catching any future
  regression where the argCount is dropped at the S0 call site.

Verification
- `tsc --noEmit` clean
- 3043 unit tests pass (+2)
- 1766 integration tests pass
- Zero regressions

Plan: docs/plans/2026-04-09-002-fix-sm12-constructor-fallback-instantiable-only-plan.md
Review: https://github.com/abhigyanpatwari/GitNexus/pull/754#issuecomment-4213536094

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 12:09:07 +01:00
Kunal Hemnani 3f0b8c1a5b fix(ingestion): replace lookupExact with lookupExactAll in named-binding-processor (#755) 2026-04-09 11:57:36 +01:00
bb68cc1eb0 Extract resolveMemberCall from resolveCallTarget (SM-11) (#744)
* Initial plan

* feat(SM-11): extract resolveMemberCall from resolveCallTarget

- Create resolveMemberCall(ownerType, methodName, currentFile, ctx, heritageMap?)
  that uses owner-scoped + MRO resolution only (no fuzzy lookup)
- resolveCallTarget delegates member calls (D0 path) to resolveMemberCall
- walkMixedChain uses resolveMemberCall for owner-scoped member-call resolution
- Add 7 unit tests for resolveMemberCall covering direct, inherited, MRO,
  null cases, and confidence tier assertions
- Export resolveMemberCall for external use

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3b7889a9-5f2f-4572-8904-45084210f10d

* fix(SM-11): address PR #744 review

Blocking fixes:

- B1: Revert unrelated package-lock.json gitnexus-shared addition

- B2: Document confidence-tier semantic change on resolveMemberCall

Performance / coupling fixes:

- S1: walkMixedChain now calls resolveMethodByOwner directly (hot path) to avoid throwaway ResolveResult allocation per chain step

- S2: Thread tier from resolveMethodByOwner via { def, tier } tuple; eliminates double ctx.resolve

Alignment with semantic-model plan (Phase 3 target):

- resolveMethodByOwner now iterates ALL class-like candidates from ctx.resolve, deduplicating matches by nodeId. Absorbs D4's ownerId-filtering into the owner-scoped path.

- Handles homonym classes (two Users in different files) without falling through to D1-D4 fuzzy widening

- Shared-ancestor MRO walks automatically dedup (both homonyms walk to same base method)

- Unified direct-vs-MRO lookup under a single canWalkMRO check

Tests added:

- T1: Three D0 skip-condition tests via new _resolveCallTargetForTesting internal export (overloadHints, preComputedArgTypes, hasActiveModuleAlias)

- T2: Rust qualified-syntax null test (trait-inherited method) + direct impl control

- T3: C++ leftmost-base diamond inheritance test

- B2 lock-in: cross-file class tier assertion

- Homonym disambiguation: only-one-owns-method, both-own-method ambiguity, shared-ancestor MRO convergence

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3014 passed

- vitest run test/integration/resolvers/: 1746 passed

* test(SM-11): address second PR #744 review round + per-language integration tests

Review fixes (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4211877593):

P1 (Performance): Replace Map allocation in resolveMethodByOwner with a firstDef+ambiguous flag pattern. Zero allocation for the common single-candidate case on the hot path — the previous Map approach allocated on every member call regardless of whether deduplication was needed.

P2 (Test gap): Strengthen the module-alias D0 skip test with a homonym fixture (two Users in different files). Previously the test passed whether or not D0 was actually bypassed; the new version proves D0 must be skipped by showing that resolveMemberCall directly returns null (ambiguous) but D1-D4 with alias narrowing picks the right one. Also fixes the underlying D2-vs-alias widening interaction: when filteredCandidates was narrowed by module-alias disambiguation, D2 no longer widens back to the full fuzzy pool (introduces aliasNarrowed boolean flag).

L1 (Language coverage): Add C# and Kotlin implements-split tests at the resolveMemberCall layer.

L2 (Maintainability): Export OverloadHints as @internal so the test can use a direct cast instead of fragile Parameters<...> type inference.

Per-language integration tests:

- rust-child-extends-parent: Direct impl method resolution via D0 (with honest documentation of the trait-method-as-Function gap that is Phase 5 / SM-16 scope)

- java-interface-default-method: User implements Validator with default method resolved via implements-split MRO

- csharp-interface-default-method: Same pattern for C# 8.0+ default interface methods

- kotlin-interface-default-method: Same pattern for Kotlin interfaces with default implementations

- python-multi-level-mro: 3-level C3 linearization (Grandparent ← Parent ← Child)

- cpp-diamond-inheritance: Classic diamond (Base ← A, B ← Derived) via leftmost-base MRO

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3015 passed

- vitest run test/integration/resolvers/: 1763 passed (+17 new per-language tests)

* fix(SM-11): Codex adversarial review corrections + deeper D0 fixes

Addresses the three high-severity findings from the Codex adversarial review of PR #744 (https://github.com/abhigyanpatwari/GitNexus/pull/744#issuecomment-4212075120), plus four deeper fixes discovered during regression triage. All discovered issues are now addressed end-to-end rather than papered over with tail-return fallbacks.

Codex review findings:

R1 (C++ diamond): The cpp-diamond-inheritance fixture used non-virtual inheritance, which is genuinely ambiguous in real C++ (two Base subobjects). Changed A and B to use 'virtual public Base' so there's a single shared Base subobject and d.method() is an unambiguous call that the leftmost-base MRO walk correctly resolves.

R2 (C# default-interface): The csharp-interface-default-method fixture called user.Validate() via a User-typed variable, but C# does not inherit default interface methods as callable class members — the call is only valid through an interface-typed variable. Changed App.cs to 'IValidator user = new User(...)' which is the idiomatic dispatch pattern.

R3 (resolveCallTarget tail-return): When D1-D4 receiver filtering produced zero file-matched and zero owner-matched candidates for a member call, the function fell through to the permissive single-candidate tail return — silently emitting CALLS edges for methods that don't belong to the receiver. Added an explicit null-route inside the D1-D4 block that fires only when both filters yielded 0.

R4 (Rust negative assertion): Added the c.trait_only() negative integration test in rust.test.ts demonstrating that direct member calls on Rust structs do not walk trait ancestry. The test now passes because of R3 (previously fell through to the tail return).

Regression triage discoveries:

1. D0 was dead code on the sequential pipeline. The sequential path sets overloadHints for every call regardless of whether the method is overloaded, and the original D0 skip condition '!overloadHints && !preComputedArgTypes' was therefore always false. The Java/C#/C++ SM-9/SM-10 inheritance tests were passing ONLY via the tail-return fallback. Fix: narrow the skip to 'overloadHints && filteredCandidates.length > 1' — skip D0 only when there are actually multiple candidates that need overload disambiguation.

2. lookupMethodByOwner couldn't disambiguate arity-differing overloads (e.g. C++ greet() vs greet(string)). With D0 now firing on the sequential path, same-name/different-arity overloads would collapse to an arbitrary first pick. Fix: added an optional argCount parameter to lookupMethodByOwner + lookupMethodByOwnerWithMRO that filters the overload set by parameterCount/requiredParameterCount before the returnType dedup.

3. Python and Rust class methods are captured as Function nodes (not Method) with ownerId set to the class. The methodByOwner index only accepted 'Method' and 'Constructor' types, so Python class methods and Rust trait methods were invisible to D0. Fix: extended the methodByOwner indexing condition to include 'Function' when ownerId is set. This also unlocks the Rust trait-method negative assertion by ensuring the qualified-syntax MRO strategy has something to return null for.

4. D0 was being skipped when a local variable shadowed an imported module name (Python 'from models.c import C; c = C()' creates both a module alias 'c → models/c.py' AND a typed local 'c'). Fix: the D0 skip now gates on 'aliasNarrowed' (a new boolean tracking whether the alias block actually narrowed filteredCandidates) instead of 'hasActiveModuleAlias'. If the method isn't in the aliased module, the receiver is a typed local variable and D0 should run.

5. PHP trait walk missed the HasTimestamps trait because lookupClassByName did not include 'Trait' type. buildHeritageMap uses lookupClassByName to resolve parent names, so 'BaseModel use HasTimestamps' was failing to register an ancestor edge for BaseModel → HasTimestamps. Fix: added 'Trait' to CLASS_TYPES. The trait is now a valid class-like type for heritage resolution (PHP use, Rust impl Trait for Struct, Scala traits).

Test updates:

- Updated the 'no heritageMap' unit test in call-processor.test.ts to assert the correct null-route behavior instead of the old tail-return fallback.

- Added a new unit test asserting Trait inclusion in the class set.

- Updated the 'does NOT include other type-like labels' test to remove Trait from its rejection set.

Verification:

- tsc --noEmit: clean

- vitest run test/unit/: 3016 passed (+1 new Trait inclusion test)

- vitest run test/integration/resolvers/: 1764 passed (+1 new Rust negative assertion)

- Zero regressions

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-09 09:52:12 +01:00
Roshan Warrierandtxhno d6debf3324 fix(symbol-table): index constructors in methodByOwner (#753)
Co-authored-by: txhno <198242577+txhno@users.noreply.github.com>
2026-04-09 08:26:15 +01:00
Pratyush Sharma 9ab92a97d0 fix(deps): pin tree-sitter-c override to resolve peer dep conflict (#720) (#723) 2026-04-09 06:40:17 +01:00
Murat Çelik 4fde5f241b feat: print skipped large file paths in verbose analyze output (#745) 2026-04-09 06:18:32 +01:00
evolution 9f9bbcd744 feat: support GITNEXUS_HOME env var to customize global directory (#746) 2026-04-09 06:18:17 +01:00
Cocoon-Break fd67cfd5a7 docs: fix web UI install link spacing in README (#731) 2026-04-09 06:16:14 +01:00
Pratyush Sharma 3f28f7ead5 fix(web): correct dev-mode serve command in OnboardingGuide (#725) 2026-04-09 06:15:14 +01:00
d9ba9aa998 SM-10: Add MRO fast path before D2 fuzzy widening in resolveCallTarget (#741)
* Initial plan

* Add MRO fast path before D2 fuzzy widening in resolveCallTarget

When receiverTypeName is known, try resolveMethodByOwner (owner-scoped
+ MRO lookup) before falling back to the expensive lookupFuzzy in D2.
This short-circuits cross-file member call resolution for the common
non-overloaded case.

The fast path is skipped when overload disambiguation hints are
available (overloadHints or preComputedArgTypes) to avoid picking the
wrong overload for same-return-type overloaded methods.

Passes heritageMap to resolveCallTarget from all 4 call sites:
- Language seed path (processCalls)
- Sequential path (processCalls)
- walkMixedChain fallback
- Worker path (processCallsFromExtracted)

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9e49521f-2472-47bc-96e9-be4a46b073f0

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-10): address PR #741 review

Correctness:
- Module-alias guard for D0. When call.receiverName matches an active
  entry in ctx.moduleAliasMap for the current file, D0 is now skipped
  and resolution falls through to D1-D4 which respects the
  alias-narrowed candidate pool. Prevents a homonymous class in a
  different file from being picked by ctx.resolve(receiverTypeName)
  inside resolveMethodByOwner. New unit test pins the contract.

Unit tests (call-processor.test.ts — 3 new):
- D0 hit: child.parentMethod() resolves via MRO walk when
  heritageMap is provided.
- D0 skipped: same scenario still resolves via D1-D4 when heritageMap
  is undefined (backward-compat guard).
- Module-alias guard: two files both define class User with a save()
  method; 'import auth_mod as auth' in app.py must resolve
  auth.user.save() to auth_mod.py, not user_mod.py.

Integration language coverage (+3 fixtures/tests):
- swift-child-extends-parent — first-wins, gated on swiftAvailable.
- ruby-child-extends-parent   — first-wins.
- php-child-extends-parent    — first-wins (uses ParentClass since
  'Parent' is a PHP reserved word).

* test(SM-10): address second PR #741 review round

Unit tests (call-processor.test.ts, +2 new):
- overloadHints guard: Java source with two same-return-type overloads
  method(int) and method(String), int added first so lookupMethodByOwner
  would return it. processCalls auto-generates overloadHints for Java,
  forcing D0 to be skipped. o.method("hello") must resolve to
  method(String) via literal-inferred disambiguation.
- preComputedArgTypes guard: worker-path equivalent via
  processCallsFromExtracted with ExtractedCall.argTypes=['String'].
  Same two overloads, same correctness guarantee.

Integration tests (+2 fixtures + test blocks):
- go-child-extends-parent    — struct embedding, first-wins
  (Go structs are labeled 'Struct' not 'Class' in GitNexus).
- dart-child-extends-parent  — extends, first-wins, gated on
  dartAvailable like other Dart tests.

Documentation:
- Expanded the fallthrough comment in resolveMethodByOwner to clarify
  that unknown-extension paths land on plain lookupMethodByOwner
  without an ancestor walk, and that D1-D4 still runs on D0 miss.

* test(SM-10): D0 miss with heritageMap present falls through to D1-D4

Closes the last remaining gap from PR #741 review round 3. The existing
'D0 skipped' test only covered the heritageMap=undefined case, leaving
the miss-with-heritageMap path implicitly covered by integration tests
only. This adds a focused unit test where:

- Class Obj has a method doWork findable via tiered resolution
  (import-scoped) but intentionally NOT registered in methodByOwner
  (no ownerId), so lookupMethodByOwner misses.
- heritageMap is provided but built from an empty heritage array, so
  getAncestors(class:Obj) returns []. The MRO walk yields no parents.
- lookupMethodByOwnerWithMRO therefore returns undefined → D0 miss.
- D1 resolves the receiver type; D2 widens via lookupFuzzy;
  D3 file-filter picks the single matching candidate.
- A CALLS edge must still be emitted — D0 miss must not swallow
  the call.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 23:24:07 +01:00
c19e76a4a3 feat(SM-9): Add lookupMethodByOwnerWithMRO using HeritageMap (#740)
* Initial plan

* feat(SM-9): add lookupMethodByOwnerWithMRO with HeritageMap parent chain walking

- Export c3Linearize from mro-processor.ts for reuse
- Add lookupMethodByOwnerWithMRO in call-processor.ts with MRO strategy support
- Update resolveMethodByOwner to fall back to MRO walk when HeritageMap available
- Thread heritageMap through walkMixedChain for chain resolution
- Add 10 unit tests covering all acceptance criteria

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat(SM-9): add Java integration test with class Child extends Parent fixture

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: address code review comments on MRO strategy documentation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cc58249b-42f1-45a9-89fb-e3917e4d0171

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* perf(SM-9): address PR #740 review comments

- Eliminate double direct lookup in resolveMethodByOwner: delegate
  straight to lookupMethodByOwnerWithMRO when a HeritageMap is
  available (the MRO helper already does the direct lookup before
  walking ancestors). Fallback path handles the no-HeritageMap case.
- Memoize C3 linearization per HeritageMap via a WeakMap keyed cache.
  HeritageMap is immutable after build, so C3 results are stable for
  its lifetime; WeakMap lets the cache auto-drain when the HeritageMap
  is GC'd. Null sentinel caches linearization failures so cyclic
  hierarchies are not reprocessed. Eliminates per-call buildParentMap +
  c3Linearize on Python codebases.
- ancestors variable typed as readonly to accept the cached result
  without copying.
- Add four missing MRO unit tests: Kotlin implements-split, C#
  implements-split, JavaScript first-wins (separate provider from TS),
  and C++ leftmost-base diamond (first diamond test for C++).

* fix(SM-9): CI prettier + address PR #740 follow-up review

- Fix CI prettier failure in test/integration/resolvers/java.test.ts
  (auto-formatted — was introduced in 37563a31 before my first fix
  commit but had not been caught locally).
- Pin caller on the SM-9 Java integration test (parentMethodCall.source
  === 'run') so a regression that misattributes the CALLS edge fails.
- Add two implements-split unit tests:
  * Ambiguous default from two interfaces → BFS first-wins. Pins the
    contract that lookupMethodByOwnerWithMRO returns a defined result
    (full ambiguity detection is deferred to computeMRO graph pass).
  * Class method precedence over interface default: Child extends Base
    implements IFoo where both define handle() — documents that BFS
    visits the extends edge first, matching Java's class-wins rule.
- Add @internal JSDoc on lookupMethodByOwnerWithMRO clarifying it is
  exported only for testing; resolveMethodByOwner is the proper entry
  point for callers.

* test(SM-9): per-language integration fixtures and tests for inherited method resolution

Extends the SM-9 integration coverage beyond Java with six new
child-extends-parent fixtures, one per MRO strategy:

- python-child-extends-parent       → C3 strategy
- typescript-child-extends-parent   → first-wins
- javascript-child-extends-parent   → first-wins (separate provider)
- kotlin-child-extends-parent       → implements-split
- csharp-child-extends-parent       → implements-split
- cpp-child-extends-parent          → leftmost-base

Each fixture follows the java-child-extends-parent pattern:
- Parent class with a single method
- Child class extending Parent, no override
- App class/function that instantiates Child and calls
  the parent method — exercises the full ingestion pipeline,
  HeritageMap construction, and lookupMethodByOwnerWithMRO walk.

For every fixture the matching integration test asserts:
- Parent and Child classes are detected
- Child → Parent EXTENDS edge is emitted
- The parent-method call resolves to the correct target file
- The caller is pinned (source === 'run' / 'Run') to catch
  edge misattribution regressions

Rust is intentionally omitted — its qualified-syntax strategy
returns undefined from lookupMethodByOwnerWithMRO by design, so
there is no inherited-method resolution to assert against.

All 1739 integration resolver tests pass (+18 new SM-9 tests
across 6 languages).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 20:46:45 +01:00
b75e76d44a feat(SM-8): Build HeritageMap from accumulated ExtractedHeritage[] (#739)
* Initial plan

* feat(SM-8): add HeritageMap with MRO-aware parent/ancestor lookup

- New heritage-map.ts: HeritageMap interface with getParents() and getAncestors()
- buildHeritageMap() consumes ExtractedHeritage[], resolves names via lookupClassByName
- Cycle protection and bounded depth (MAX_ANCESTOR_DEPTH=32) in getAncestors
- Worker path: HeritageMap built from deferredWorkerHeritage, threaded into processCallsFromExtracted
- Sequential path: Heritage accumulated across chunks, HeritageMap built after all chunks, passed to processCalls
- 18 unit tests covering parent lookup, multi-level, diamond, cycles, missing parent, bounded depth

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: rename cycle test for clarity per code review

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/c413e0a3-5d63-4ddb-8ece-02fe6ed99efd

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(SM-8): merge implementor map into heritage map

- Add `getImplementorFiles(interfaceName)` to HeritageMap interface
- Build implementor index (interface name → file paths) alongside parent
  lookup in `buildHeritageMap`, using same `resolveExtendsType` logic
- Remove `ImplementorMap` type, `buildImplementorMap`, `mergeImplementorMaps`
  from call-processor.ts
- Update `findInterfaceDispatchTargets`, `processCalls`, and
  `processCallsFromExtracted` to use HeritageMap for both parent
  lookup and implementor dispatch
- Pipeline: single `buildHeritageMap` call replaces separate
  buildImplementorMap + buildHeritageMap for both worker and
  sequential paths
- Migrate implementor tests from call-processor.test.ts to
  heritage-map.test.ts (4 new getImplementorFiles tests)
- Update interface dispatch test to use buildHeritageMap instead
  of hand-constructed ImplementorMap

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: rename implementor test for clarity per code review

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/085dffb4-b31e-4aa5-9aa3-4314bc0010e7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(SM-8): address PR #739 review comments

- pipeline.ts: cache chunk file contents from Pass 1 to eliminate
  double-read of sequential chunks in Pass 2. Peak memory drains
  incrementally as Pass 2 processes each chunk.
- heritage-map.ts: document Rust trait-impl omission from implementor
  index and the interface-name collision limitation.
- heritage-map.test.ts: add six tests covering the extends->IMPLEMENTS
  path across C# (interfaceNamePattern), Swift (heritageDefaultEdge),
  Java (symbol-table Interface lookup), Kotlin, PHP, and the Rust
  trait-impl omission.
- pipeline.ts: comment why the heritage accumulation uses a manual
  push loop instead of spread (ref #650).

* test(SM-8): address second PR #739 review pass

- Add TypeScript implements test to getImplementorFiles (closes
  the .ts coverage gap flagged by the bot reviewer).
- Tighten deep-chain boundary assertion from toBeLessThanOrEqual(32)
  to toBe(32) so a future regression returning fewer ancestors
  fails loudly. Added an ancestors[31] === 'class:Level32' check
  to pin the upper boundary.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-04-08 19:00:08 +01:00
MyShiningand许恩宁 83b5bec293 [cli] Replace owner-filtered method lookups in type-env (#736)
* refactor(type-env): use owner method lookup

* test(type-env): cover owner lookup edge cases

* test(type-env): cover inherited overload ambiguity

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 17:41:09 +01:00
MyShiningand许恩宁 3388ae16d7 [cli] Replace Phase P class checks with class lookup index (#734)
* refactor(call-processor): use class lookup index in phase p

* test(call-processor): cover class lookup fallback

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 14:48:54 +01:00
MyShiningand许恩宁 d784f591b2 [cli] Replace class-type fuzzy lookups in type-env.ts (#733)
* refactor(type-env): use class lookup index for type resolution

* test(type-env): add lookupClassByName regression coverage

* test(type-env): expand class lookup regression coverage

---------

Co-authored-by: 许恩宁 <xuenning@qiyi.com>
2026-04-08 14:12:01 +01:00
Kunal Hemnani 0f43190543 feat(symbol-table): add fuzzy lookup counters (#708) 2026-04-08 07:53:30 +01:00
Roshan Warrier fe87ff8f74 fix(symbol-table): index constructors in methodByOwner (#694) 2026-04-08 06:32:02 +01:00
Deepak Chauhan be2401061e [cli] Add qualified class lookups to SymbolTable (#716) 2026-04-07 22:57:18 +01:00
Deepak Chauhan b73233d232 feat(symbol-table): add class name lookup index (#707) 2026-04-07 13:29:50 +01:00
Tushar Dhawas (Kyo) 1c8ae5eb46 refactor: extract CLASS_LIKE_TYPES constant (#693)
* refactor: extract CLASS_LIKE_TYPES constant

* chore: apply prettier formatting
2026-04-07 11:56:03 +01:00
Zander Raycraft b73928f732 scarf (#688) 2026-04-06 18:39:43 -05:00
Dmytro Semchuk 19faf3b326 fix(docs): fix codex duplicate typo in main readme file (#687) 2026-04-06 22:20:35 +01:00
Gergő Magyar cb772b9e29 feat: lookupMethodByOwner index for O(1) cross-class chain resolution (#665)
Add eagerly-populated methodByOwner index to SymbolTable, keyed by
ownerNodeId\0methodName. Used by walkMixedChain as a fast path for
resolving intermediate method calls in cross-class chains like
user.getAddress().getCity().getZipCode(), avoiding expensive fuzzy
lookups when the owner type is already known.

Handles overloaded methods: returns the first match when all overloads
share the same returnType, undefined when return types differ (ambiguous).

- Add lookupMethodByOwner to SymbolTable interface + implementation
- Add resolveMethodByOwner helper in call-processor.ts
- Add fast path in walkMixedChain before resolveCallTarget fallback
- Add Java cross-class chain fixture + 6 integration tests
- Add 148 unit tests for methodByOwner index behavior
2026-04-06 10:20:04 +01:00
ivkondandClaude Opus 4.6 10f8815639 fix(ignore): respect negation patterns in .gitnexusignore (#654)
* fix(ignore): respect negation patterns in .gitnexusignore childrenIgnored

childrenIgnored checked `ig.ignores(rel) || ig.ignores(rel + '/')` which
short-circuited on the bare path — directory-only negation patterns like
`!iOS/` were missed because `ig.ignores('iOS')` treats the path as a file.
Now only checks with trailing slash since childrenIgnored is only called
for directories. Bare-name patterns (e.g. `local`) still match per gitignore spec.

Fixes #596

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(ignore): add edge-case for bare `!dir` negation pattern

Verifies that `!iOS` (without trailing slash) also un-ignores the iOS/
directory — confirms the `ignore` package normalizes both `!dir` and
`!dir/` forms consistently when tested with a trailing-slash path.

Addresses non-blocking review suggestion on #654.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs(ignore): link ignore package docs for bare-name normalization

Adds references to the `ignore` package documentation in both the
childrenIgnored comment and the bare-negation test, explaining why
`!iOS` (without trailing slash) also re-includes the iOS/ directory.

Addresses non-blocking review suggestion on #654.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 08:32:47 +01:00
Abhigyan Patwari 6ead5e5986 fix(setup): prefer global gitnexus binary over npx for MCP config (#653) 2026-04-06 07:46:10 +01:00
Abhigyan Patwari 14791ded4a fix(server): return clean CORS rejection instead of 500 error (#646) 2026-04-06 07:45:19 +01:00
tantkandtian kian tan 9eeb20bb04 fix: replace Array.push(...spread) with loop to prevent stack overflow (#650)
* fix: replace Array.push(...spread) with loop to prevent stack overflow

On large codebases (78K+ C files), deferred arrays in
runChunkedParseAndResolve accumulate 100K+ entries. The spread
operator in push(...array) puts every element on the call stack
as a function argument, exceeding the maximum call stack size.

Replace all 11 occurrences of `arr.push(...other)` with
`for (const _item of other) arr.push(_item)` which uses
constant stack space regardless of array size.

Fixes #649

* fix: also replace push(...spread) in parsing-processor.ts

* style: format pipeline.ts to match prettier config

---------

Co-authored-by: tian kian tan <tan@example.com>
2026-04-05 21:54:09 +01:00
Gergő Magyar 5a7c0fdbb1 feat: same-arity overload disambiguation via type-hash suffix (#651) (#658)
* feat: same-arity overload disambiguation via type-hash suffix (#651)

Add ~type1,type2 suffix to Method/Constructor node IDs when same-arity
overloads with different parameter types exist in the same class. Also add
$const suffix for C++ const-qualified method overloads via new isConst field.

Key changes:
- typeTagForId() detects same-arity collisions and appends ~typeTag
- constTagForId() detects const/non-const collisions and appends $const
- TS/JS excluded from type-hashing (overload signatures collapse to impl body)
- Sequential findEnclosingFunction fixed: falls through on ambiguous same-class
  candidates instead of picking first; fallback path includes typeTag + constTag
- Per-call-site integration tests across Java, C#, Kotlin, C++, TypeScript
- Cross-file + chain resolution tests for all 5 languages
- C++ isConst extraction via tree-sitter type_qualifier in function_declarator

1710 integration + 18 unit tests pass.

* fix: preserve generic/template args in type-hash, perf + type safety fixes

- Add rawType field to ParameterInfo preserving full type text (vector<int>)
  while type stays simplified (vector). typeTagForId uses rawType for tags.
- Populate rawType in all 11 language method extractors
- Add buildCollisionGroups() to pre-group methods by name#arity (O(N) once
  per class instead of O(N) per method call)
- Cache method extraction in call-processor findEnclosingFunction fallback
- Fix null guards on getLanguageFromFilename in all findEnclosing paths
- Tighten SKIP_TYPE_HASH_LANGUAGES to ReadonlySet<SupportedLanguages>
- Document ID stability invariant on first overload introduction
- C++ integration tests: template overloads (vector<int> vs vector<string>),
  cross-file template + chain resolution, out-of-class method definitions

1718 integration + 20 unit tests pass.

* fix: add rawType to method-extraction unit test assertions

All 26 parameter .toEqual() assertions in method-extraction.test.ts
needed the new rawType field added to match ParameterInfo schema change.

* perf: cache tempMap/groups per class, consolidate extractFromNode

- Cache derived method map + collision groups per classNode.id in
  parsing-processor (avoids rebuild per method in same class)
- Replace per-call extractFromNode with cached class extraction +
  funcName:line lookup in call-processor fallback (avoids AST walk
  per call site)
- Remove dead clearEnclosingFunctionCache export, fix JSDoc

* test: add sequential-path integration test for same-arity overloads

Add skipWorkers option to PipelineOptions to force sequential parsing.
New test suite verifies type-hash disambiguation produces identical
results through the sequential path (parsing-processor + call-processor
findEnclosingFunction) as the worker path.
2026-04-05 21:51:55 +01:00
Gergő Magyar 0561d24efd feat: METHOD_IMPLEMENTS edges, overload disambiguation, MethodExtractor unification (#574) (#642) 2026-04-04 18:41:47 +01:00
Abhigyan Patwari 153262304c fix(mcp): unify stdout silencing to prevent embedder/pool-adapter conflicts (#645) 2026-04-04 11:56:49 +01:00
Abhigyan Patwari 16cf4c503e fix(web): replace aggressive heartbeat disconnect with graceful reconnection (#643) 2026-04-04 11:56:35 +01:00
Abhigyan Patwari 57951a197b fix(web): scope all backend calls to the active repo, not always the first (#644) 2026-04-04 11:55:34 +01:00
Gergő MagyarandClaude Opus 4.6 63fc4c795f feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby (#624)
* feat: MethodExtractor configs for Python, PHP, Swift, Dart, Rust, Ruby with exhaustive integration tests

Add per-language MethodExtractionConfig for all remaining tree-sitter languages
(RFC #568 PR 2). Each config follows the established createMethodExtractor()
factory pattern — no new types, no parse-worker changes.

Configs:
- Python: @abstractmethod, @staticmethod/@classmethod, *args/**kwargs, type hints, _/__ visibility
- PHP: abstract/final/static keywords, PHP 8 #[] attributes, __construct/__destruct
- Swift: 5-level visibility, protocol-as-abstract, static/class methods, @ attributes
- Dart: _ convention visibility, abstract (no body), method_signature unwrapping
- Rust: pub visibility, &self receiver, trait_item + impl_item, #[] attributes
- Ruby: positional visibility via sibling-walk, singleton_method as static

Integration fixtures (18 directories) covering 3 resolution patterns:
- Method enrichment: parameterTypes, isAbstract, isFinal, annotations on graph nodes
- Overload dispatch: arity-based CALLS resolution via parameterTypes
- Abstract dispatch: abstract/concrete method distinction (Python, PHP, Rust, Swift)

Go deferred — requires factory changes for receiver-based method extraction.

Closes #571

* fix: address code review findings across 6 MethodExtractor configs

Fix all actionable items from the PR #624 deep-dive review:

Dart (critical — fixes 6 CI failures):
- isDartStatic: check children first, siblings as fallback
- isDartAbstract: handle declaration nodes for abstract methods
- extractSingleParam: detect required keyword as sibling token
- Add declaration to methodNodeTypes, mixin_declaration to typeDeclarationNodes
- Add member call query for variable assignments in tree-sitter-queries

Python:
- hasDecorator now matches dotted paths (e.g. @abc.abstractmethod)
- Fix version comment from ^0.23.6 to 0.23.4

PHP:
- Add enum_declaration to typeDeclarationNodes (PHP 8.1+)
- Add version comment for 0.23.12

Swift:
- Add isOverride using hasKeyword/hasModifier pattern

Rust:
- Fix version comment from ^0.23.2 to 0.23.1

Also: identifier fallback in generic.ts for mixin owner names,
Dart integration test label fix (Method vs Function), version
comment for tree-sitter-dart 1.0.0.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Dart extension_declaration and Ruby module_function support

Dart:
- Add extension_declaration to typeDeclarationNodes and extension_body
  to bodyNodeTypes — extension methods are now extracted into the graph
- Add extension_declaration and mixin_declaration to CLASS_CONTAINER_TYPES
  for HAS_METHOD edge resolution

Ruby:
- module_function now maps to visibility 'private' in extractRubyVisibility
- module_function methods marked isStatic via backward-walk in isStatic
- Override semantics: private/public after module_function resets isStatic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(go): Go MethodExtractor config with receiver-based extraction

Add Go as the 13th language with a per-language MethodExtractor config.
Go methods are top-level (not nested in struct bodies), so this adds
extractFromNode() to the MethodExtractor interface for direct method
node extraction without an enclosing class.

Config extracts:
- Name from field_identifier (methods) / identifier (functions)
- Return type including multi-return (first type from parameter_list)
- Parameters with variadic support
- Visibility via uppercase/lowercase convention
- Receiver type with pointer unwrapping (*User → User)
- isStatic for functions (no receiver)

Infrastructure:
- extractOwnerName optional hook on MethodExtractionConfig
- extractFromNode on MethodExtractor (factory auto-implements)
- Parse-worker uses extractFromNode when no enclosing class found
- method_declaration added to CLASS_CONTAINER_TYPES

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: method enrichment integration tests for 7 languages + TS abstract class fix

Add method-enrichment integration test fixtures and test blocks for
Go, C++, Java, Kotlin, TypeScript, JavaScript, and C#. Each fixture
tests: class detection, HAS_METHOD edges, EXTENDS edges, isAbstract,
isStatic, annotations, parameterTypes, and CALLS edge resolution.

Fixes found during testing:
- Remove method_declaration from CLASS_CONTAINER_TYPES (added for Go
  but broke Java/C# HAS_METHOD edge resolution — method_declaration
  is also Java's method node type)
- Add abstract_class_declaration query to TypeScript tree-sitter
  queries (was missing, so abstract classes were invisible to pipeline)

1699 integration tests pass across 20 test files, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: format typeDeclarationNodes array for better readability in PHP config

* fix: Go interface methods + Rust impl-for-Struct owner resolution

Go:
- Add method_elem to methodNodeTypes so interface method signatures
  are extractable as abstract methods
- Integration test: Animal interface detected, Speak isAbstract,
  CALLS edges from app.go

Rust:
- Add extractOwnerName to resolve impl Trait for Struct to the
  concrete Struct (not the Trait) — fixes method misattribution
- Fix findEnclosingClassId to generate Struct: label (not Impl:)
  for impl blocks so HAS_METHOD edges resolve to struct nodes
- Tighten abstract-dispatch test: assert SqlRepo owns find/save

generic.ts:
- Fix extractOwnerName fallback: when hook returns a value, skip
  both name-field and type_identifier scan (was overwriting result)

1703 integration tests pass, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: code review response — Rust impl label, Swift params, Dart async, sequential methodExtractor

Address code review findings from PR #624:

- ast-helpers: Rust `impl Trait for Struct` uses Struct label (matches existing
  graph node), plain `impl Struct` uses Impl label (matches definition.impl)
- swift: fix parameter type extraction (user_type not type_annotation), detect
  default values as function_declaration siblings, add version comment
- dart: isDartAsync now detects async*/sync* generators, add clarifying comment
  for declaration nodes in extension bodies
- python: correct isFinal comment (PEP 591 @typing.final exists, just not modeled)
- parsing-processor: port methodExtractor enrichment to sequential path so
  isAbstract/isStatic/visibility/annotations/isFinal populate on <15-file repos
- tests: remove silent `if (prop !== undefined)` guards, assert properties
  directly, fix label queries (Dart Method vs Function, Swift Method for protocol
  methods), add Rust HAS_METHOD sourceLabel tests, Swift parameterTypes tests,
  and Dart async/sync* integration tests with fixture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Rust grammar gap + qualified method IDs to resolve same-file collisions

Phase 1 — Rust grammar:
- Add function_signature_item query to RUST_QUERIES so abstract trait methods
  (fn speak(&self) -> String;) become graph nodes with isAbstract=true

Phase 2 — Qualified method IDs:
- findEnclosingClassInfo returns {classId, className} for AST-based class lookup
- Both parsing paths (sequential + worker) qualify method/property IDs with
  enclosing class: Method:file:ClassName.method instead of Method:file:method
- extractFuncNameFromSourceId handles ClassName.method format
- Fixes silent data loss when same-name methods in different classes shared a
  file (e.g., Animal.speak and Dog.speak both now exist as distinct graph nodes)

Test updates:
- Rust: abstract+concrete trait methods both verified, function count adjusted
- Python: static method disambiguation now emits 2 CALLS edges (correct — no
  more ID collision masking the second call)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: owner-aware resolution for qualified method IDs

Address Codex adversarial review findings after qualified ID change:

- findEnclosingFunction: disambiguate candidates by ownerId when multiple
  same-name methods exist in file; qualify fallback-generated IDs
- findEnclosingFunctionId (worker): qualify sourceIds with enclosing class
  name so CALLS source attribution matches definition-phase node IDs
- buildExportedTypeMapFromGraph: use lookupExactAll + nodeId match instead
  of lookupExactFull which returns first definition for bare name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: methodExtractor variadic arity, return type preservation, PHP abstract dispatch

Three bugs in the methodExtractor enrichment path broke 17 integration tests:

1. Variadic parameterCount: buildMethodProps and parse-worker set
   parameterCount = info.parameters.length even for variadic functions,
   causing arity filtering to reject valid calls. Now checks isVariadic
   and sets parameterCount = undefined (matching extractMethodSignature).

2. C++ bare `...` token: extractCppParameters only iterated named
   children, missing the unnamed `...` token in C-style variadics like
   log_entry(const char* fmt, ...). Added fallback scan of all children.

3. Return type stripping: All 11 language extractReturnType functions
   used extractSimpleTypeName() which strips generic parameters
   (List<User> → "List", Task<User> → "Task"). Changed to .text?.trim()
   to preserve full generic types needed for for-loop iterable resolution,
   async-await binding, and return-type inference.

Also fixes PHP abstract dispatch test that matched SqlRepository instead
of the interface due to ambiguous filePath.includes('Repository') filter,
and adds parent-walk fallback in PHP isAbstract for extractFromNode path.

* chore: remove plan and review artifacts from PR

* fix: address Round 4 review findings + infrastructure improvements

- Ruby: add singleton_class support for class << self methods (4 new tests)
- PHP: add enum_declaration to CLASS_CONTAINER_TYPES
- Dart: add mixin/extension labels to CONTAINER_TYPE_TO_LABEL
- Swift: add TODO for unverifiable struct/enum node types on Node 22
- C#: add grammar version comment (0.23.1)
- Ruby: fix version comment range to pin (0.23.1)
- Rust/ast-helpers: add cross-reference comments for impl_item duplication
- ast-helpers: document CLASS_CONTAINER_TYPES ↔ typeDeclarationNodes invariant
- generic.ts: replace Array.includes with Set for O(1) dedup in addNestedBodies
- Go/Python/Ruby: align isAbstract signature with 2-param interface contract
- CLAUDE.md: fix malformed backtick around gitnexus:start HTML comment
- parsing-processor: add per-class method extraction cache (eliminates O(N*M))
- ast-helpers: add scoped_type_identifier to impl_item resolution
- call-processor: add dev-mode warnings at silent candidates[0] fallbacks
- MCP context(): surface methodMetadata for Method/Function/Constructor nodes
- resources.ts: update schema to list all stored Method properties

* fix: singleton_class HAS_METHOD edge regression in findEnclosingClassInfo

singleton_class (class << self) was added to CLASS_CONTAINER_TYPES but
has no name field — its receiver `self` has node type 'self', not
'identifier'. findEnclosingClassInfo now walks up to the enclosing
class/module to inherit its name, matching ruby.ts:extractOwnerName.

Also fixes findEnclosingClassNode in parse-worker.ts to skip
singleton_class and return the actual class/module node.

Adds integration test assertions for from_habitat (class << self method):
HAS_METHOD edge from Animal, isStatic=true, parameterCount=1.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:11:31 +01:00
Abhigyan Patwari 5c4fca21c3 Merge pull request #626 from ivkond/feat/intra-repo-service-tracking-clean
[group] Intra-repo service communication tracking
2026-04-03 17:04:24 +05:30
Nguyen Hai SonandClaude Opus 4.6 dd0f5eed7d feat(vue): Vue SFC support + destructured call result tracking (#604)
* feat(vue): add Vue SFC (.vue) support for indexing

Vue Single File Components are now fully supported in the indexing pipeline.
The implementation extracts <script> / <script setup> blocks from .vue files
and parses them using the existing TypeScript tree-sitter grammar — no new
npm dependencies required.

Key changes:
- SFC script extractor: regex-based extraction of <script setup lang="ts">
  blocks with correct line offset mapping back to the .vue file
- Vue language provider: reuses TypeScript queries, type config, field
  extractors, and named binding extraction
- Import resolution: .vue added to EXTENSIONS so `import Foo from './Foo'`
  resolves to Foo.vue; Vue import resolver delegates to TS resolver for
  tsconfig path alias support
- Export detection: <script setup> top-level bindings are implicitly exported
- Template component detection: PascalCase tags in <template> emit CALLS edges
- Line offsets applied to all emitted positions (startLine, endLine, route
  lineNumbers, decorator positions) in both worker and sequential paths

Validated on a 3,553-file Vue project:
  Before: 24,693 nodes | 73,614 edges | 0 symbols from .vue
  After:  30,495 nodes | 112,324 edges | 5,213 symbols from .vue
          18,682 imports from .vue | 5,826 vue-to-vue imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(typescript): track destructured call results in TypeEnv

Extend `extractPendingAssignment` to handle object destructuring from
function calls and await expressions:

  const { isMaker } = useUserRole()
  const { data } = await fetchData()
  const { name } = repo.getProfile()

Previously, only `const { x } = someVariable` (identifier RHS) produced
TypeEnv bindings. Call-expression RHS was silently skipped, leaving
destructured properties untracked.

The fix emits a synthetic `callResult` item plus N `fieldAccess` items
per destructured property, which the existing fixpoint resolver processes
in 2 iterations. No changes needed to type-env.ts, PendingAssignment
types, or call-processor — the existing infrastructure handles it.

Also extracts a `collectDestructuredFields` helper to share the
object_pattern property iteration logic between the identifier and
call-expression branches.

Note: Full property-type resolution requires the callee to have a
declared returnType in the SymbolTable. Arrow-function composables
without type annotations (common in Vue/React) won't resolve property
types until return-type inference is added in a future change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vue): address PR review issues for Vue SFC support

- Extract duplicated isVueSetupTopLevel to vue-sfc-extractor.ts shared
  utility, removing identical copies from parse-worker.ts and
  parsing-processor.ts
- Fix VUE_BUILT_INS to be a superset of TS BUILT_INS by importing and
  spreading the TypeScript set, preventing spurious unresolved calls for
  standard built-ins (Symbol, BigInt, WeakMap, array methods, etc.)
- Add Vue template component CALLS edge resolution in both sequential
  and worker paths (call-processor.ts), matching PascalCase template
  tags against imported .vue file basenames via the import map
- Add integration test for template PascalCase CALLS edges
  (App.vue → Button.vue)
- Add integration test for isExported: false on non-setup <script>
  blocks (OldStyle.vue options API)
- Add comment explaining TEMPLATE_RE greedy regex behavior for nested
  template tags
- Fix stale language count comment (14 → 15) and remove dead code
  branch in test

Made-with: Cursor

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:18:55 +05:30
Chirag Nighut e3d73a7aed Java method reference (#622) 2026-04-02 15:16:35 +01:00
ivkondandClaude Opus 4.6 255e3e79eb fix(group): address 4 HIGH-priority issues from PR #626 review
1. Path traversal via group name — add validateGroupName() with regex
   [a-zA-Z0-9][a-zA-Z0-9_-]*, called in getGroupDir (defense in depth)

2. gRPC proto regex can't handle nested braces — replace serviceRe with
   extractServiceBlocks() brace-depth counter (init depth=1, skip
   malformed protos)

3. Service boundary detector directory exclusions — add EXCLUDED_DIRS
   set (vendor, target, build, dist, __pycache__, .venv, venv, .tox,
   .mypy_cache, .gradle, .mvn, out, bin) replacing inline node_modules

4. Double-close of LadybugDB pools — remove blanket closeLbug() from
   cli/group.ts; sync.ts per-id cleanup is sufficient

Tests: 22 new tests across 5 files. Full suite: 4706 passed, 0 failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:33 +03:00
ivkondandClaude Opus 4.6 be4458b650 docs: add design spec and implementation plan for PR #626 HIGH fixes
Spec covers 4 HIGH-priority issues from review: path traversal via
group name, gRPC proto regex nested braces, service boundary detector
directory exclusions, double-close of LadybugDB pools.

Plan: 6 tasks with TDD, ordered by complexity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:18 +03:00
ivkondandClaude Opus 4.6 4fed097abb feat(group): add sync pipeline, CLI, MCP tools, and monorepo fixture
Wire extractors into the sync pipeline with service boundary detection.
GroupService provides high-level API for all group operations.

- Sync pipeline: orchestrates extraction (HTTP, gRPC, topics) with
  service boundary assignment and exact matching
- GroupService: groupList, groupSync, groupContracts, groupQuery,
  groupStatus (groupImpact deferred to cross-repo follow-up PR)
- CLI: group create/add/remove/list/sync/contracts/query/status
- MCP tools: group_list, group_sync, group_contracts, group_query,
  group_status
- Monorepo fixture: 3 services (auth/orders/gateway) connected via
  gRPC + Kafka + HTTP — all intra-repo cross-links discovered
- Documentation: CLI commands and MCP tools added to both READMEs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:31 +03:00
ivkondandClaude Opus 4.6 4fa395f4b6 feat(group): add service boundary detection and contract extractors
Service communication detection for microservice monorepos:

- ServiceBoundaryDetector: auto-detects service boundaries via markers
  (package.json, go.mod, Dockerfile, pom.xml, Cargo.toml, build.gradle,
  pyproject.toml, etc.)
- HttpRouteExtractor: graph-assisted (Strategy A) with source-scan
  fallback (Strategy B) for Spring, Express, Laravel, FastAPI providers
  and fetch/axios consumers
- GrpcExtractor: parses .proto files, detects Go/Java/Python/TS gRPC
  servers (RegisterXxxServer, @GrpcService, add_XxxServicer_to_server,
  @GrpcMethod) and clients (NewXxxClient, newBlockingStub, XxxStub)
- TopicExtractor: Kafka (@KafkaListener, producer.send), RabbitMQ
  (@RabbitListener, channel.publish/consume), NATS (nc.Subscribe/Publish)
  across Java, Node, Go, and Python

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:40:12 +03:00
ivkondandClaude Opus 4.6 52277247fe feat(group): add group infrastructure and contract matching
Core foundation for repository group analysis:
- Type system: ContractType, ExtractedContract, StoredContract, CrossLink
  with optional `service` field for intra-repo matching
- Config parser for group.yaml (repos, detection flags, matching thresholds)
- Contract registry storage with atomic writes
- Exact matching engine with per-type normalization (HTTP, gRPC, topic)
  and intra-repo support (different services within same repo can match)
- Extract LadybugDB pool-adapter from MCP backend for reuse by sync pipeline
- Git staleness checker for group status reporting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:39:43 +03:00
Gergő Magyar ba5de0bde4 feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#617)
* feat(cpp): C/C++ MethodExtractor config with pure virtual detection (#572)

- Pure virtual (= 0) detected as isAbstract via token scanning
- virtual/final/override via hasKeyword and virtual_specifier children
- Access specifier visibility via backward sibling walk (public:/private:/protected:)
- Pointer/reference parameter types extracted correctly
- Constructor and destructor support via declaration node type
- Static detection via storage_class_specifier
- 16 new tests covering all acceptance criteria

* fix(cpp): isVirtual infers from override/final + out-of-class resolution

- isVirtual returns true for override/final methods (C++ mandates these
  are virtual)
- Add findClassNodeByQualifiedName to parse-worker: resolves Foo::bar()
  back to the Foo class declaration for method extractor enrichment
- Handles pointer/ref return types, constructors, destructors
- Integration test for virtual/static/constructor inline methods
- 233 unit+integration tests pass, 97 C++ resolver tests pass

* fix(cpp): address review — deep pointers, templates, unions, trailing returns

- Fix extractParamName: recursive unwrap for int** ptr → "ptr" (not "**ptr")
- Fix findFunctionDeclarator: recursive unwrap for multi-level pointer chains
- Template methods: generic extractor unwraps template_declaration to inner node
- union_specifier: added to typeDeclarationNodes, visibility defaults to public
- Trailing return type: auto foo() -> T now extracts T instead of "auto"
- Fix version comment: ^0.22.4 → ^0.23.4 to match package.json
- 4 new tests: double pointer params, template methods, union methods, trailing returns

* fix(cpp): template method visibility + union isTypeDeclaration test

extractCppVisibility now walks from the template_declaration parent
when the node is wrapped by a template, restoring correct access-
specifier resolution for templated class methods.

Also adds missing isTypeDeclaration assertion for union_specifier and
expands the template method test with explicit visibility checks.

* fix(cpp): address deep gap analysis review findings

- findClassNodeByQualifiedName: recursive pointer/reference
  declarator unwrap, fixing out-of-class linking for deep pointer
  return types (e.g. int** Foo::bar())
- findClassNodeByQualifiedName: recurse into namespace_definition
  blocks so namespace-wrapped classes resolve correctly
- Suppress = delete / = default special members from extraction
  via delete_method_clause / default_method_clause node detection
- Update known-gaps: namespace-wrapped classes, const-overload collapse
- Add tree-sitter-c version comment for consistency
- toBeFalsy() → toBe(undefined) for precise isVirtual assertion
- Tests: = delete, = default, = 0 non-regression, operator overloads,
  deep pointer return types, default visibility (class vs struct),
  multiple access specifier sections
2026-04-01 18:07:11 +01:00
Abhigyan PatwariandClaude Opus 4.6 dc86ea96dc chore: release v1.5.3 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:35:24 +05:30
Abhigyan PatwariandClaude Opus 4.6 d1adc8331a chore: release v1.6.0 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:32:11 +05:30
80d363f145 fix(wiki): Azure OpenAI compat and HTML viewer script injection (#618)
* fix(wiki): Azure OpenAI compat and HTML viewer script injection

- Use max_completion_tokens instead of deprecated max_tokens for all models
- Skip sending temperature for Azure provider (some models reject non-default values)
- Simplify Azure interactive setup: endpoint + deployment + key (3 prompts instead of 7)
- Escape </script> in embedded JSON to prevent premature script tag closure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(test): align wiki-llm-client test with max_completion_tokens change

The test expected max_tokens for non-reasoning models, but the source
now uses max_completion_tokens for all models since max_tokens is
deprecated by newer OpenAI models.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:30:43 +05:30
Gergő Magyar 12be2025f1 feat(ts,js): TypeScript/JavaScript MethodExtractor config (#588)
* feat(ts,js): MethodExtractor config for TypeScript and JavaScript (#570)

Add per-language method extraction config following the established
JVM and C# patterns. Shared config base mirrors the field extractor's
typescript-javascript.ts pattern — TS-only node types are harmless
no-ops for JS.

Key features:
- isAbstract for abstract class methods and interface methods
- Parameter extraction with isOptional (?:, defaults) and isVariadic (...)
- Decorator extraction from preceding body-level siblings
- isAsync and isOverride detection
- Visibility via accessibility_modifier two-pass pattern
- Return type extraction unwrapping type_annotation

* test(ts,js): add override, getter/setter, destructured param tests

Address code review findings:
- Add override method detection test
- Add getter/setter extraction test
- Add destructured parameter with type annotation test
- Tighten constructor and private method assertions

* refactor(ts,js): address code review findings

- Replace O(M*N) decorator index scan with previousNamedSibling walk
- Remove dead findVisibility 'modifiers' fallback (TS uses
  accessibility_modifier, not a modifiers wrapper)
- Document call_signature/construct_signature as known gaps
- Document that TS constructors are method_definition nodes
- Remove unused findVisibility import

* fix(ts,js): type guard before cast, add generator/computed/overload tests

- Use type guard pattern (Set.has check before as-cast) in visibility
  extraction to ensure string is validated before narrowing
- Add generator method test (*items()) — confirms extraction works
- Add computed property name test ([Symbol.iterator]) — documents
  bracket-in-name behavior as intentional
- Add class-level method overload test — verifies overload signatures
  + implementation are all extracted

* fix(ts,js): detect #private methods as visibility 'private'

ES2022 private class methods (#name) use private_property_identifier
as their name node type. Detect this and return 'private' visibility
instead of the default 'public'.

* fix(ts,js): address review findings + close ingestion gaps

- hasKeyword/findVisibility: skip name field child to prevent false
  positives on soft-keyword method names (e.g. `abstract()`, `static()`)
- extractTsJsParameters: filter TS `this` parameter (compile-time only)
- extractMethodSignature: mirror `this`-param skip in fallback path
- tree-sitter queries: capture abstract_method_signature,
  method_signature, and private_property_identifier for TS; add
  private_property_identifier for JS
- Remove dead childForFieldName('name') fallbacks and typeFromAnnotation
  fallback
- Add 10+ unit tests, 4 integration tests through query pipeline

* test(ts): update HAS_METHOD count for interface method_signature capture

The new method_signature query now captures ILogger.log() as a Method
node with a HAS_METHOD edge, increasing the expected count from 4 to 5.

* fix(ts,js): address second review — async generator test, declare module gap

- Add async generator method test (async *values() → isAsync: true)
- Document declare module/global augmentation as known gap
2026-04-01 14:09:59 +01:00
Abhigyan PatwariandClaude Opus 4.6 dcf40da3a8 fix: ensure import rewrites survive npm publish lifecycle
npm runs `prepare` after `prepack` during publish, so the previous
`prepare: tsc` overwrote the rewritten imports before packing.
Both `prepare` and `prepack` now run the full build script so the
tarball always contains rewritten relative imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:28:14 +05:30
Abhigyan PatwariandClaude Opus 4.6 c617350c58 chore: release v1.5.1 — update CHANGELOG and package-lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:05:08 +05:30
71255a096c fix: bundle gitnexus-shared into CLI dist (#613)
* fix(ci): build gitnexus-shared before publish, use CHANGELOG for release notes

The publish workflow was missing the gitnexus-shared build step that
the setup-gitnexus composite action provides. Since PR #536 unified
the ingestion pipeline, gitnexus imports types from gitnexus-shared,
so it must be built first.

Also replaces generate_release_notes with CHANGELOG.md extraction so
GitHub Releases use the reviewed changelog entry instead of a flat
PR title list.

Made-with: Cursor

* fix: bundle gitnexus-shared into CLI dist to fix module resolution

gitnexus-shared was declared as a file: dependency but never published
to npm, causing ERR_MODULE_NOT_FOUND for users installing gitnexus
globally. The build script now copies gitnexus-shared/dist into
dist/_shared/ and rewrites bare specifiers to relative paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move gitnexus-shared to devDependencies, use tsc for prepare

gitnexus-shared must remain available for tsc to resolve imports during
development/CI, but is not needed at runtime since it's bundled into
dist/_shared/. Moving it to devDependencies keeps it out of production
installs while allowing compilation. The prepare script now runs plain
tsc (no shared bundling needed for local dev).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:04:39 +05:30
Gergo Magyar 39322e37df fix(ci): build gitnexus-shared before npm ci in publish workflow
The publish job failed because npm ci triggers the prepare script (tsc)
before gitnexus-shared types are available. Add the same build step
that the setup-gitnexus composite action uses in CI.
2026-04-01 07:35:46 +01:00
Abhigyan PatwariandAbhigyan Patwari 0e6f8f1720 chore: release v1.5.0 — update CHANGELOG and package-lock (#611)
Made-with: Cursor

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
2026-04-01 11:24:38 +05:30
Abhigyan Patwari 113dadf945 chore: bump version to 1.5.0
Made-with: Cursor
2026-04-01 10:34:46 +05:30
Abhigyan PatwariandAbhigyan Patwari af421cc9a3 feat(web): repo landing screen with selectable repo cards (#607)
* feat(web): add repo landing screen with selectable repo cards

Instead of auto-loading the first indexed repo when the backend is
detected, show a landing screen that lets users choose which repo
to explore or analyze a new one. This addresses the UX gap where
users with multiple indexed repos had no way to pick—they were
always sent to the first one found.

- New RepoLanding component with clickable repo cards (name, stats,
  indexed date) and an embedded RepoAnalyzer for new repos
- DropZone gains a 'landing' phase between server detection and
  graph loading
- Shared connectToRepo handler replaces the old handleAnalyzeComplete
  for both repo selection and post-analysis connection

Made-with: Cursor

* fix(web): update e2e flow for repo landing screen

The new landing screen intentionally stops auto-loading the first indexed
repo, so the existing Playwright tests were still waiting for the explorer
to appear automatically. Update the specs to select a repo from the landing
screen before asserting on the graph, and add a stable test id for repo cards.

Also format DropZone to satisfy the Prettier CI check.

Made-with: Cursor

* fix(e2e): use waitFor instead of instant isVisible for landing card

locator.isVisible() is a non-retrying instant check — the landing card
hadn't rendered yet when it was called, causing the click to be silently
skipped. Switch to waitFor which properly polls until the element appears.

Made-with: Cursor

---------

Co-authored-by: Abhigyan Patwari <abhigyan@Abhigyans-MacBook-Air.local>
2026-04-01 10:34:10 +05:30
Gergő Magyar c72890d59d feat(csharp): C# MethodExtractor config (#582)
* feat(csharp): add C# MethodExtractor config (#573)

Add C# method extraction config mirroring the JVM pattern from PR #576.
Wire csharpMethodConfig into the C# language provider and add 18 tests
covering classes, interfaces, abstract classes, structs, records,
constructors, params/out/ref/optional parameters, sealed methods,
attributes, and visibility modifiers.

* fix(csharp): add destructor, operator, conversion operator, and in-param support

- Add destructor_declaration, operator_declaration, and
  conversion_operator_declaration to methodNodeTypes
- Custom extractName for operators (e.g., "operator +", "implicit operator double")
- Fix extractReturnType for operator declarations (use type field, not returns)
- Add in modifier to parameter extraction (alongside out/ref)
- Add 4 new tests: destructor, operator+, implicit conversion, in parameter

* fix(csharp): add ref param test and document compound visibility limitation

- Add test for ref parameter modifier (was only testing out)
- Document that protected internal / private protected resolve to first modifier

* feat(csharp): support compound visibilities (protected internal, private protected)

- Add 'protected internal' and 'private protected' to FieldVisibility union
- Detect compound modifiers in both C# method and field extractors via
  collectModifierTexts helper scanning adjacent modifier nodes
- Add 2 tests for compound visibility detection

* feat(csharp): primary constructors, virtual/override/async, primary fields

Address all known limitations from review:

- Primary constructor support (C# 12): add extractPrimaryConstructor to
  MethodExtractionConfig and extractPrimaryFields to FieldExtractionConfig.
  Record params become public readonly properties; class params become
  private captured fields.
- Add isVirtual, isOverride, isAsync optional fields to MethodInfo,
  MethodExtractionConfig, NodeProperties, and parse-worker propagation.
- Detect virtual/override/async modifiers in C# method config.
- Move collectModifierTexts to shared helpers.ts (deduplicate).
- Fix destructor name to ~ClassName (disambiguates from constructor).
- Add expression-bodied method test.
- 118 tests total across method + field extraction suites, all passing.

* fix(csharp): review round 2 — annotations, record_struct, grammar pin

- Fix primary constructor annotations: use [] instead of extracting
  class-level attributes (C# has no syntax for ctor-specific attributes)
- Add record_struct_declaration to typeDeclarationNodes in both method
  and field extractors, CLASS_CONTAINER_TYPES, and isRecord visibility check
- Pin tree-sitter-c-sharp version (^0.23.1) in params comment

* fix(csharp): complete record_struct query + label mapping, sealed override test

- Add record_struct_declaration capture patterns to tree-sitter-queries.ts
  (type definition + primary constructor)
- Add record_struct_declaration → 'Struct' in CONTAINER_TYPE_TO_LABEL
- Assert isOverride: true alongside isFinal in sealed override test

* fix(csharp): record_struct label mismatch, add record struct + documented limitation tests

- Fix record_struct_declaration query tag: @definition.struct (not @definition.record)
  to match CONTAINER_TYPE_TO_LABEL and prevent broken HAS_METHOD edges
- Add 3 record struct tests: isTypeDeclaration, method extraction, primary constructor
- Add documented limitation tests: partial method (isAbstract: false), generic type
  parameter stripping (name excludes <T>)

* fix(csharp): remove record_struct_declaration — not a real tree-sitter node type

tree-sitter-c-sharp 0.23.1 parses 'record struct' as record_declaration
(absorbs the 'struct' keyword as an unnamed child token). The non-existent
record_struct_declaration in queries caused TSQueryErrorNodeType, breaking
ALL C# file processing.

Remove from: tree-sitter-queries.ts, typeDeclarationNodes in both
extractors, CLASS_CONTAINER_TYPES, and CONTAINER_TYPE_TO_LABEL.
Record struct types are already handled via record_declaration.

* feat(csharp): add isPartial support, filter targeted attributes, static ctor test

- Add isPartial optional field to MethodInfo, MethodExtractionConfig,
  NodeProperties, and parse-worker propagation pipeline
- Detect partial modifier in C# config — marks both declaration-only
  and implemented partial methods
- Filter targeted attribute lists (e.g. [return: MarshalAs(...)]) in
  extractCSharpAnnotations — only untargeted attributes collected
- Add static constructor test (isStatic: true, same name as class)
- Add 3 partial method tests: declaration-only, with body, coexisting pair
- Document record_struct/record_class as defensive dead code in
  export-detection.ts (grammar absorbs keywords into record_declaration)

* fix(csharp): this param for extension methods, dedup visibility, test fixes

- Handle this modifier on extension method parameters (type prefixed
  as 'this string', consistent with out/ref/in handling)
- Deduplicate visibility logic in extractPrimaryConstructor — reuse
  csharpMethodConfig.extractVisibility instead of inline compound check
- Fix record struct test title to reflect actual grammar behavior
- Add conversion operator returnType assertion
- Add extension method this parameter test

* fix(csharp): primary constructor line points to param list, empty name guard

- Use paramList.startPosition instead of ownerNode.startPosition for
  primary constructor line number (avoids methodInfoCache key collision)
- Guard against empty param names from tree-sitter error recovery nodes
2026-03-30 08:41:17 +01:00
agung widhiatmojoandClaude Opus 4.6 be210c7c61 docs: add gitnexus-shared build step before gitnexus-web (#585)
gitnexus-web imports from gitnexus-shared, which requires npm run build
to generate dist/. Without this step, npm run dev fails with module
resolution errors.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 08:40:47 +01:00
Subham Kundu 8d7aa94381 chore: add enterprise offering section to README, ignore local_docs/ (#579)
* chore: add enterprise offering section to README, ignore local_docs/

* chore: normalize em dash to hyphen in README enterprise section

* docs: expand enterprise section with commercial licensing and feature details
2026-03-29 14:55:17 +05:30
Subham Kundu d90d1ba96f fix(eval): exclude litellm 1.82.7 and 1.82.8 due to compatibility issues (#580) 2026-03-29 05:23:37 +01:00
Gergő Magyar 313b13fade feat(java,kotlin): MethodExtractor abstraction with per-language configs (#576) 2026-03-28 21:31:08 +00:00
Gabriel J Campbell b03413dcf9 feat: added skip-agents-md cli flag (#517)
* feat: added skip-agents-md cli flag

* fix: apply prettier formatting

* feat: added skip-agents-md cli flag

* fix: apply prettier formatting

* feat: add skipAgentsMd option to skip AGENTS.md and CLAUDE.md updates

* fixed bad merge
2026-03-28 21:23:59 +00:00
Abhigyan PatwariandClaude Opus 4.6 9f69c43100 feat(wiki): Azure OpenAI support for wiki command (#562)
* feat(wiki): extend LLMConfig/CLIConfig with Azure and reasoning model fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): restore cursor model resolution, fix LLMProvider type, clean up regex

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(wiki): remove stale LLMProvider type alias from repo-manager

* fix(wiki): fix Azure auth header, api-version param, reasoning model params, content_filter error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): tighten Azure detection, reasoning model regex, content_filter gating

- isReasoningModel: new regex matches only o1/o3 bare + any oN-mini/oN-preview; bare o4/o5/etc now return false
- isAzureProvider: use URL hostname matching to block spoofed subdomain URLs
- callLLM: warn on Azure legacy /deployments/ URL without api-version
- callLLM: gate content_filter error to azure===true; also catch ResponsibleAIPolicyViolation
- tests: add afterEach stub cleanup, spoofed-URL, bare-o4, non-Azure content_filter, and URL-only Azure auto-detect tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): detect content_filter finish_reason in SSE stream and throw clear error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): skip delta accumulation after content_filter, use provider-neutral error message

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wiki): add Azure OpenAI option to interactive setup wizard

Inserts Azure as option [3] in the provider menu (shifting Custom to [4]
and Cursor to [5]), adds guided Azure setup flow with resource/deployment
prompts, v1/legacy URL format selection, reasoning-model flag, and
content_filter error handling in the catch block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): store explicit false for non-reasoning Azure deployments, trim resource name inputs

- isReasoningModelDeployment now stores false (not undefined) when user says no
- Always include isReasoningModel in saved azureConfig (no conditional guard needed)
- Trim resourceName and deploymentName prompt inputs to avoid whitespace issues
- Improve reasoning model note to mention Azure requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(wiki): add --api-version and --reasoning-model CLI flags for Azure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(wiki): include apiVersion and reasoningModel in hasCLIOverrides guard

* fix(wiki): default provider to 'openai' in resolveLLMConfig when not configured

* style: apply prettier formatting

* fix(wiki): address PR review — remove unrelated files, harden inputs

- Remove evidence/, fix-adapter.js, and planning doc accidentally included
- URL-encode apiVersion in buildRequestUrl to prevent query string injection
- Add --no-reasoning-model flag to allow CLI override of saved config
- Simplify verbose ternary in Azure wizard prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(wiki): use execFileSync for EDITOR to prevent shell injection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:23:41 +05:30
Gergő Magyar ddb6a704b3 refactor: reduce explicit any types (#566)
* refactor: replace NodeProperties index signature any with unknown

Change [key: string]: any to [key: string]: unknown in NodeProperties.
Remove 19 redundant (node.properties as any) casts in csv-generator.ts
— all accessed properties are already declared on the type.

* refactor: replace any with SyntaxNode across ingestion layer

Mechanical substitution — all tree-sitter AST node parameters and
variables typed as any are now properly typed as SyntaxNode.

- ast-helpers.ts: 13 any → SyntaxNode
- parsing-processor.ts: 8 any → SyntaxNode
- parse-worker.ts: 40 any → SyntaxNode/TreeSitterLanguage/Parser.Query
- php.ts: 11 any → SyntaxNode

Also adds TreeSitterLanguage type alias for optional grammar loading.

* refactor: eliminate remaining any in ingestion layer

- call-processor, call-routing, c-cpp: SyntaxNode substitutions
- parse-worker: typed WorkerIncomingMessage discriminated union
- worker-pool: typed WorkerOutgoingMessage + Error handler
- ast-cache, import-processor: targeted cast for Tree.delete()
- community-processor: graphology AbstractGraph types, LeidenModule
  interface for vendored leiden code

Ingestion layer: 130 → 7 any warnings remaining.
2026-03-28 17:11:24 +00:00
Chirag Nighut e2de9271fc feat(java): method references, worker overload disambiguation, interface dispatch (#540)
* feat(java): method references + worker overload disambiguation (TypeEnv + argTypes)

Fix two Java gaps: (1) method references (obj::method) via
  tree-sitter @call + parseJavaMethodReference wired through extractLanguageCallSiteSeed for parse-worker and call-processor; (2) overloaded calls with typed
  non-literal args by extending OverloadHints with TypeEnv for identifiers and adding ExtractedCall.argTypes from extractCallArgTypes on the worker path with
  matchCandidatesByArgTypes (inferJvmLiteralType remains for literals).

* test(csharp): expect interface-dispatch edge for IRepository.Save in heritage fixture

* refactor(ingestion): move parseJavaMethodReference to call-sites/java.ts

* refactor(ingestion): defer worker call resolution until implementor map is complete

* style: prettier + remove unused import for CI quality checks

Made-with: Cursor

* fix(ingestion): implementor map for C# base_list + sequential pipeline path

- buildImplementorMap: treat extends rows as implements when resolveExtendsType
  says IMPLEMENTS (worker heritage mirrors parse-worker, all base_list as extends)
- Worker path: pass ctx into buildImplementorMap(deferredWorkerHeritage, ctx)
- Sequential path: extract heritage before processCalls and pass implementor map
  so small repos get interface-dispatch CALLS (fixes csharp-proj integration test)

Made-with: Cursor

* perf(pipeline): accumulate sequential implementor map without O(E) per chunk

- Merge buildImplementorMap(chunk heritage) into one map each sequential chunk so
  work is O(heritage) per chunk and interface dispatch sees prior chunks (worker parity)
- Drop unused globalImplementorMap + redundant merge after worker pass

Made-with: Cursor
2026-03-28 16:59:28 +00:00
Gergő Magyar acf6fbdd39 feat: configure eslint with unused import removal (#564)
* feat: configure eslint with unused import removal

Add ESLint v9 (flat config) for code quality:
- eslint-plugin-unused-imports for auto-removing dead imports
- @typescript-eslint for TypeScript-aware linting
- eslint-plugin-react-hooks for React hooks rules
- eslint-config-prettier to avoid formatting conflicts
- lint-staged runs eslint --fix before prettier on .ts/.tsx
- CI lint job added to ci-quality.yml

* refactor: remove unused imports via eslint --fix

Auto-fixed by eslint-plugin-unused-imports. No logic changes.

* chore: add eslint fix commit to .git-blame-ignore-revs
2026-03-28 15:28:09 +00:00
Gergő Magyar bf09eab95b feat: configure prettier with pre-commit hook (#563)
* feat: configure prettier with pre-commit hook integration

Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo
root with husky pre-commit hook integration. Moves husky from
gitnexus/ to root package.json for reliable hook installation.

- Root package.json with prepare/format/format:check scripts
- .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4
- .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md
- .gitattributes enforcing LF line endings for Windows consistency
- Pre-commit hook uses direct node_modules/.bin/ paths (no npx)

* style: apply prettier formatting to entire codebase

One-time bulk format. No logic changes.
Use .git-blame-ignore-revs to skip this commit in git blame.

* chore: add .git-blame-ignore-revs for prettier format commit

* perf: pre-commit hook runs only tests related to staged files

Use vitest --related to scope test execution to tests that import
the changed files, instead of running the full suite on every commit.

* perf: remove vitest from pre-commit hook, keep in CI only

Pre-commit now runs lint-staged + tsc only. Tests run in CI
(ci-tests.yml) where they belong — keeps commits fast.

* ci: add prettier format check to quality workflow

PRs will now fail if code isn't formatted with prettier.
2026-03-28 14:58:04 +00:00
Gergő Magyar fd7fb5bf1f feat: unify web and cli ingestion pipeline (#536)
* feat: add server-side ingestion API (POST /api/analyze, SSE progress)

Extract core analysis orchestration from CLI into shared run-analyze.ts
module. Add server-side analyze endpoints so the web app can trigger
ingestion via HTTP instead of running the full pipeline in-browser.

New files:
- src/core/run-analyze.ts — shared runFullAnalysis() orchestrator
- src/server/analyze-job.ts — job manager (single-slot, dedup, SSE events)
- src/server/analyze-worker.ts — forked child process (8GB heap, IPC)
- src/server/git-clone.ts — shallow clone/pull with SSRF protection

API endpoints:
- POST /api/analyze — start analysis (returns 202 + jobId)
- GET /api/analyze/:jobId — poll job status
- GET /api/analyze/:jobId/progress — SSE progress stream

Security: URL validation blocks private IPs and non-HTTP schemes.
Path validation requires absolute paths. Git stderr not leaked to API.

* feat(web): add server-side analyze UI (Phase 2)

Add "Analyze on Server" flow to the web app's Server tab so users
can trigger server-side ingestion from the browser. On completion,
the graph is automatically loaded via the existing connectToServer flow.

New files:
- AnalyzeProgress.tsx — progress bar with phase label, elapsed time, cancel

Modified files:
- backend.ts — startAnalyze(), streamAnalyzeProgress() SSE client
- DropZone.tsx — analyze URL input + button below Connect section
- App.tsx — onServerAnalyze handler wires analyze -> connect flow

* feat: add job cancellation, timeout, and child process tracking (Phase 3)

- DELETE /api/analyze/:jobId — cancel running analysis (SIGTERM to worker)
- 30-minute timeout kills long-running workers automatically
- Child process refs tracked in JobManager for cleanup on shutdown
- dispose() kills all active children on SIGINT/SIGTERM
- Web cancel button now calls server DELETE endpoint
- cancelAnalyze() added to web backend client

* refactor(web): remove browser ingestion pipeline (Phase 4)

Delete 16 duplicated ingestion files, 2 unused service files
(git-clone, zip), and tree-sitter parser-loader from gitnexus-web.
All ingestion now runs server-side via POST /api/analyze.

Deleted (18 files, ~5,000 lines):
- core/ingestion/*.ts (16 pipeline processors)
- core/tree-sitter/parser-loader.ts (WASM tree-sitter loader)
- services/git-clone.ts (isomorphic-git client-side clone)
- services/zip.ts (JSZip extraction)

Simplified:
- DropZone.tsx — server-only (removed ZIP/GitHub tabs)
- ingestion.worker.ts — removed runPipeline/runPipelineFromFiles
- useAppState.tsx — removed pipeline callbacks
- App.tsx — removed handleFileSelect/handleGitClone
- main.tsx — removed Buffer polyfill for isomorphic-git
- types/pipeline.ts — removed PipelineResult/serialize helpers

Kept: cluster-enricher.ts (LLM enrichment, still used by worker)

Dependencies now removable: web-tree-sitter, isomorphic-git,
@isomorphic-git/lightning-fs, jszip (estimated 3-4MB bundle savings)

* refactor(web): sync graph schema from CLI + delete WASM grammars

Sync graph/types.ts and lbug/schema.ts from the CLI (source of truth)
to the web module so the browser LadybugDB can handle all node and
relationship types the server pipeline produces.

Synced types: Route, Tool, Section node labels; HANDLES_ROUTE, FETCHES,
HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES relationship types;
description fields on Function/Class/Interface/Method/CodeElement.

Deleted: public/wasm/ directory (14 tree-sitter WASM grammars + core).
Removed deps: web-tree-sitter, isomorphic-git, @isomorphic-git/lightning-fs,
jszip, buffer, @types/jszip (~3-4MB bundle savings).

* feat: create gitnexus-shared package for unified type definitions

Create a new gitnexus-shared package that is the single source of truth
for types shared between the CLI and web modules:

- SupportedLanguages enum (15 languages)
- Graph types: NodeLabel, NodeProperties, RelationshipType, GraphNode, GraphRelationship
- Schema constants: NODE_TABLES, REL_TYPES, REL_TABLE_NAME, EMBEDDING_TABLE_NAME
- Pipeline types: PipelinePhase, PipelineProgress

Both gitnexus (CLI) and gitnexus-web import from gitnexus-shared via
file: dependency. Each package re-exports and extends with platform-specific
additions (CLI: KnowledgeGraph with mutation methods; Web: simpler KnowledgeGraph).

This ensures types can never drift between packages — adding a new
language, node type, or relationship type in gitnexus-shared automatically
propagates to both consumers.

* refactor: import shared types directly from gitnexus-shared at call sites

Replace all re-export patterns with direct imports from gitnexus-shared.
72 files updated across CLI and web:

- SupportedLanguages: 49 CLI files now import from 'gitnexus-shared'
  instead of '../config/supported-languages.js'
- GraphNode, GraphRelationship, NodeLabel: 22 CLI + 10 web files now
  import from 'gitnexus-shared' instead of local re-export wrappers
- NODE_TABLES: api.ts imports from 'gitnexus-shared'
- PipelineProgress: useAppState.tsx imports from 'gitnexus-shared'

Local types.ts files now only define platform-specific KnowledgeGraph
(CLI has mutation methods, web has add-only). No more re-exports.

* fix: update lock files for gitnexus-shared, remove stale vite polyfills

Add gitnexus-shared@1.0.0 to lock files so npm ci succeeds in CI.
Remove buffer polyfill and global define from vite.config.ts (isomorphic-git was removed).

* fix(security): add write guard to HTTP /api/query, fix CORS proxy bypass

- Add isWriteQuery() check to POST /api/query handler — blocks CREATE,
  DELETE, SET, MERGE, DROP, etc. via HTTP API (guard was only in MCP
  pool adapter and browser-side, not the HTTP server path)
- Extend CYPHER_WRITE_RE with CALL, INSTALL, LOAD keywords
- Fix CORS proxy subdomain bypass: endsWith('github.com') allowed
  'evil-github.com'. Now requires exact match or '.github.com' suffix

* feat(server): enhance /api/search with enrichment, add /api/grep, strip graph content

- POST /api/search: add mode param (hybrid|semantic|bm25), server-side
  enrichment returns connections/cluster/processes per result in one call
  (collapses 31 sequential HTTP calls to 1 for the agent search tool)
- GET /api/grep: regex search across indexed file contents, eliminates
  need to transfer all file contents to browser
- GET /api/graph: strip content field by default (80-95% payload
  reduction). Use ?includeContent=true for backward compat
- Add LRU cache invalidation hook point for future caching

* feat(server): add /api/embed endpoint for server-side embedding generation

- POST /api/embed: triggers embedding pipeline via onnxruntime-node
  with JobManager for single-slot concurrency, timeout, and dedup
- GET /api/embed/:jobId: poll job status
- GET /api/embed/:jobId/progress: SSE stream with heartbeat, event IDs,
  and X-Accel-Buffering:no header for proxy compatibility
- DELETE /api/embed/:jobId: cancel running embedding job
- Maps embedding pipeline phases (ready→complete, error→failed) to
  JobManager status conventions

* feat(web): create consolidated BackendClient module

Single HTTP client replacing backend.ts, server-connection.ts, and
worker HTTP helpers. Includes:
- Typed methods: runQuery, search (enriched), grep, readFile, connect
- Generic streamSSE<T> utility extracted from analyze progress pattern
- BackendError with discriminated code field (network/server/client/timeout)
- Embed API: startEmbeddings, streamEmbeddingProgress, cancelEmbeddings
- Search with mode param (hybrid|semantic|bm25) and enrichment

* refactor(web): rewrite Graph RAG tools for backend-only HTTP queries

- Search tool: uses enriched /api/search (1 call replaces 31 sequential queries)
- Cypher tool: removes browser-side embedding; {{QUERY_VECTOR}} routes to
  /api/search with mode:'semantic' instead of local transformers.js
- Grep tool: uses /api/grep instead of in-memory fileContents map
- Read tool: uses /api/file instead of fileContents map lookup
- Impact tool: getCallSiteSnippet now async via /api/file
- createGraphRAGTools now accepts GraphRAGBackend interface instead of
  7 separate function params + fileContents map
- createGraphRAGAgent simplified to (config, backend, context?)
- Removed imports: embedder, lbug/schema (replaced with gitnexus-shared)
- Net: -205 lines

* refactor(web): delete WASM infrastructure, remove 7 packages (-5242 lines)

Delete browser-side LadybugDB, embeddings, search, and worker:
- gitnexus-web/src/core/lbug/ (adapter, csv-generator, schema, query-result)
- gitnexus-web/src/core/embeddings/ (embedder, pipeline, text-gen, types)
- gitnexus-web/src/core/search/ (bm25-index, hybrid-search)
- gitnexus-web/src/workers/ingestion.worker.ts (828 lines)
- gitnexus-web/src/services/server-connection.ts (merged into backend-client)
- gitnexus-web/src/types/lbug-wasm.d.ts

Remove packages: @ladybugdb/wasm-core, @huggingface/transformers,
comlink, minisearch, vite-plugin-wasm, vite-plugin-top-level-await,
vite-plugin-static-copy

Update vite.config.ts: remove WASM plugins, COOP/COEP headers,
worker config, optimizeDeps exclude

Update imports: App.tsx, DropZone, Header, AnalyzeProgress,
BackendRepoSelector, useBackend → backend-client

* refactor(web): replace Worker/Comlink with direct BackendClient calls

- useAppState: remove Worker instantiation, Comlink.wrap, apiRef.
  All queries now go through BackendClient HTTP functions directly.
- Agent runs on main thread (I/O-bound LLM streaming, not CPU-bound)
- initializeAgent: creates GraphRAGAgent with GraphRAGBackend interface
  bound to BackendClient methods (runQuery, search, grep, readFile)
- startEmbeddings: calls POST /api/embed + SSE progress instead of
  running browser-side transformers.js pipeline
- switchRepo: no longer loads graph into WASM DB or extracts fileContents
- App.tsx: handleServerConnect simplified (no fileContents, no loadServerGraph)
- Delete old backend.ts (replaced by backend-client.ts)
- Net: -396 lines

* fix(web): fix await-in-map build error in agent streaming

Move dynamic import of AIMessage outside .map() callback to avoid
"await can only be used inside an async function" build error.

* fix(web): remove stale apiRef references that broke chat functionality

sendChatMessage referenced apiRef.current (deleted Worker ref) which
would throw TypeError. Replaced with agentRef.current guard since agent
now runs on main thread.

* fix(server): dispose embedJobManager on shutdown, fix job mutation

- Add embedJobManager.dispose() to shutdown handler (was missing,
  causing cleanup timer to keep Node process alive)
- Replace direct job.repoName/status mutation with updateJob() to
  ensure SSE event emission for initial status change

* fix(server): parameterize Cypher, harden grep, unify SSE endpoints

- Search enrichment: replace string interpolation with executePrepared()
  using $nid parameter binding to prevent Cypher injection
- Add executePrepared() to core lbug-adapter (prepare/execute pattern)
- /api/grep: add 200-char pattern length limit (ReDoS protection),
  search files on disk instead of loading entire corpus into memory
  (constant memory usage regardless of repo size)
- Extract mountSSEProgress() shared helper for SSE streaming — both
  analyze and embed endpoints now have consistent heartbeat (30s),
  event IDs (reconnection support), and X-Accel-Buffering header

* refactor(web): remove dead code from Worker-era architecture

- Remove loadServerGraph no-op function, interface member, and all consumers
- Remove testArrayParams stub and interface member
- Remove fileContents state from GraphStateProvider (never populated in
  server-side architecture)
- Remove forceDevice parameter from startEmbeddings (server-side, no device choice)
- Replace phantom EmbeddingProgress type with inline { phase, percent }
- Replace resolvePathFromContents (needed fileContents Map) with graph-based
  file path resolution using filePathIndex built from graph nodes
- Fix: AI citation grounding ([[file.ts:10]]) now works via graph node lookup
  instead of broken fileContents-based resolution

* fix(web): use streamAgentResponse for full tool_call/reasoning streaming

Replace naive agent.stream() loop that only handled content chunks with
streamAgentResponse() generator from agent.ts. This properly routes:
- reasoning tokens (before/between tool calls)
- tool_call events (name, args, status)
- tool_result events (completed tool output)
- content tokens (final answer after all tools done)

Previously the onChunk handler for tool_call/tool_result/reasoning was
dead code since the streaming loop only emitted content events.

* fix(web): resolve CI type errors from dead code removal

- Import GraphNode/GraphRelationship from gitnexus-shared in graph.ts
  (not re-exported from local types.ts)
- Add Route, Tool entries to NODE_COLORS and NODE_SIZES constants
- Add PipelineResult type to web types/pipeline.ts
- Remove fileContents from CodeReferencesPanel and RightPanel
- Remove testArrayParams and forceDevice from EmbeddingStatus
- Remove forceDevice args from startEmbeddings() calls in App.tsx
- Fix embeddingProgress property accesses for simplified type

* fix(ci): add setup-gitnexus-web action, build shared once per job

- Remove prepare script from gitnexus-shared (tsc not available during
  npm ci of consuming packages)
- Create .github/actions/setup-gitnexus-web composite action: builds
  gitnexus-shared then runs npm ci for gitnexus-web
- setup-gitnexus action: already builds gitnexus-shared for CLI jobs
- ci-quality typecheck-web: uses setup-gitnexus-web (DRY)
- ci-e2e: uses setup-gitnexus-web (DRY)
- ci-tests: gitnexus-shared already built by setup-gitnexus, just
  install web deps without rebuilding

* fix(ci): use prepare script so gitnexus-shared builds during npm ci

Move typescript from devDependencies to dependencies in gitnexus-shared
so the prepare script (tsc) works when npm resolves file: deps during
npm ci. No GHA modifications needed — npm handles the build lifecycle
automatically.

Remove manual gitnexus-shared build steps from setup-gitnexus and
setup-gitnexus-web actions.

* fix(ci): build gitnexus-shared explicitly in setup actions

The file: dependency protocol doesn't reliably run prepare scripts
because devDependencies aren't installed first. Instead of fragile
lifecycle hacks, build gitnexus-shared explicitly in both setup actions:
- setup-gitnexus: npm install && npm run build in gitnexus-shared/
- setup-gitnexus-web: same, before npm ci in gitnexus-web/
- ci-tests: shared already built by setup-gitnexus, web just npm ci

No prepare script, no dist in git, no typescript as a prod dependency.

* fix: remove CALL from CYPHER_WRITE_RE — breaks FTS and vector search

CALL is used by read-only procedures: CALL QUERY_FTS_INDEX(...) and
CALL QUERY_VECTOR_INDEX(...). Adding it to the write guard blocked all
FTS search, causing 3 test failures. The database is opened in read-only
mode as defense-in-depth against write procedures via CALL.

Keep INSTALL and LOAD in the blocklist (genuinely dangerous).

* fix(web): update vercel.json for gitnexus-shared, remove COOP/COEP

- Add installCommand that builds gitnexus-shared before installing
  web deps (Vercel doesn't know about the monorepo file: dependency)
- Remove Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy
  headers (no longer needed — WASM LadybugDB removed)

* fix(web): update tests for deleted modules

- Delete csv-generator.test.ts (tests deleted WASM-only csv-generator)
- Update security-guards.test.ts: import NODE_TABLES/REL_TYPES from
  gitnexus-shared instead of deleted src/core/lbug/schema
- Update server-connection.test.ts: import normalizeServerUrl from
  backend-client, remove extractFileContents tests (function deleted)

* fix(e2e): remove Server tab click — UI is now server-only

The DropZone no longer has ZIP/GitHub/Server tabs (browser ingestion
was removed). The server URL input is directly visible on the landing
page. Update e2e test to skip the tab click and go straight to input.

All 5 e2e tests pass locally.

* refactor: use gitnexus-shared for PipelinePhase/PipelineProgress types

CLI was duplicating PipelinePhase and PipelineProgress locally instead
of importing from gitnexus-shared. Updated all consumers to import
directly. Also removed dead code: SerializablePipelineResult,
serializePipelineResult(), deserializePipelineResult().

* fix(server): address PR #536 review — security, race conditions, dead code

- Fix path traversal in POST /api/analyze: split into isAbsolute + normalize check
- Add shared repo lock (activeRepoPaths) preventing concurrent analyze+embed on same repo
- Fix 202 response returning actual job.status instead of hardcoded 'queued'
- Add 30-minute timeout for embedding jobs (was missing unlike analyze jobs)
- Fix DropZone calling startAnalyze without setting backend URL first
- Add SSE reconnect with exponential backoff (3 retries) and Last-Event-ID
- Fix normalizeServerUrl to return base URL (no /api suffix) — clear contract
- Delete dead code: proxy.ts, server-graph-hydration.ts, pipeline.ts re-export barrel
- Update LoadingOverlay to import PipelineProgress directly from gitnexus-shared

* fix(server): fix repo lock key mismatch and embed cancel race

- Use getStoragePath(targetPath) as lock key in analyze handler to match
  embed handler's entry.storagePath — keys now always align
- Guard embed completion: don't overwrite 'failed' with 'complete' when
  job was cancelled while pipeline was still running
- Remove unused jobType parameter from acquireRepoLock
- Log backend.init() errors instead of silently swallowing

* fix: add gitnexus-shared as a local dependency in package-lock.json

* refactor: move language detection to gitnexus-shared, add syntax highlighting for all 15 languages

Move getLanguageFromFilename() from CLI to gitnexus-shared with COBOL
support added. Add getSyntaxLanguageFromFilename() for Prism-compatible
syntax highlighting covering all 15 code languages plus auxiliary
formats (json, yaml, markdown, html, css, bash, sql, xml).

Refactor CodeReferencesPanel to use shared function instead of a local
30-line switch. Delete dead gitnexus-web/src/config/supported-languages.ts
(web already imports SupportedLanguages from gitnexus-shared).

* feat(web): add first-time user onboarding with auto server detection

Replace the manual "Connect to Server" panel with an automatic onboarding
flow that guides first-time users through starting the GitNexus server.

Server detection:
- useBackend hook polls via setTimeout chain (3s, no overlap)
- Page Visibility API pauses polling when tab is hidden
- SSE heartbeat (/api/heartbeat) for instant disconnect detection

Onboarding UI (OnboardingGuide.tsx):
- Step-by-step flow: copy command → run → auto-connect
- Smart command: shows `gitnexus serve` in dev, `npx gitnexus@latest serve` in prod
- Node.js version auto-detected from package.json via Vite define
- Faux terminal windows with copy-to-clipboard, platform tabs, polling indicator

Transitions (DropZone.tsx):
- Crossfade wrapper with snapshot pattern for smooth phase transitions
- Three phases: onboarding → success (1.2s hold) → loading → graph
- Auto-recovery: falls back to onboarding if server dies or connect fails

Server changes:
- GET /api/heartbeat: SSE endpoint for liveness detection
- GET /api/info: version, launch context, Node.js version
- npm run serve script for local development
- app.disable('x-powered-by') hardening

* feat(web): add repo analysis UI, SSE heartbeat, and review fixes

Repo analysis:
- AnalyzeOnboarding: empty-state card when server has zero repos
- RepoAnalyzer: GitHub URL + Local Folder tabs with browse button
- Header repo dropdown: click project badge to switch repos or analyze new
- DropZone 'analyze' phase integrated into Crossfade transitions

Reliability fixes from 5-agent review:
- Polling: stop scheduling timers when tab hidden, restart on visibility return
- Heartbeat: exponential backoff (1s/2s/4s, 3 retries) prevents graph loss on blip
- RepoAnalyzer: completion timer tracked in ref, cleaned up on unmount
- DropZone: standardized card padding (p-7), heading sizes (text-lg)

Accessibility:
- prefers-reduced-motion global CSS rule (WCAG 2.3.3)
- focus-visible rings on CopyButton
- cursor-pointer on all Header buttons
- Consistent rounded-xl on all dropdowns

Cleanup:
- Deleted dead AnalyzeSheet.tsx (219 LOC) and BackendRepoSelector.tsx (89 LOC)
- Fixed AnalyzeProgress lucide import (lucide-react → @/lib/lucide-icons)

* fix(server): resolve analyze worker fork crash in dev mode

The forked analyze worker was crashing immediately with exit code 1
when running via `npm run serve` (tsx). Two issues:

1. Worker path resolved to `analyze-worker.js` but only `.ts` exists
   in the source directory — the `.js` file is only in `dist/`.

2. On Windows, bare `--import tsx` in execArgv fails because Node's
   ESM resolver for --import uses the child's CWD, not the parent's
   node_modules. Windows also rejects raw paths as `d:` is not a
   valid URL scheme.

Fix: detect dev vs prod via `import.meta.url` extension. In dev mode,
resolve `tsx/esm` to an absolute `file://` URL via `pathToFileURL()`
anchored to the parent's `createRequire` context. This works on all
platforms and doesn't depend on the child's CWD or PATH.

Also captures child stderr for better crash diagnostics.

Verified: `POST /api/analyze` with GitHub URL completes successfully
in dev mode (tsx) — status goes from cloning → analyzing → complete.

* fix(server): add worker auto-retry, error handling, and crash diagnostics

Worker resilience:
- Auto-retry up to 2 times with exponential backoff (1s, 2s) on crash
- SSE progress shows "Retrying after crash (1/2)..." during retry
- Captures child stderr for crash diagnostics in failure message
- AnalyzeJob tracks retryCount per job

Server error handling:
- app.listen wrapped in Promise so EADDRINUSE/EACCES propagate cleanly
- serve.ts catches startup errors with friendly messages and exit code 1
- EADDRINUSE gets actionable guidance (stop other process or --port flag)
- Global uncaughtException/unhandledRejection handlers prevent silent exits
- DEBUG=1 env var shows full stack traces

* feat: add e2e tests for onboarding flows, worker retry, and error handling

E2E tests (onboarding.spec.ts — 11 tests):
- Flow 1: OnboardingGuide shown when server unreachable (6 tests)
- Flow 2: Auto-connect with success card, analyze phase for zero repos
- Flow 3: Analyze form — GitHub URL validation, Local Folder tab, tab switching
- Flow 4: Repo dropdown in exploring view (skipped without live server)

Updated server-connect.spec.ts:
- Replaced manual Connect button flow with auto-connect waitForGraphLoaded

Server resilience:
- Worker auto-retry (2 attempts with exponential backoff) on crash
- Friendly error messages for serve startup failures (EADDRINUSE etc.)
- Global uncaughtException/unhandledRejection handlers prevent silent exits
- app.listen wrapped in Promise for proper error propagation

* refactor(shared): enforce exhaustive language coverage via Record types

Replace the if/else chain in getLanguageFromFilename with two exhaustive
Record<SupportedLanguages, ...> maps:

- EXTENSION_MAP: every language → its file extensions
- SYNTAX_MAP: every language → its Prism syntax identifier

Adding a new member to the SupportedLanguages enum without adding it to
both maps now produces a TypeScript compile error:

  Property '[SupportedLanguages.NewLang]' is missing in type...

This matches the existing pattern in languages/index.ts (providers table)
which already uses `satisfies Record<SupportedLanguages, LanguageProvider>`.

Three compile-time enforcement points now exist:
1. EXTENSION_MAP in language-detection.ts (file extensions)
2. SYNTAX_MAP in language-detection.ts (Prism syntax identifiers)
3. providers in languages/index.ts (LanguageProvider instances)

* feat(web): load source code from server and scroll to selected line

CodeReferencesPanel now fetches file content via GET /api/file when a
node is selected, instead of showing "Code not available in memory".

- Fetches via readFile() from backend-client when selectedFilePath changes
- Shows loading spinner while fetching
- After content loads, auto-scrolls to the selected node's startLine
- Highlights the selected line range with a cyan left border
- Cancels in-flight fetch if selection changes before it completes

Also: refactored language-detection.ts to use exhaustive Record types
(EXTENSION_MAP and SYNTAX_MAP) so adding a new SupportedLanguages enum
member without implementing extensions/syntax is a compile error.

* feat: buffered file reading for Code Inspector

Server: GET /api/file now supports ?startLine=N&endLine=M for reading
a line range instead of the entire file. Returns { content, startLine,
endLine, totalLines }.

Client: readFile() returns ReadFileResult with metadata. When selecting
a symbol (function, class, method), fetches only ±50 lines around the
symbol's startLine/endLine instead of the full file. File nodes still
fetch the entire file.

SyntaxHighlighter startingLineNumber set from the buffer offset so line
numbers are correct even for partial reads.

* fix: adapt readFile callers to new ReadFileResult return type

tools.ts: readFile comes from GraphRAGBackend interface which returns
Promise<string> (the adapter in useAppState extracts .content), so
revert the { content } destructuring back to plain string assignment.

useAppState.tsx: wrap backendReadFile with { repo } options object
and extract .content to satisfy the GraphRAGBackend interface.

* fix(web): ensure new repos appear in list immediately after analysis

Two fixes:

1. DropZone: handleAnalyzeComplete now passes the repoName through to
   connectToServer so the specific newly-analyzed repo loads — not the
   server's default first repo.

2. App.tsx: fetchRepos() is now awaited BEFORE handleServerConnect in
   both the DropZone and Header flows. This ensures the repo list is
   populated before the exploring view renders, so the new repo appears
   in the header dropdown immediately without a page reload.

* feat: delete repos, re-analyze with force, select after analysis

Server — DELETE /api/repo:
- Acquires repo lock first (409 if analyze/embed in flight)
- Closes LadybugDB, deletes index + clone dir, unregisters, re-inits
- Lock released in finally block

Server — analyze complete:
- backend.init() must succeed before SSE complete fires
- If backend.init() fails, job is marked failed (not complete)

Web — Header repo dropdown:
- Re-analyze: calls POST /api/analyze with force=true, shows spinning
  icon + inline progress bar via SSE
- Delete: acquires lock, aborts any running re-analysis SSE for same
  repo, refreshes list, switches to next repo
- After analysis completes: refreshes repo list, connects to the
  specific repo by name, loads graph, shows in explorer
- Retry with 1.5s backoff on 404 (server may still be reinitializing)

Type safety:
- err: any → err: unknown + instanceof BackendError in retry loop
- Added missing BackendRepo + BackendError imports in App.tsx
2026-03-28 14:07:11 +00:00
Juan Pablo Maddoni c27c9f612c fix/opencode mcp gitnexus timeout (#363) 2026-03-27 12:18:01 -05:00
893 changed files with 103702 additions and 34977 deletions
+5
View File
@@ -0,0 +1,5 @@
# Prettier initial formatting (2026-03-28)
afcc3d1523f99c77ff67c4fd1af12334660113f6
# ESLint unused import removal (2026-03-28)
1491826bc8da5436d3b1eb092d9274f2c9f028a7
+2
View File
@@ -0,0 +1,2 @@
* text=auto eol=lf
.husky/* text eol=lf
@@ -0,0 +1,21 @@
name: Setup GitNexus Web
description: Setup Node.js 20, build gitnexus-shared, install web dependencies
runs:
using: composite
steps:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
- name: Install web dependencies
run: npm ci
shell: bash
working-directory: gitnexus-web
@@ -16,6 +16,11 @@ runs:
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
shell: bash
working-directory: gitnexus-shared
- name: Install dependencies
run: npm ci
shell: bash
+1 -1
View File
@@ -35,7 +35,7 @@ changelog:
- dependencies
- title: "\U0001F4DD Other Changes"
labels:
- "*"
- '*'
exclude:
labels:
- dependencies
+1 -9
View File
@@ -28,15 +28,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: gitnexus-web
- uses: ./.github/actions/setup-gitnexus-web
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
+27 -7
View File
@@ -4,6 +4,32 @@ on:
workflow_call:
jobs:
format:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npx prettier --check .
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci
- run: npx eslint .
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -18,12 +44,6 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus-web/package-lock.json
- run: npm ci
working-directory: gitnexus-web
- uses: ./.github/actions/setup-gitnexus-web
- run: npx tsc -b --noEmit
working-directory: gitnexus-web
+3 -3
View File
@@ -6,12 +6,12 @@ name: CI Report
on:
workflow_run:
workflows: ["CI"]
workflows: ['CI']
types: [completed]
permissions:
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
actions: read # needed to list/download workflow run artifacts
contents: read # needed for sparse checkout of vitest.config.ts
pull-requests: write # needed to post sticky PR comment
jobs:
+1
View File
@@ -28,6 +28,7 @@ jobs:
--coverage.reportOnFailure=true
working-directory: gitnexus
# gitnexus-shared already built by setup-gitnexus action above
- name: Install gitnexus-web dependencies
run: npm ci
working-directory: gitnexus-web
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
pull-requests: write
issues: write
id-token: write
actions: read # required for Claude to read CI results on PRs
actions: read # required for Claude to read CI results on PRs
steps:
# For PR-related triggers, resolve the fork repo so we can checkout correctly.
- name: Resolve PR context
+20 -1
View File
@@ -30,6 +30,10 @@ jobs:
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- run: npm ci
working-directory: gitnexus
@@ -63,7 +67,22 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Extract release notes from CHANGELOG
id: changelog
shell: bash
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
echo "fallback=true" >> "$GITHUB_OUTPUT"
else
echo "$NOTES" > /tmp/release-notes.md
echo "fallback=false" >> "$GITHUB_OUTPUT"
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
generate_release_notes: true
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
+3 -1
View File
@@ -93,4 +93,6 @@ GitNexus.sln
.history/
.swarm/
.swarm/
local_docs/
+9 -16
View File
@@ -1,33 +1,26 @@
#!/usr/bin/env bash
# Pre-commit hook (husky): typecheck + unit tests for both packages.
# Mirrors CI checks from ci-quality.yml and ci-tests.yml.
# Pre-commit hook: format staged files + typecheck.
# Tests run in CI (ci-tests.yml), not here.
# Skip with: git commit --no-verify
#
# CI coverage:
# quality / typecheck → tsc --noEmit in gitnexus/
# quality / typecheck-web → tsc -b --noEmit in gitnexus-web/
# tests / ubuntu+coverage → vitest run in gitnexus/ (all projects)
# e2e / chromium → playwright (requires servers — skipped)
ROOT="$(git rev-parse --show-toplevel)"
# 1. Format staged files with prettier via lint-staged
echo "pre-commit: formatting staged files..."
"$ROOT/node_modules/.bin/lint-staged" || exit 1
# 2. Typecheck changed packages
WEB_CHANGED=$(git diff --cached --name-only -- 'gitnexus-web/' | head -1)
CLI_CHANGED=$(git diff --cached --name-only -- 'gitnexus/' | head -1)
if [ -n "$WEB_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus-web (tsc -b)..."
cd "$ROOT/gitnexus-web" && npx tsc -b --noEmit
echo "pre-commit: running gitnexus-web unit tests..."
npx vitest run --reporter=dot
cd "$ROOT/gitnexus-web" && ./node_modules/.bin/tsc -b --noEmit || exit 1
fi
if [ -n "$CLI_CHANGED" ]; then
echo "pre-commit: typechecking gitnexus..."
cd "$ROOT/gitnexus" && npx tsc --noEmit
echo "pre-commit: running gitnexus unit tests (default project)..."
npx vitest run --project default --reporter=dot
cd "$ROOT/gitnexus" && ./node_modules/.bin/tsc --noEmit || exit 1
fi
echo "pre-commit: all checks passed"
+16
View File
@@ -0,0 +1,16 @@
dist/
coverage/
gitnexus/vendor/
gitnexus/test/fixtures/
gitnexus-web/playwright-report/
gitnexus-web/test-results/
*.d.ts
*.snap
*.wasm
*.md
.gitnexus/
.vercel/
.claude-flow/
.swarm/
assets/
repomix-output*
+10
View File
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./gitnexus-web/src/index.css"
}
+4 -3
View File
@@ -1,10 +1,10 @@
<!-- version: 1.2.0 -->
<!-- version: 1.3.0 -->
<!--
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
Last updated: 2026-03-22
-->
Last reviewed: 2026-03-24
Last reviewed: 2026-04-13
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@@ -54,6 +54,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
| Date | Version | Change |
|------|---------|--------|
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
| 2026-03-24 | 1.2.0 | Fixed gitnexus:start block duplication (was inlined in Reference Docs bullet). |
| 2026-03-23 | 1.1.0 | Updated agent instructions (sections, references, Cursor layout). |
| 2026-03-22 | 1.0.0 | Added structured agent header and changelog. |
@@ -63,7 +64,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current symbol stats, run `npx gitnexus analyze` and inspect `.gitnexus/meta.json`.
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
+117 -2
View File
@@ -16,7 +16,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
1. **Ingestion** (`gitnexus analyze`)
- Entry: `gitnexus/src/cli/analyze.ts` → `runPipelineFromRepo` in `gitnexus/src/core/ingestion/pipeline.ts`.
- Walks the git working tree, parses supported languages via **Tree-sitter**, resolves imports/calls/inheritance, detects **communities** and **processes** (execution flows), and builds an in-memory **knowledge graph** (`gitnexus/src/core/graph/`).
- The pipeline is structured as a **DAG (Directed Acyclic Graph)** of named phases (see [Pipeline Phase DAG](#pipeline-phase-dag) below).
- Output is loaded into **LadybugDB** under **`.gitnexus/`** at the repo root (`lbug/`, `meta.json`, etc.). Optional **FTS** indexes and **embeddings** attach to the same store.
- The repo is registered in **`~/.gitnexus/registry.json`** so MCP can find it from any working directory.
@@ -49,7 +49,7 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
| If you are changing… | Start in… |
|----------------------|-----------|
| CLI commands / flags | `gitnexus/src/cli/` (`index.ts`, per-command modules). |
| Parsing or graph construction | `gitnexus/src/core/ingestion/` (pipeline, processors, resolvers, type-extractors). |
| Parsing or graph construction | `gitnexus/src/core/ingestion/pipeline-phases/` (individual phase files), `pipeline.ts` (orchestrator). |
| Graph schema / DB access | `gitnexus/src/core/lbug/` (`schema.ts`, `lbug-adapter.ts`), `gitnexus/src/mcp/core/lbug-adapter.ts` if MCP-specific. |
| MCP protocol, tools, resources | `gitnexus/src/mcp/server.ts`, `tools.ts`, `resources.ts`. |
| Search ranking | `gitnexus/src/core/search/` (BM25, hybrid fusion). |
@@ -58,8 +58,123 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
## Pipeline Phase DAG
The ingestion pipeline is a DAG of named phases. Each phase is defined in its own file under `gitnexus/src/core/ingestion/pipeline-phases/` with explicit dependencies, typed inputs, and typed outputs.
```
scan → structure → [markdown, cobol] → parse → [routes, tools, orm]
→ crossFile → mro → communities → processes
```
### Phase files
| Phase | File | Dependencies | What it does |
|-------|------|-------------|--------------|
| `scan` | `scan.ts` | (root) | Walk repo filesystem, collect paths + sizes |
| `structure` | `structure.ts` | `scan` | Build File/Folder nodes + CONTAINS edges |
| `markdown` | `markdown.ts` | `structure` | Extract headings and cross-links from .md/.mdx |
| `cobol` | `cobol.ts` | `structure` | Regex-based COBOL/JCL extraction |
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Chunked tree-sitter parse, import/call/heritage resolution |
| `routes` | `routes.ts` | `parse` | Route registry (Next.js, Expo, PHP, decorator-based) |
| `tools` | `tools.ts` | `parse` | MCP/RPC tool detection |
| `orm` | `orm.ts` | `parse` | Prisma/Supabase ORM query edges |
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological order |
| `mro` | `mro.ts` | `crossFile` | Method Resolution Order, METHOD_OVERRIDES edges |
| `communities` | `communities.ts` | `mro` | Leiden community detection |
| `processes` | `processes.ts` | `communities`, `routes`, `tools` | Execution flow detection, Route/Tool → Process links |
### How to add a new phase
1. Create a new file in `pipeline-phases/` (e.g. `my-phase.ts`)
2. Define a `PipelinePhase<MyOutput>` object with `name`, `deps`, and `execute(ctx, deps)`
3. Export it from `pipeline-phases/index.ts`
4. Add it to the `buildPhaseList()` function in `pipeline.ts`
```typescript
// pipeline-phases/my-phase.ts
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { getPhaseOutput } from './types.js';
import type { ParseOutput } from './parse.js';
export interface MyPhaseOutput { /* ... */ }
export const myPhase: PipelinePhase<MyPhaseOutput> = {
name: 'myPhase',
deps: ['parse'], // runs after parse completes
async execute(ctx, deps) {
const { allPaths } = getPhaseOutput<ParseOutput>(deps, 'parse');
// ... do work, write to ctx.graph ...
return { /* typed output */ };
},
};
```
### DAG runner
The runner (`pipeline-phases/runner.ts`) validates the DAG at startup (detects cycles and missing deps via topological sort), then executes phases in dependency order. Each phase receives:
- `ctx: PipelineContext` — shared graph, repoPath, progress callback
- `deps: Map<string, PhaseResult>` — outputs from all upstream phases
## Known limitations
### Overloaded method resolution
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
disambiguate overloaded methods. Two overloads with different parameter counts
produce distinct graph nodes: `Method:file:Class.method#1` vs
`Method:file:Class.method#2`.
**Same-arity overload disambiguation:** When two overloads share the same
parameter count but differ in types (e.g. `save(int)` vs `save(String)`), a
type-hash suffix `~type1,type2` is appended to produce distinct node IDs:
`Method:file:Class.save#1~int` vs `Method:file:Class.save#1~String`. The suffix
is only added when a same-arity collision is detected within a class and all
parameters have non-null type annotations. Languages without type info (Python,
Ruby, JS) fall back to arity-only IDs. TypeScript/JavaScript overload signatures
are intentionally excluded from type-hashing because they are declaration-only
contracts that should collapse to the implementation body's node ID. See issue
\#651.
**C++ const-qualified overload disambiguation:** Methods overloaded by const
qualification (e.g. `begin()` vs `begin() const`) are disambiguated via an
`isConst` property and a `$const` ID suffix appended to the const-qualified
variant when a non-const collision exists. The `$const` suffix appears after the
type-hash suffix: e.g. `Method:file:Container.begin#0$const`.
**Generic/template type preservation in type-hash:** The type-hash suffix uses
`rawType` (full AST text including generic/template args) rather than the
simplified `type` from `extractSimpleTypeName`. This means C++ template overloads
like `process(vector<int>)` vs `process(vector<string>)` produce distinct IDs:
`~vector<int>` vs `~vector<std::string>`. Java generic overloads like
`process(List<String>)` vs `process(List<Integer>)` are a compile error due to
type erasure, so this gap is theoretical for Java.
**ID stability on first overload:** Type and const tags are collision-only. When
a class has `save(int)` as its only `save` method, the ID is `save#1` (no tag).
Adding `save(String)` changes the original to `save#1~int`. This is correct for
fresh analysis but means IDs are not stable across overload additions. Future
incremental re-analysis should account for this.
**Variadic method matching:** When one side is variadic (`parameterCount`
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
emitted with confidence 0.7 instead of 1.0. Variadic methods like
`foo(String... args)` may superficially match `foo(String s)` by type but
are not guaranteed to be interchangeable across all languages (Java/Kotlin
accept this via varargs sugar; TypeScript, C#, Rust do not).
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
| Match quality | Confidence | When |
|---|---|---|
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
## Related docs
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
- [TESTING.md](TESTING.md) — how to run tests.
+13
View File
@@ -10,6 +10,19 @@ All notable changes to GitNexus will be documented in this file.
- Added automatic cleanup of stale KuzuDB index files
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
## [1.5.3] - 2026-04-01
### Added
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
### Fixed
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
- Ensure import rewrites survive npm publish lifecycle
## [1.4.0] - 2026-03-13
### Added
+206 -3
View File
@@ -1,10 +1,10 @@
<!-- version: 1.2.0 -->
<!-- version: 1.3.0 -->
<!--
Metadata: version, last reviewed, scope, model policy, reference docs, changelog.
Last updated: 2026-03-22
-->
Last reviewed: 2026-03-24
Last reviewed: 2026-04-13
**Project:** GitNexus · **Environment:** dev · **Maintainer:** repository maintainers (see GitHub)
@@ -41,6 +41,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
| Date | Version | Change |
|------|---------|--------|
| 2026-04-13 | 1.3.0 | Updated GitNexus index stats after DAG refactor. |
| 2026-03-24 | 1.2.0 | Removed duplicated gitnexus:start block and scope table; replaced with pointers to AGENTS.md. |
| 2026-03-23 | 1.1.0 | Updated agent instructions to match AGENTS.md. |
| 2026-03-22 | 1.0.0 | Added structured header and changelog. |
@@ -49,4 +50,206 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## GitNexus rules
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
GitNexus MCP rules are in the `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (4325 symbols, 10556 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
| `gitnexus://repo/GitNexus/processes` | All execution flows |
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->
+27
View File
@@ -0,0 +1,27 @@
# Migration Guide
## OVERRIDES → METHOD_OVERRIDES (PR #642)
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
consistency with the new `METHOD_IMPLEMENTS` edge type.
### Do I need to migrate?
**No.** Backward compatibility is handled automatically at runtime:
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
edges continue to return correct results without any manual intervention.
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
queries that reference either will work.
### What happens on re-index?
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
edges going forward. The old `OVERRIDES` edges are replaced as part of the
normal full re-index.
### When will the legacy alias be removed?
The `OVERRIDES` compat alias will remain until a future major version. Removal
will be announced in this file and in the changelog before it happens.
+52 -13
View File
@@ -19,6 +19,8 @@
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
</a>
<p><strong>Enterprise (SaaS & Self-hosted)</strong> - <a href="https://akonlabs.com">akonlabs.com</a></p>
</div>
**Building nervous system for agent context.**
@@ -50,7 +52,7 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
| **What** | Index repos locally, connect AI agents via MCP | Visual graph explorer + AI chat in browser |
| **For** | Daily development with Cursor, Claude Code, Codex, Windsurf, OpenCode | Quick exploration, demos, one-off analysis |
| **Scale** | Full repos, any size | Limited by browser memory (~5k files), or unlimited via backend mode |
| **Install** | `npm install -g gitnexus` | No install —[gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Install** | `npm install -g gitnexus` | No install — [gitnexus.vercel.app](https://gitnexus.vercel.app) |
| **Storage** | LadybugDB native (fast, persistent) | LadybugDB WASM (in-memory, per session) |
| **Parsing** | Tree-sitter native bindings | Tree-sitter WASM |
| **Privacy** | Everything local, no network | Everything in-browser, no server |
@@ -59,6 +61,28 @@ https://github.com/user-attachments/assets/172685ba-8e54-4ea7-9ad1-e31a3398da72
---
## Enterprise
GitNexus is available as an **enterprise offering** - either as a fully managed **SaaS** or a **self-hosted** deployment. Also available for **commercial use** of the OSS version with proper licensing.
Enterprise includes:
- **PR Review** - automated blast radius analysis on pull requests
- **Auto-updating Code Wiki** - always up-to-date documentation (Code Wiki is also available in OSS)
- **Auto-reindexing** - knowledge graph stays fresh automatically
- **Multi-repo support** - unified graph across repositories
- **OCaml support** - additional language coverage
- **Priority feature/language support** - request new languages or features
**Upcoming:**
- Auto regression forensics
- End-to-end test generation
👉 Learn more at [akonlabs.com](https://akonlabs.com)
💬 For commercial licensing or enterprise inquiries, ping us on [Discord](https://discord.gg/AAsRVT6fGb) or drop an email at founders@akonlabs.com
---
## Development
- [ARCHITECTURE.md](ARCHITECTURE.md) — packages, index → graph → MCP flow, where to change code
@@ -95,7 +119,6 @@ To configure MCP for your editor, run `npx gitnexus setup` once — or set it up
| **Codex** | Yes | Yes | — | MCP + Skills |
| **Windsurf** | Yes | — | — | MCP |
| **OpenCode** | Yes | Yes | — | MCP + Skills |
| **Codex** | Yes | — | — | MCP |
> **Claude Code** gets the deepest integration: MCP tools + agent skills + PreToolUse hooks that enrich searches with graph context + PostToolUse hooks that auto-reindex after commits.
@@ -147,8 +170,8 @@ codex mcp add gitnexus -- npx -y gitnexus@latest mcp
{
"mcp": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@latest", "mcp"]
"type": "local",
"command": ["gitnexus", "mcp"]
}
}
}
@@ -165,13 +188,14 @@ args = ["-y", "gitnexus@latest", "mcp"]
### CLI Commands
```bash
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --force # Force full re-index
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus setup # Configure MCP for your editors (one-time)
gitnexus analyze [path] # Index a repository (or update stale index)
gitnexus analyze --force # Force full re-index
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexus section edits
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI connection
gitnexus list # List all indexed repositories
@@ -181,11 +205,21 @@ gitnexus clean --all --force # Delete all indexes
gitnexus wiki [path] # Generate repository wiki from knowledge graph
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
# Repository groups (multi-repo / monorepo service tracking)
gitnexus group create <name> # Create a repository group
gitnexus group add <name> <repo> # Add a repo to a group
gitnexus group remove <name> <repo> # Remove a repo from a group
gitnexus group list [name] # List groups, or show one group's config
gitnexus group sync <name> # Extract contracts and match across repos/services
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
gitnexus group query <name> <q> # Search execution flows across all repos in a group
gitnexus group status <name> # Check staleness of repos in a group
```
### What Your AI Agent Gets
**7 tools** exposed via MCP:
**16 tools** exposed via MCP (11 per-repo + 5 group):
| Tool | What It Does | `repo` Param |
| ------------------ | ----------------------------------------------------------------- | -------------- |
@@ -196,6 +230,11 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
| `cypher` | Raw Cypher graph queries | Optional |
| `group_list` | List configured repository groups | — |
| `group_sync` | Extract contracts and match across repos/services | — |
| `group_contracts`| Inspect extracted contracts and cross-links | — |
| `group_query` | Search execution flows across all repos in a group | — |
| `group_status` | Check staleness of repos in a group | — |
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
@@ -291,8 +330,8 @@ Or run locally:
```bash
git clone https://github.com/abhigyanpatwari/gitnexus.git
cd gitnexus/gitnexus-web
npm install
cd gitnexus/gitnexus-shared && npm install && npm run build
cd ../gitnexus-web && npm install
npm run dev
```
@@ -0,0 +1,725 @@
# PR #626 HIGH-Priority Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
---
### Task 1: Path Traversal — Validate Group Name
**Files:**
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
- Test: `gitnexus/test/unit/group/storage.test.ts`
- [ ] **Step 1: Write failing tests for validateGroupName**
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
```typescript
describe('validateGroupName', () => {
it('test_validateGroupName_traversal_path_throws', () => {
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_slash_in_name_throws', () => {
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_empty_string_throws', () => {
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_dash_throws', () => {
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_starts_with_underscore_throws', () => {
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_dots_throws', () => {
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
});
it('test_validateGroupName_valid_alphanumeric_passes', () => {
expect(() => validateGroupName('my-group_01')).not.toThrow();
});
it('test_validateGroupName_single_char_passes', () => {
expect(() => validateGroupName('A')).not.toThrow();
});
it('test_validateGroupName_all_digits_passes', () => {
expect(() => validateGroupName('123')).not.toThrow();
});
});
describe('getGroupDir rejects invalid names', () => {
it('test_getGroupDir_traversal_throws', () => {
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
});
it('test_getGroupDir_valid_name_returns_path', () => {
const dir = getGroupDir(tmpDir, 'company');
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
});
});
describe('createGroupDir rejects invalid names', () => {
it('test_createGroupDir_traversal_throws', async () => {
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
```typescript
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
export function validateGroupName(name: string): void {
if (!GROUP_NAME_RE.test(name)) {
throw new Error(
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
);
}
}
export function getGroupDir(gitnexusDir: string, groupName: string): string {
validateGroupName(groupName);
return path.join(gitnexusDir, 'groups', groupName);
}
```
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
git commit -m "fix(group): validate group name to prevent path traversal
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
Called in getGroupDir (defense in depth) which covers all CLI entry
points: create, add, remove, status, sync.
Addresses PR #626 review item 1 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Directory Exclusions in Service Boundary Detector
**Files:**
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
- [ ] **Step 1: Write failing tests for excluded directories**
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
```typescript
it('test_detect_skips_vendor_directory', async () => {
writeFile('services/auth/package.json', '{}');
writeFile('services/auth/src/index.ts', '');
// vendor should be skipped — its contents should not create a boundary
writeFile('vendor/some-dep/package.json', '{}');
writeFile('vendor/some-dep/src/lib.go', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/auth');
expect(paths).not.toContain('vendor/some-dep');
});
it('test_detect_skips_target_directory', async () => {
writeFile('services/api/go.mod', 'module api');
writeFile('services/api/main.go', '');
writeFile('target/classes/Main.java', '');
writeFile('target/pom.xml', '<project/>');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('target');
});
it('test_detect_skips_pycache_directory', async () => {
writeFile('services/ml/pyproject.toml', '[project]');
writeFile('services/ml/model.py', '');
// __pycache__ with a marker + source files — would be detected as
// a boundary if not excluded, since it has package.json + .py file
writeFile('__pycache__/package.json', '{}');
writeFile('__pycache__/cached.py', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/ml');
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
});
it('test_detect_skips_dotfile_directories_regression', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
writeFile('.hidden/package.json', '{}');
writeFile('.hidden/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
const paths = boundaries.map((b) => b.servicePath);
expect(paths).toContain('services/api');
expect(paths).not.toContain('.hidden');
});
it('test_detect_does_not_skip_regular_source_directories', async () => {
writeFile('services/api/package.json', '{}');
writeFile('services/api/src/index.ts', '');
const boundaries = await detectServiceBoundaries(tmpDir);
expect(boundaries).toHaveLength(1);
expect(boundaries[0].serviceName).toBe('api');
});
```
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
In `gitnexus/src/core/group/service-boundary-detector.ts`:
After `SOURCE_EXTENSIONS` (after line 51), add:
```typescript
const EXCLUDED_DIRS = new Set([
'node_modules',
'vendor',
'target',
'build',
'dist',
'__pycache__',
'.venv',
'venv',
'.tox',
'.mypy_cache',
'.gradle',
'.mvn',
'out',
'bin',
]);
```
In `walkForBoundaries`, replace line 78:
```typescript
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
```
with:
```typescript
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
```
In `hasSourceFilesInSubdirs`, replace line 130:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
```
with:
```typescript
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
Expected: ALL PASS
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
git commit -m "fix(group): add directory exclusions to service boundary detector
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
Replaces inline node_modules check.
Addresses PR #626 review item 3 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: Remove Double-Close of LadybugDB Pools
**Files:**
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
```typescript
it('test_syncGroup_closes_only_opened_pools', async () => {
const config = makeConfig({
'app/backend': 'backend-repo',
'app/frontend': 'frontend-repo',
});
const closedIds: string[] = [];
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
const { vi } = await import('vitest');
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
if (id) closedIds.push(id);
});
try {
await syncGroup(config, {
resolveRepoHandle: async (_name, groupPath) => ({
id: groupPath.replace(/\//g, '-'),
path: groupPath,
repoPath: '/tmp/' + groupPath,
storagePath: '/tmp/' + groupPath + '/.gitnexus',
}),
skipWrite: true,
}).catch(() => {});
// Regardless of extraction errors, closeLbug should be called per id
// closeLbug should only receive specific pool ids, never undefined/empty
for (const id of closedIds) {
expect(id).toBeTruthy();
expect(typeof id).toBe('string');
}
// No blanket close (no-arg call)
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
expect(blanketCalls).toHaveLength(0);
} finally {
initSpy.mockRestore();
closeSpy.mockRestore();
}
});
```
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
```typescript
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
const source = fs.readFileSync(cliGroupPath, 'utf-8');
// closeLbug() without arguments (blanket close) must not appear.
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
// Match closeLbug() but not closeLbug(someArg)
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
expect(source).not.toMatch(blanketClosePattern);
});
```
- [ ] **Step 4: Run test to verify it fails**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
In `gitnexus/src/cli/group.ts`:
Remove the `closeLbug` import at line 160:
```typescript
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
```
Replace the try/finally wrapper (lines 162-189):
```typescript
try {
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
} finally {
await closeLbug().catch(() => {});
}
```
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
```typescript
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
Expected: ALL PASS
- [ ] **Step 7: Commit**
```bash
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
sync.ts already closes pools per-id in its finally block.
The blanket closeLbug() in cli/group.ts tears down ALL active pools
including unrelated ones in MCP server context.
Addresses PR #626 review item 4 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: gRPC Proto Regex — Brace-Depth Counter
**Files:**
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
- [ ] **Step 1: Write failing tests for nested braces in proto services**
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
```typescript
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
writeFile(
'api/gateway.proto',
`syntax = "proto3";
package gateway.v1;
import "google/api/annotations.proto";
service GatewayService {
rpc GetUser (GetUserRequest) returns (UserResponse) {
option (google.api.http) = {
get: "/v1/users/{user_id}"
};
}
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
option (google.api.http) = {
post: "/v1/users"
body: "*"
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
);
expect(providers).toHaveLength(2);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::gateway.v1.GatewayService/CreateUser',
'grpc::gateway.v1.GatewayService/GetUser',
]);
});
it('test_extract_proto_with_multiple_services', async () => {
writeFile(
'api/multi.proto',
`syntax = "proto3";
package multi;
service ServiceA {
rpc MethodA (Req) returns (Res);
}
service ServiceB {
rpc MethodB1 (Req) returns (Res);
rpc MethodB2 (Req) returns (Res);
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
);
expect(providers).toHaveLength(3);
const ids = providers.map((c) => c.contractId).sort();
expect(ids).toEqual([
'grpc::multi.ServiceA/MethodA',
'grpc::multi.ServiceB/MethodB1',
'grpc::multi.ServiceB/MethodB2',
]);
});
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
writeFile(
'api/nested.proto',
`syntax = "proto3";
package nested;
service DeepService {
rpc DeepMethod (Req) returns (Res) {
option (google.api.http) = {
post: "/v1/deep"
body: "*"
additional_bindings {
get: "/v1/deep/{id}"
}
};
}
}`,
);
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
);
expect(providers).toHaveLength(1);
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
});
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
writeFile(
'api/broken.proto',
`syntax = "proto3";
package broken;
service IncompleteService {
rpc SomeMethod (Req) returns (Res);
// Missing closing brace — EOF before depth returns to 0
`,
);
// Should not throw; incomplete service is silently skipped
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
const providers = contracts.filter(
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
);
// The old regex would find partial match; the new parser should skip it
expect(providers).toHaveLength(0);
});
```
- [ ] **Step 2: Run tests to verify the nested brace test fails**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
```typescript
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
const pkg = pkgMatch ? pkgMatch[1] : '';
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
const rpcRe = /rpc\s+(\w+)\s*\(/g;
let rpcMatch: RegExpExecArray | null;
while ((rpcMatch = rpcRe.exec(body)) !== null) {
const methodName = rpcMatch[1];
const cid = contractId(pkg, serviceName, methodName);
out.push(
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
package: pkg,
service: serviceName,
method: methodName,
source: 'proto',
}),
);
}
}
return out;
}
```
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
```typescript
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
const results: Array<{ name: string; body: string }> = [];
const headerRe = /service\s+(\w+)\s*\{/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerRe.exec(content)) !== null) {
const serviceName = headerMatch[1];
const bodyStart = headerMatch.index + headerMatch[0].length;
let depth = 1;
let pos = bodyStart;
while (pos < content.length && depth > 0) {
const ch = content[pos];
if (ch === '{') depth++;
else if (ch === '}') depth--;
pos++;
}
// If EOF before depth returns to 0, skip incomplete service
if (depth !== 0) continue;
// body is between opening { (consumed by regex) and closing } (pos is one past it)
const body = content.slice(bodyStart, pos - 1);
results.push({ name: serviceName, body });
}
return results;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
Expected: ALL PASS (including existing regression tests)
- [ ] **Step 5: Commit**
```bash
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
The serviceRe regex used [^}]* which stopped at the first '}'.
Proto services with google.api.http annotations contain nested {}
blocks, causing methods to be missed.
New extractServiceBlocks() uses a brace-depth counter (init depth=1
after opening {, scan char-by-char). Malformed protos with unclosed
braces are silently skipped.
Addresses PR #626 review item 2 (HIGH).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: Run Full Test Suite
- [ ] **Step 1: Run all group-related tests**
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
Expected: ALL PASS
- [ ] **Step 2: Run full test suite to catch regressions**
Run: `cd gitnexus && npx vitest run`
Expected: ALL PASS, 0 failures
- [ ] **Step 3: Run typecheck**
Run: `cd gitnexus && npx tsc --noEmit`
Expected: No errors
---
### Task 6: CLI Integration Smoke Test
- [ ] **Step 1: Add CLI smoke test for path traversal**
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
```typescript
it('test_create_with_invalid_name_fails', () => {
const result = runGroup(['create', '../../evil']);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('Invalid group name');
});
```
- [ ] **Step 2: Run test**
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
Expected: ALL PASS
- [ ] **Step 3: Commit**
```bash
cd gitnexus && git add test/integration/group/group-cli.test.ts
git commit -m "test(group): add CLI smoke test for path traversal rejection
Verifies that 'group create ../../evil' fails with Invalid group name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
```
@@ -0,0 +1,175 @@
# PR #626 HIGH-Priority Fixes Design
**Date:** 2026-04-02
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
---
## Fix 1: Path Traversal via Group Name
**File:** `gitnexus/src/core/group/storage.ts`
**Risk:** A group name like `../../etc` creates directories outside the intended path.
### Solution
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
- Call in `createGroupDir` (primary entry point)
- Call in `getGroupDir` (defense in depth)
- Throw descriptive error on invalid names
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
### Why regex over path.resolve + startsWith
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
- Stricter: disallows spaces, dots, Unicode edge cases
- Simpler to reason about
### Tests
- `../../evil` throws
- `foo/bar` throws
- Empty string throws
- `my-group_01` passes
- `A` (single char) passes
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
### CLI/API entry points accepting groupName
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
| Command | Entry | Calls |
|---------|-------|-------|
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
| `group add` | `cli/group.ts` action | `getGroupDir` |
| `group remove` | `cli/group.ts` action | `getGroupDir` |
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
| `group status` | `cli/group.ts` action | `getGroupDir` |
| `group sync` | `cli/group.ts` action | `getGroupDir` |
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
---
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
### Solution
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
2. Initialise depth to 1 immediately after the opening `{`
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
4. Stop when depth reaches 0 (the matching closing `}`)
5. Return name + body pairs
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
### Tests
- Proto with single service, no nesting (regression)
- Proto with `google.api.http` nested braces inside RPC options
- Proto with multiple services
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
- Malformed proto with unclosed brace (graceful handling)
---
## Fix 3: Directory Exclusions in Service Boundary Detector
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
### Solution
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
```text
node_modules, vendor, target, build, dist,
__pycache__, .venv, venv, .tox, .mypy_cache,
.gradle, .mvn, out, bin
```
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
Apply in both:
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
### Tests
- Directory named `vendor/` is skipped
- Directory named `target/` is skipped
- Directory named `__pycache__/` is skipped
- Regular source directories are NOT skipped
- Dotfile directories still skipped (regression)
---
## Fix 4: Double-Close of LadybugDB Pools
**Files:**
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
### Solution
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
```typescript
// sync.ts — KEEP: cleans up only pools opened by this sync
finally {
for (const id of [...new Set(openPoolIds)]) {
await closeLbug(id).catch(() => {});
}
}
```
```typescript
// cli/group.ts — REMOVE: blanket close that kills all pools
finally {
await closeLbug().catch(() => {}); // DELETE THIS
}
```
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
### Tests (unit level — mock pool adapter)
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
---
## Out of Scope
- JSON -> LadybugDB migration (tracked in #606)
- MEDIUM/LOW issues (items 5-10 from review summary)
- Test gap coverage beyond what's needed for these 4 fixes
- Any refactoring or architectural changes
## Execution Order
Fixes are independent — can be implemented in parallel or any order.
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
+83
View File
@@ -0,0 +1,83 @@
import tsPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import unusedImports from 'eslint-plugin-unused-imports';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
export default [
// Global ignores
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/coverage/**',
'gitnexus/vendor/**',
'gitnexus-web/src/vendor/**',
'gitnexus/test/fixtures/**',
'gitnexus-web/playwright-report/**',
'gitnexus-web/test-results/**',
'**/*.d.ts',
'.claude/**',
'.history/**',
],
},
// Base TypeScript config for all packages
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
'unused-imports': unusedImports,
},
rules: {
// Unused imports — auto-fixable
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
// TypeScript quality
'@typescript-eslint/no-unused-vars': 'off', // handled by unused-imports plugin
'no-unused-vars': 'off', // handled by unused-imports plugin
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-non-null-assertion': 'warn',
// General quality
'no-debugger': 'error',
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
},
},
// CLI package — allow console.log (it's a CLI tool)
{
files: ['gitnexus/src/cli/**/*.ts', 'gitnexus/src/server/**/*.ts'],
rules: {
'no-console': 'off',
},
},
// React-specific rules for gitnexus-web
{
files: ['gitnexus-web/src/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
// Disable formatting rules (prettier handles those)
prettierConfig,
];
+2 -2
View File
@@ -1,8 +1,8 @@
# Claude Haiku 4.5 — fast, cheap, good baseline
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/anthropic/claude-haiku-4.5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-haiku-4.5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0
+2 -2
View File
@@ -2,8 +2,8 @@
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
# To use Anthropic directly, change to: anthropic/claude-opus-4-20250514
model:
model_name: "openrouter/anthropic/claude-opus-4"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-opus-4'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 16384
temperature: 0
+2 -2
View File
@@ -2,8 +2,8 @@
# Via OpenRouter (set OPENROUTER_API_KEY in .env)
# To use Anthropic directly, change to: anthropic/claude-sonnet-4-20250514
model:
model_name: "openrouter/anthropic/claude-sonnet-4"
cost_tracking: "ignore_errors"
model_name: 'openrouter/anthropic/claude-sonnet-4'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 16384
temperature: 0
+3 -3
View File
@@ -1,9 +1,9 @@
model: deepseek-ai/deepseek-chat
provider: openrouter
cost:
input: 0.14 # per 1M tokens
output: 0.28 # per 1M tokens
input: 0.14 # per 1M tokens
output: 0.28 # per 1M tokens
# Native DeepSeek API (direct)
api_key: null
base_url: null
+3 -3
View File
@@ -1,9 +1,9 @@
model: deepseek-ai/DeepSeek-V3
provider: openrouter
cost:
input: 0.27 # per 1M tokens
output: 1.10 # per 1M tokens
input: 0.27 # per 1M tokens
output: 1.10 # per 1M tokens
# Native DeepSeek API (direct)
# Get your API key at: https://platform.deepseek.com/
# Or use OpenRouter with: OPENROUTER_API_KEY
+2 -2
View File
@@ -1,7 +1,7 @@
# GLM 4.7 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/zhipuai/glm-4.7"
cost_tracking: "ignore_errors"
model_name: 'openrouter/zhipuai/glm-4.7'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0
+2 -2
View File
@@ -1,7 +1,7 @@
# GLM 5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/zhipuai/glm-5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/zhipuai/glm-5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0
+2 -2
View File
@@ -1,7 +1,7 @@
# MiniMax M1 2.5 — via OpenRouter (set OPENROUTER_API_KEY in .env)
model:
model_name: "openrouter/minimax/minimax-m1-2.5"
cost_tracking: "ignore_errors"
model_name: 'openrouter/minimax/minimax-m1-2.5'
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0
+2 -2
View File
@@ -3,9 +3,9 @@
# The action_regex tells mini-swe-agent to parse ```bash blocks from responses.
model:
model_class: litellm_textbased
model_name: "openrouter/minimax/minimax-m2.5"
model_name: 'openrouter/minimax/minimax-m2.5'
action_regex: "```(?:bash|mswea_bash_command)\\s*\\n(.*?)\\n```"
cost_tracking: "ignore_errors"
cost_tracking: 'ignore_errors'
model_kwargs:
max_tokens: 8192
temperature: 0
+3 -3
View File
@@ -1,9 +1,9 @@
# Baseline mode — no GitNexus, pure mini-swe-agent (control group)
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "baseline"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'baseline'
step_limit: 30
cost_limit: 3.0
environment:
environment_class: "docker"
environment_class: 'docker'
+3 -3
View File
@@ -5,14 +5,14 @@
#
# Use this mode to isolate the value of explicit tools without grep augmentation.
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "native"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'native'
step_limit: 30
cost_limit: 3.0
track_gitnexus_usage: true
environment:
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
enable_gitnexus: true
skip_embeddings: true
gitnexus_timeout: 120
+3 -3
View File
@@ -8,8 +8,8 @@
#
# The agent decides when to use explicit tools vs rely on enriched grep results.
agent:
agent_class: "eval.agents.gitnexus_agent.GitNexusAgent"
gitnexus_mode: "native_augment"
agent_class: 'eval.agents.gitnexus_agent.GitNexusAgent'
gitnexus_mode: 'native_augment'
step_limit: 30
cost_limit: 3.0
augment_timeout: 5.0
@@ -17,7 +17,7 @@ agent:
track_gitnexus_usage: true
environment:
environment_class: "eval.environments.gitnexus_docker.GitNexusDockerEnvironment"
environment_class: 'eval.environments.gitnexus_docker.GitNexusDockerEnvironment'
enable_gitnexus: true
skip_embeddings: true
gitnexus_timeout: 120
+1 -1
View File
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"mini-swe-agent>=2.0.0",
"litellm>=1.50.0",
"litellm>=1.50.0,!=1.82.7,!=1.82.8",
"datasets>=3.0.0",
"typer>=0.12.0",
"rich>=13.0.0",
+60 -25
View File
@@ -64,10 +64,26 @@ function extractPattern(toolName, toolInput) {
const tokens = cmd.split(/\s+/);
let foundCmd = false;
let skipNext = false;
const flagsWithValues = new Set(['-e', '-f', '-m', '-A', '-B', '-C', '-g', '--glob', '-t', '--type', '--include', '--exclude']);
const flagsWithValues = new Set([
'-e',
'-f',
'-m',
'-A',
'-B',
'-C',
'-g',
'--glob',
'-t',
'--type',
'--include',
'--exclude',
]);
for (const token of tokens) {
if (skipNext) { skipNext = false; continue; }
if (skipNext) {
skipNext = false;
continue;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue;
@@ -98,33 +114,42 @@ function runGitNexusCli(args, cwd, timeout) {
// Detect whether 'gitnexus' is on PATH (cheap check, no execution)
let useDirectBinary = false;
try {
const which = spawnSync(
isWin ? 'where' : 'which', ['gitnexus'],
{ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }
);
const which = spawnSync(isWin ? 'where' : 'which', ['gitnexus'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['pipe', 'pipe', 'pipe'],
});
useDirectBinary = which.status === 0;
} catch { /* not on PATH */ }
} catch {
/* not on PATH */
}
if (useDirectBinary) {
return spawnSync(
isWin ? 'gitnexus.cmd' : 'gitnexus', args,
{ encoding: 'utf-8', timeout, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(isWin ? 'gitnexus.cmd' : 'gitnexus', args, {
encoding: 'utf-8',
timeout,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
// npx fallback needs shell on Windows since npx is a .cmd script
return spawnSync(
isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args],
{ encoding: 'utf-8', timeout: timeout + 5000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
return spawnSync(isWin ? 'npx.cmd' : 'npx', ['-y', 'gitnexus', ...args], {
encoding: 'utf-8',
timeout: timeout + 5000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
/**
* Emit a hook response with additional context for the agent.
*/
function sendHookResponse(hookEventName, message) {
console.log(JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message }
}));
console.log(
JSON.stringify({
hookSpecificOutput: { hookEventName, additionalContext: message },
}),
);
}
/**
@@ -149,7 +174,9 @@ function handlePreToolUse(input) {
if (!child.error && child.status === 0) {
result = child.stderr || '';
}
} catch { /* graceful failure */ }
} catch {
/* graceful failure */
}
if (result && result.trim()) {
sendHookResponse('PreToolUse', result.trim());
@@ -185,10 +212,15 @@ function handlePostToolUse(input) {
let currentHead = '';
try {
const headResult = spawnSync('git', ['rev-parse', 'HEAD'], {
encoding: 'utf-8', timeout: 3000, cwd, stdio: ['pipe', 'pipe', 'pipe'],
encoding: 'utf-8',
timeout: 3000,
cwd,
stdio: ['pipe', 'pipe', 'pipe'],
});
currentHead = (headResult.stdout || '').trim();
} catch { return; }
} catch {
return;
}
if (!currentHead) return;
@@ -197,16 +229,19 @@ function handlePostToolUse(input) {
try {
const meta = JSON.parse(fs.readFileSync(path.join(gitNexusDir, 'meta.json'), 'utf-8'));
lastCommit = meta.lastCommit || '';
hadEmbeddings = (meta.stats && meta.stats.embeddings > 0);
} catch { /* no meta — treat as stale */ }
hadEmbeddings = meta.stats && meta.stats.embeddings > 0;
} catch {
/* no meta — treat as stale */
}
// If HEAD matches last indexed commit, no reindex needed
if (currentHead && currentHead === lastCommit) return;
const analyzeCmd = `npx gitnexus analyze${hadEmbeddings ? ' --embeddings' : ''}`;
sendHookResponse('PostToolUse',
sendHookResponse(
'PostToolUse',
`GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` +
`Run \`${analyzeCmd}\` to update the knowledge graph.`
`Run \`${analyzeCmd}\` to update the knowledge graph.`,
);
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "gitnexus-shared",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus-shared",
"version": "1.0.0",
"devDependencies": {
"typescript": "^6.0.2"
}
},
"node_modules/typescript": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "gitnexus-shared",
"version": "1.0.0",
"private": true,
"description": "Shared type definitions for GitNexus CLI and web",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc"
},
"files": [
"dist",
"src"
],
"devDependencies": {
"typescript": "^6.0.2"
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Graph type definitions — single source of truth.
*
* Both gitnexus (CLI) and gitnexus-web import from this package.
* Do NOT add Node.js-specific or browser-specific imports here.
*/
import { SupportedLanguages } from '../languages.js';
export type NodeLabel =
| 'Project'
| 'Package'
| 'Module'
| 'Folder'
| 'File'
| 'Class'
| 'Function'
| 'Method'
| 'Variable'
| 'Interface'
| 'Enum'
| 'Decorator'
| 'Import'
| 'Type'
| 'CodeElement'
| 'Community'
| 'Process'
// Multi-language node types
| 'Struct'
| 'Macro'
| 'Typedef'
| 'Union'
| 'Namespace'
| 'Trait'
| 'Impl'
| 'TypeAlias'
| 'Const'
| 'Static'
| 'Property'
| 'Record'
| 'Delegate'
| 'Annotation'
| 'Constructor'
| 'Template'
| 'Section'
| 'Route'
| 'Tool';
export type NodeProperties = {
name: string;
filePath: string;
startLine?: number;
endLine?: number;
language?: SupportedLanguages | string;
isExported?: boolean;
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
// Community
heuristicLabel?: string;
cohesion?: number;
symbolCount?: number;
keywords?: string[];
description?: string;
enrichedBy?: 'heuristic' | 'llm';
// Process
processType?: 'intra_community' | 'cross_community';
stepCount?: number;
communities?: string[];
entryPointId?: string;
terminalId?: string;
entryPointScore?: number;
entryPointReason?: string;
// Method/property
parameterCount?: number;
level?: number;
returnType?: string;
declaredType?: string;
visibility?: string;
isStatic?: boolean;
isReadonly?: boolean;
isAbstract?: boolean;
isFinal?: boolean;
isVirtual?: boolean;
isOverride?: boolean;
isAsync?: boolean;
isPartial?: boolean;
annotations?: string[];
// Route/response
responseKeys?: string[];
errorKeys?: string[];
middleware?: string[];
// Extensible
[key: string]: unknown;
};
export type RelationshipType =
| 'CONTAINS'
| 'CALLS'
| 'INHERITS'
| 'METHOD_OVERRIDES'
| 'METHOD_IMPLEMENTS'
| 'IMPORTS'
| 'USES'
| 'DEFINES'
| 'DECORATES'
| 'IMPLEMENTS'
| 'EXTENDS'
| 'HAS_METHOD'
| 'HAS_PROPERTY'
| 'ACCESSES'
| 'MEMBER_OF'
| 'STEP_IN_PROCESS'
| 'HANDLES_ROUTE'
| 'FETCHES'
| 'HANDLES_TOOL'
| 'ENTRY_POINT_OF'
| 'WRAPS'
| 'QUERIES';
export interface GraphNode {
id: string;
label: NodeLabel;
properties: NodeProperties;
}
export interface GraphRelationship {
id: string;
sourceId: string;
targetId: string;
type: RelationshipType;
confidence: number;
reason: string;
step?: number;
}
+25
View File
@@ -0,0 +1,25 @@
// Graph types
export type {
NodeLabel,
NodeProperties,
RelationshipType,
GraphNode,
GraphRelationship,
} from './graph/types.js';
// Schema constants
export {
NODE_TABLES,
REL_TABLE_NAME,
REL_TYPES,
EMBEDDING_TABLE_NAME,
} from './lbug/schema-constants.js';
export type { NodeTableName, RelType } from './lbug/schema-constants.js';
// Language support
export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';
+148
View File
@@ -0,0 +1,148 @@
/**
* Language Detection — maps file paths to SupportedLanguages enum values.
*
* Shared between CLI (ingestion pipeline) and web (syntax highlighting).
*
* ADDING A NEW LANGUAGE:
* 1. Add enum member to SupportedLanguages in languages.ts
* 2. Add file extensions to EXTENSION_MAP below
* 3. TypeScript will error if you miss either step (exhaustive Record)
*/
import { SupportedLanguages } from './languages.js';
/** Ruby extensionless filenames recognised as Ruby source */
const RUBY_EXTENSIONLESS_FILES = new Set([
'Rakefile',
'Gemfile',
'Guardfile',
'Vagrantfile',
'Brewfile',
]);
/**
* Exhaustive map: every SupportedLanguages member → its file extensions.
*
* If a new language is added to the enum without adding an entry here,
* TypeScript emits a compile error: "Property 'NewLang' is missing in type..."
*/
const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.JavaScript]: ['.js', '.jsx', '.mjs', '.cjs'],
[SupportedLanguages.TypeScript]: ['.ts', '.tsx', '.mts', '.cts'],
[SupportedLanguages.Python]: ['.py'],
[SupportedLanguages.Java]: ['.java'],
[SupportedLanguages.C]: ['.c'],
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
[SupportedLanguages.CSharp]: ['.cs'],
[SupportedLanguages.Go]: ['.go'],
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],
[SupportedLanguages.Rust]: ['.rs'],
[SupportedLanguages.PHP]: ['.php', '.phtml', '.php3', '.php4', '.php5', '.php8'],
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
[SupportedLanguages.Swift]: ['.swift'],
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
const extToLang = new Map<string, SupportedLanguages>();
for (const [lang, exts] of Object.entries(EXTENSION_MAP) as [
SupportedLanguages,
readonly string[],
][]) {
for (const ext of exts) {
extToLang.set(ext, lang);
}
}
/**
* Map file extension to SupportedLanguage enum.
* Returns null if the file extension is not recognized.
*/
export const getLanguageFromFilename = (filename: string): SupportedLanguages | null => {
// Fast path: check the extension map
const lastDot = filename.lastIndexOf('.');
if (lastDot >= 0) {
const ext = filename.slice(lastDot).toLowerCase();
const lang = extToLang.get(ext);
if (lang !== undefined) return lang;
}
// Ruby extensionless files (Rakefile, Gemfile, etc.)
const basename = filename.split('/').pop() || filename;
if (RUBY_EXTENSIONLESS_FILES.has(basename)) {
return SupportedLanguages.Ruby;
}
return null;
};
/**
* Exhaustive map: every SupportedLanguages member → Prism syntax identifier.
*
* If a new language is added to the enum without adding an entry here,
* TypeScript emits a compile error.
*/
const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.JavaScript]: 'javascript',
[SupportedLanguages.TypeScript]: 'typescript',
[SupportedLanguages.Python]: 'python',
[SupportedLanguages.Java]: 'java',
[SupportedLanguages.C]: 'c',
[SupportedLanguages.CPlusPlus]: 'cpp',
[SupportedLanguages.CSharp]: 'csharp',
[SupportedLanguages.Go]: 'go',
[SupportedLanguages.Ruby]: 'ruby',
[SupportedLanguages.Rust]: 'rust',
[SupportedLanguages.PHP]: 'php',
[SupportedLanguages.Kotlin]: 'kotlin',
[SupportedLanguages.Swift]: 'swift',
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */
const AUXILIARY_SYNTAX_MAP: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
md: 'markdown',
mdx: 'markdown',
html: 'markup',
htm: 'markup',
erb: 'markup',
xml: 'markup',
css: 'css',
scss: 'css',
sass: 'css',
sh: 'bash',
bash: 'bash',
zsh: 'bash',
sql: 'sql',
toml: 'toml',
ini: 'ini',
dockerfile: 'docker',
};
/** Extensionless filenames → Prism-compatible syntax identifiers */
const AUXILIARY_BASENAME_MAP: Record<string, string> = {
Makefile: 'makefile',
Dockerfile: 'docker',
};
/**
* Map file path to a Prism-compatible syntax highlight language string.
* Covers all SupportedLanguages (code files) plus common non-code formats.
* Returns 'text' for unrecognised files.
*/
export const getSyntaxLanguageFromFilename = (filePath: string): string => {
const lang = getLanguageFromFilename(filePath);
if (lang) return SYNTAX_MAP[lang];
const ext = filePath.split('.').pop()?.toLowerCase();
if (ext && ext in AUXILIARY_SYNTAX_MAP) return AUXILIARY_SYNTAX_MAP[ext];
const basename = filePath.split('/').pop() || '';
if (basename in AUXILIARY_BASENAME_MAP) return AUXILIARY_BASENAME_MAP[basename];
return 'text';
};
+25
View File
@@ -0,0 +1,25 @@
/**
* Supported language enum — single source of truth.
*
* Both CLI and web use this to identify which language a file/node belongs to.
* The CLI uses it throughout the ingestion pipeline; the web uses it for display.
*/
export enum SupportedLanguages {
JavaScript = 'javascript',
TypeScript = 'typescript',
Python = 'python',
Java = 'java',
C = 'c',
CPlusPlus = 'cpp',
CSharp = 'csharp',
Go = 'go',
Ruby = 'ruby',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
Swift = 'swift',
Dart = 'dart',
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
}
@@ -0,0 +1,73 @@
/**
* LadybugDB schema constants — single source of truth.
*
* NODE_TABLES and REL_TYPES define what the knowledge graph can contain.
* Both CLI and web must agree on these for data compatibility.
*
* Full DDL schemas remain in each package's own schema.ts because
* the CLI uses native LadybugDB and the web uses WASM.
*/
export const NODE_TABLES = [
'File',
'Folder',
'Function',
'Class',
'Interface',
'Method',
'CodeElement',
'Community',
'Process',
'Section',
'Struct',
'Enum',
'Macro',
'Typedef',
'Union',
'Namespace',
'Trait',
'Impl',
'TypeAlias',
'Const',
'Static',
'Property',
'Record',
'Delegate',
'Annotation',
'Constructor',
'Template',
'Module',
'Route',
'Tool',
] as const;
export type NodeTableName = (typeof NODE_TABLES)[number];
export const REL_TABLE_NAME = 'CodeRelation';
export const REL_TYPES = [
'CONTAINS',
'DEFINES',
'IMPORTS',
'CALLS',
'EXTENDS',
'IMPLEMENTS',
'HAS_METHOD',
'HAS_PROPERTY',
'ACCESSES',
'METHOD_OVERRIDES',
'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated
'METHOD_IMPLEMENTS',
'MEMBER_OF',
'STEP_IN_PROCESS',
'HANDLES_ROUTE',
'FETCHES',
'HANDLES_TOOL',
'ENTRY_POINT_OF',
'WRAPS',
'QUERIES',
] as const;
export type RelType = (typeof REL_TYPES)[number];
export const EMBEDDING_TABLE_NAME = 'CodeEmbedding';
+23
View File
@@ -0,0 +1,23 @@
/**
* MRO (Method Resolution Order) strategy — shared between CLI and any
* future consumer that reasons about multiple-inheritance semantics.
*
* Lives in `gitnexus-shared` so the low-level resolution module
* (`core/ingestion/model/resolve.ts`) does not need to import from
* `languages/` — keeping the `model/` layer free of language-registry
* coupling.
*
* Strategy semantics:
* - `first-wins`: BFS ancestor walk, first match wins (default).
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
* - `c3`: C3-linearized ancestor order, first match wins (Python).
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
* interface-default ambiguity is handled at graph level.
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
*/
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
+29
View File
@@ -0,0 +1,29 @@
/**
* Pipeline progress types — shared between CLI and web.
*/
export type PipelinePhase =
| 'idle'
| 'extracting'
| 'structure'
| 'parsing'
| 'imports'
| 'calls'
| 'heritage'
| 'communities'
| 'processes'
| 'enriching'
| 'complete'
| 'error';
export interface PipelineProgress {
phase: PipelinePhase;
percent: number;
message: string;
detail?: string;
stats?: {
filesProcessed: number;
totalFiles: number;
nodesCreated: number;
};
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"composite": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
-104
View File
@@ -1,104 +0,0 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
/**
* CORS Proxy for isomorphic-git
*
* isomorphic-git calls: /api/proxy?url=https://github.com/...
*/
export default async function handler(req: VercelRequest, res: VercelResponse) {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Git-Protocol, Accept');
res.status(200).end();
return;
}
// Get URL from query parameter
const { url } = req.query;
if (!url || typeof url !== 'string') {
res.status(400).json({ error: 'Missing url query parameter' });
return;
}
// Only allow GitHub URLs for security
const allowedHosts = ['github.com', 'raw.githubusercontent.com'];
let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
res.status(400).json({ error: 'Invalid URL' });
return;
}
if (!allowedHosts.some(host => parsedUrl.hostname.endsWith(host))) {
res.status(403).json({ error: 'Only GitHub URLs are allowed' });
return;
}
try {
const headers: Record<string, string> = {
'User-Agent': 'git/isomorphic-git',
};
// Forward relevant headers
if (req.headers.authorization) {
headers['Authorization'] = req.headers.authorization as string;
}
if (req.headers['content-type']) {
headers['Content-Type'] = req.headers['content-type'] as string;
}
if (req.headers['git-protocol']) {
headers['Git-Protocol'] = req.headers['git-protocol'] as string;
}
if (req.headers.accept) {
headers['Accept'] = req.headers.accept as string;
}
// Get request body for POST requests
let body: Buffer | undefined;
if (req.method === 'POST') {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
body = Buffer.concat(chunks);
}
const response = await fetch(url, {
method: req.method || 'GET',
headers,
body: body ? new Uint8Array(body) : undefined,
});
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Expose-Headers', '*');
// Forward response headers (except ones that cause issues)
const skipHeaders = [
'content-encoding',
'transfer-encoding',
'connection',
'www-authenticate', // IMPORTANT: Strip this to prevent browser's native auth popup!
];
response.headers.forEach((value, key) => {
if (!skipHeaders.includes(key.toLowerCase())) {
res.setHeader(key, value);
}
});
res.status(response.status);
const buffer = await response.arrayBuffer();
res.send(Buffer.from(buffer));
} catch (error) {
console.error('Proxy error:', error);
res.status(500).json({ error: 'Proxy request failed', details: String(error) });
}
}
+10 -4
View File
@@ -1,4 +1,4 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { test, expect } from '@playwright/test';
/**
* Debug harnesses for investigating specific UI issues.
@@ -9,7 +9,7 @@ const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const debugTest = process.env.DEBUG_E2E ? test : test.skip;
async function connectToServer(page: import('@playwright/test').Page) {
page.on('console', msg => {
page.on('console', (msg) => {
if (msg.type() === 'error') console.log(`[error] ${msg.text()}`);
});
@@ -86,7 +86,10 @@ debugTest('debug: process view Reset View button', async ({ page }, testInfo) =>
const transformAfterReset = await diagramDiv.getAttribute('style');
console.log('Transform AFTER reset:', transformAfterReset);
await page.screenshot({ path: testInfo.outputPath('debug-modal-after-reset.png'), fullPage: true });
await page.screenshot({
path: testInfo.outputPath('debug-modal-after-reset.png'),
fullPage: true,
});
// Verify transform actually changed back
expect(transformAfterZoom).not.toBe(transformBefore);
@@ -123,5 +126,8 @@ debugTest('debug: lightbulb clears node selection dimming', async ({ page }, tes
// Click it again to toggle back on
await lightbulbBtn.click();
await page.waitForTimeout(500);
await page.screenshot({ path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'), fullPage: true });
await page.screenshot({
path: testInfo.outputPath('debug-after-lightbulb-toggle-back.png'),
fullPage: true,
});
});
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for heartbeat disconnect/reconnect behavior.
*
* Verifies the key regression: when the heartbeat fails, the UI shows a
* "reconnecting" banner instead of resetting to the onboarding screen.
*
* Strategy: block /api/heartbeat via route interception BEFORE loading the
* graph. The heartbeat EventSource can never connect, so onReconnecting
* fires on the first retry attempt. This reliably tests the banner behavior
* without depending on setOffline timing (which varies across CI environments).
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Heartbeat Reconnect', () => {
test('shows reconnecting banner instead of onboarding reset when heartbeat is unavailable', async ({
page,
}) => {
// Block the heartbeat BEFORE navigating — the EventSource will fail
// immediately on every connection attempt, triggering onReconnecting.
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
// Load the app and connect to a repo (all other endpoints work normally)
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
// Wait for graph to load (heartbeat is blocked, but graph loads fine)
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// The reconnecting banner should appear (heartbeat is failing)
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// The graph canvas should STILL be visible — NOT reset to onboarding
await expect(page.locator('canvas').first()).toBeVisible();
});
test('banner clears when heartbeat becomes available', async ({ page }) => {
// Start with heartbeat blocked
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// auto-connect may skip the landing screen
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Verify banner appears
const banner = page.getByText('Server connection lost');
await expect(banner).toBeVisible({ timeout: 15_000 });
// Unblock heartbeat — the real server is running, so reconnect will succeed
await page.unroute('**/api/heartbeat');
// Banner should disappear as heartbeat reconnects
await expect(banner).not.toBeVisible({ timeout: 30_000 });
// Graph should still be there
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
});
});
+3 -3
View File
@@ -12,16 +12,16 @@ import { test } from '@playwright/test';
*/
test.skip(
!!process.env.CI || process.env.PWDEBUG !== '1',
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0'
'Manual recording requires --headed and PWDEBUG=1. Run: PWDEBUG=1 npx playwright test e2e/manual-record.spec.ts --headed --timeout=0',
);
test('manual recording session', async ({ page }) => {
page.on('console', msg => {
page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') {
console.log(`[${msg.type()}] ${msg.text()}`);
}
});
page.on('pageerror', err => console.log(`[crash] ${err.message}`));
page.on('pageerror', (err) => console.log(`[crash] ${err.message}`));
await page.goto('http://localhost:5173');
await page.pause();
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for multi-repo scoping and URL persistence.
*
* Verifies that:
* - Connecting via ?server= loads data and sets ?project= in the URL
* - The repo name appears in the UI after connecting
* - F5 with ?server=&project= reconnects to the correct repo
*
* Runs against the single indexed repo in CI — validates the plumbing
* works end-to-end even with one repo.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
// Still need to fetch the repo name for assertions
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
test.describe('Multi-Repo Scoping', () => {
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
// Navigate with ?server= param (the bookmarkable shortcut)
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// Wait for graph to load
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should now contain ?project= with the repo name
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).toBe(firstRepoName);
});
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL should still have ?server=
const url = new URL(page.url());
expect(url.searchParams.get('server')).toBeTruthy();
// F5 should reconnect (not show onboarding)
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
});
test('node count in status bar matches backend data', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// Fetch expected node count from backend
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
const repoInfo = await res.json();
const expectedNodes = repoInfo.stats?.nodes;
if (expectedNodes) {
// Status bar shows node count — use the status-ready area to avoid
// matching multiple elements (file tree, header may also show counts)
const statusBar = page.locator('footer');
const nodeText = statusBar.getByText(/\d+ nodes/).first();
await expect(nodeText).toBeVisible({ timeout: 10_000 });
const text = await nodeText.textContent();
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
expect(displayedNodes).toBeGreaterThan(0);
}
});
});
+300
View File
@@ -0,0 +1,300 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for the onboarding and analysis user flows.
*
* These tests cover:
* - Flow 1: OnboardingGuide shown when no server is running
* - Flow 2: Analyze form when server has zero repos
* - Flow 3: Auto-connect when server has repos
* - Flow 4: Repo dropdown in exploring view
*
* Most tests mock the backend at the network level so they don't
* require a live gitnexus server.
*/
const BACKEND_URL = 'http://localhost:4747';
async function enterExploringView(page: import('@playwright/test').Page) {
await page.goto('/');
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
try {
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
}
// ── Flow 1: Onboarding (no server running) ─────────────────────────────────
test.describe('Flow 1: Onboarding — no server', () => {
test('shows OnboardingGuide when backend is unreachable', async ({ page }, testInfo) => {
// Block all requests to the backend so the probe fails
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Wait for initial probe to complete and onboarding to appear
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: testInfo.outputPath('onboarding-visible.png') });
});
test('shows step-by-step instructions', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Step 1 is active (done once polling starts)
await expect(page.getByText('Copy the command')).toBeAttached({ timeout: 10_000 });
// Step 2 title changes to "Waiting for server to start" once polling begins
await expect(page.getByText('Waiting for server to start')).toBeAttached({ timeout: 10_000 });
// Step 3 is always rendered
await expect(page.getByText('Auto-connects and opens the graph')).toBeAttached({
timeout: 5_000,
});
});
test('shows terminal window with command', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Should show either dev or prod command in a terminal block
const terminal = page.locator('code');
await expect(terminal.first()).toBeVisible({ timeout: 10_000 });
// The $ prompt should be present
await expect(page.getByText('$')).toBeVisible();
});
test('shows polling indicator', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
// Polling starts after initial probe fails
await expect(page.getByText('Listening for server')).toBeVisible({ timeout: 10_000 });
});
test('shows Node.js version requirement', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
await expect(page.getByText(/Node\.js.*\d+/)).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Port 4747')).toBeVisible();
});
test('copy button has accessible label', async ({ page }) => {
await page.route(`${BACKEND_URL}/**`, (route) => route.abort('connectionrefused'));
await page.goto('/');
await expect(page.getByText('Copy the command')).toBeVisible({ timeout: 10_000 });
const copyBtn = page.getByLabel('Copy to clipboard').first();
await expect(copyBtn).toBeVisible();
});
});
// ── Flow 2: Server detected → success → auto-connect ──────────────────────
test.describe('Flow 2: Server detected — auto-connect', () => {
test('shows success card when server becomes reachable', async ({ page }, testInfo) => {
// Start with server unreachable
let blockBackend = true;
await page.route(`${BACKEND_URL}/**`, (route) => {
if (blockBackend) return route.abort('connectionrefused');
// Let it through to the real handler below
return route.fallback();
});
// Mock the backend responses for when we "start" the server
await page.route(`${BACKEND_URL}/api/repos`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({ json: [{ name: 'test-repo', path: '/tmp/test' }] });
});
await page.route(`${BACKEND_URL}/api/repo`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({
json: { name: 'test-repo', path: '/tmp/test', repoPath: '/tmp/test' },
});
});
await page.route(`${BACKEND_URL}/api/graph**`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
await route.fulfill({ json: { nodes: [], relationships: [] } });
});
await page.route(`${BACKEND_URL}/api/heartbeat`, async (route) => {
if (blockBackend) return route.abort('connectionrefused');
// SSE response
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: ':ok\n\n',
});
});
await page.goto('/');
// Verify onboarding is shown first
await expect(page.getByText('Start your local server')).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: testInfo.outputPath('before-server-start.png') });
// "Start" the server by unblocking requests
blockBackend = false;
// Wait for success card
await expect(page.getByText('Server Connected')).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('success-card.png') });
});
test('transitions to analyze phase when server has zero repos', async ({ page }, testInfo) => {
// Mock server with zero repos — repos endpoint returns empty array
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
await page.route(`${BACKEND_URL}/api/info`, (route) =>
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
await page.goto('/');
// Should transition: onboarding → success → analyze (zero repos)
// The analyze form tabs should be visible
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('analyze-empty-state.png') });
});
});
// ── Flow 3: Analyze form ───────────────────────────────────────────────────
test.describe('Flow 3: Analyze form', () => {
test.beforeEach(async ({ page }) => {
// Mock server with zero repos to show the analyze form
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
await page.route(`${BACKEND_URL}/api/info`, (route) =>
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
});
test('GitHub URL tab validates input', async ({ page }, testInfo) => {
await page.goto('/');
// Wait for analyze form (transition: onboarding → success → analyze)
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
// Type an invalid URL
const input = page.locator('input[type="url"]');
await input.fill('not-a-url');
// Analyze button should be visible but disabled
const analyzeBtn = page.getByRole('button', { name: /Analyze Repository/ });
await expect(analyzeBtn).toBeVisible();
// Type a valid GitHub URL
await input.fill('https://github.com/anthropics/courses');
await page.screenshot({ path: testInfo.outputPath('valid-github-url.png') });
});
test('Local Folder tab shows browse button', async ({ page }, testInfo) => {
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
// Switch to Local Folder tab
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Browse button should be visible
await expect(page.getByText('Browse for folder')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
});
test('switching tabs clears input', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('tab', { name: 'GitHub URL' })).toBeVisible({ timeout: 20_000 });
// Type in GitHub URL
const urlInput = page.locator('input[type="url"]');
await urlInput.fill('https://github.com/test/repo');
// Switch to Local Folder
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Switch back to GitHub URL — input should be empty
await page.getByRole('tab', { name: 'GitHub URL' }).click();
const newUrlInput = page.locator('input[type="url"]');
await expect(newUrlInput).toHaveValue('');
});
});
// ── Flow 4: Repo dropdown (requires running server) ────────────────────────
test.describe('Flow 4: Repo dropdown in exploring view', () => {
const SKIP_MSG = 'Requires running gitnexus server with indexed repos';
test.beforeAll(async () => {
if (process.env.E2E) return;
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
if (!res.ok) {
test.skip(true, SKIP_MSG);
return;
}
const repos = await res.json();
if (!repos.length) {
test.skip(true, 'Server has no indexed repos');
return;
}
} catch {
test.skip(true, SKIP_MSG);
}
});
test('project badge opens repo dropdown', async ({ page }, testInfo) => {
await enterExploringView(page);
await page.screenshot({ path: testInfo.outputPath('exploring-loaded.png') });
// Click the project badge (has a chevron)
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Repo dropdown should be visible
await expect(page.getByText('Repositories')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('Analyze a new repository')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('repo-dropdown-open.png') });
});
test('analyze option opens inline form', async ({ page }, testInfo) => {
await enterExploringView(page);
// Open repo dropdown
const badge = page
.locator('header button')
.filter({ has: page.locator('svg') })
.first();
await badge.click();
// Click "Analyze a new repository..."
await page.getByText('Analyze a new repository').click();
// Should show the analyze form inline
await expect(page.getByText('GitHub URL')).toBeVisible({ timeout: 5_000 });
await expect(page.getByText('Local Folder')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('inline-analyze-form.png') });
});
});
+168
View File
@@ -0,0 +1,168 @@
import { test, expect } from '@playwright/test';
/**
* E2E tests for the repo-switching and false-404 fixes.
*
* Most tests use the live backend (same pattern as multi-repo-scoping.spec.ts).
* The 503 hold-queue test uses route interception to simulate a slow analysis.
*/
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
let firstRepoName: string;
test.beforeAll(async () => {
if (process.env.E2E) {
try {
const res = await fetch(`${BACKEND_URL}/api/repos`);
const repos = await res.json();
firstRepoName = repos[0]?.name ?? '';
} catch {
firstRepoName = '';
}
return;
}
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available');
return;
}
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available');
return;
}
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos');
return;
}
firstRepoName = repos[0].name;
}
} catch {
test.skip(true, 'servers not available');
}
});
// ── 1. Hold-queue: 503 → descriptive user message ────────────────────────────
test.describe('Hold-queue timeout error', () => {
test('shows descriptive message when /api/repo returns 503', async ({ page }, testInfo) => {
// Intercept only /api/repo (singular) — not /api/repos — to return a 503
// regex: /api/repo followed by end, ?, or # — NOT /api/repos
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({
error: `Repository analysis for "${firstRepoName}" is taking longer than expected. Please try again in a moment.`,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
// UI should show the 503 error message
await expect(page.getByText(/taking longer than expected/i)).toBeVisible({
timeout: 20_000,
});
await page.screenshot({ path: testInfo.outputPath('hold-queue-503.png') });
});
});
// ── 2. ?project= URL persistence ─────────────────────────────────────────────
test.describe('?project= URL persistence', () => {
test('?project= is set in URL after connecting via ?server=', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
// first repo returned by the live backend
if (firstRepoName) expect(project).toBe(firstRepoName);
});
test('?project= is still present after F5 reload', async ({ page }) => {
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// After connect, URL has ?server=&project= — F5 re-uses both params
await page.reload();
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBeTruthy();
});
});
// ── 3. ?project= + ?server= combined auto-connect ────────────────────────────
test.describe('?project= auto-connect', () => {
test('navigating with ?server=&project= connects to the correct repo', async ({
page,
}, testInfo) => {
if (!firstRepoName) test.skip(true, 'no repo name available');
await page.goto(
`/?server=${encodeURIComponent(BACKEND_URL)}&project=${encodeURIComponent(firstRepoName)}`,
);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// ?project= in URL should match what we passed in
const url = new URL(page.url());
expect(url.searchParams.get('project')).toBe(firstRepoName);
await page.screenshot({ path: testInfo.outputPath('project-param-connect.png') });
});
});
// ── 4. Windows path normalization ─────────────────────────────────────────────
test.describe('Windows path normalization', () => {
test('project name uses basename when /api/repo returns a Windows-style repoPath', async ({
page,
}) => {
const repoName = firstRepoName || 'test-repo';
const windowsPath = `C:\\Users\\LENOVO\\.gitnexus\\repos\\${repoName}`;
// Mock /api/repo to return a Windows backslash path while keeping name correct
await page.route(/\/api\/repo(?!s)(\?.*)?$/, (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
// intentionally omit `name` to force path-based extraction
path: windowsPath,
repoPath: windowsPath,
}),
}),
);
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
// URL ?project= must be the short basename, NOT the full Windows path
const url = new URL(page.url());
const project = url.searchParams.get('project');
expect(project).toBeTruthy();
expect(project).not.toContain('\\');
expect(project).not.toContain('LENOVO');
expect(project).toBe(repoName);
});
});
+72 -74
View File
@@ -1,9 +1,10 @@
import { test, expect, type TestInfo } from '@playwright/test';
import { test, expect } from '@playwright/test';
/**
* E2E tests for the GitNexus web UI.
* E2E tests for the GitNexus web UI — exploring view features.
*
* Requires:
* - gitnexus serve running on localhost:4747
* - gitnexus serve running on localhost:4747 with at least one indexed repo
* - gitnexus-web dev server running on localhost:5173
*
* Skipped when servers aren't available (CI without services, etc.).
@@ -12,95 +13,103 @@ import { test, expect, type TestInfo } from '@playwright/test';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
// Skip all tests if the gitnexus server or Vite dev server isn't reachable
test.beforeAll(async () => {
if (process.env.E2E) return; // force-run
if (process.env.E2E) return;
try {
const [backendRes, frontendRes] = await Promise.allSettled([
fetch(`${BACKEND_URL}/api/repos`),
fetch(FRONTEND_URL),
]);
if (backendRes.status === 'rejected' || (backendRes.status === 'fulfilled' && !backendRes.value.ok)) {
if (
backendRes.status === 'rejected' ||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
) {
test.skip(true, 'gitnexus serve not available on :4747');
return;
}
if (frontendRes.status === 'rejected' || (frontendRes.status === 'fulfilled' && !frontendRes.value.ok)) {
if (
frontendRes.status === 'rejected' ||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
) {
test.skip(true, 'Vite dev server not available on :5173');
return;
}
// Check there's at least one indexed repo
if (backendRes.status === 'fulfilled') {
const repos = await backendRes.value.json();
if (!repos.length) {
test.skip(true, 'No indexed repos — run gitnexus analyze first');
return;
}
}
} catch {
test.skip(true, 'servers not available');
}
});
/** Shared helper: connect to the local server and wait for the graph to load */
async function connectAndWaitForGraph(page: import('@playwright/test').Page, testInfo: TestInfo) {
// Signal to the app that we are running under Playwright (used to skip heavy Ladybug loads).
await page.addInitScript(() => {
(window as unknown as { __PLAYWRIGHT_TEST__?: boolean }).__PLAYWRIGHT_TEST__ = true;
});
/**
* Wait for the server-detection flow to complete.
*
* The app auto-detects the server, then either:
* - shows the landing screen when indexed repos exist, or
* - goes straight into analyze onboarding when there are zero repos.
*
* For these tests we require at least one indexed repo, so pick the first
* landing card when present and then wait for the exploring view.
*/
async function waitForGraphLoaded(page: import('@playwright/test').Page) {
await page.goto('/');
// Wait for the app to fully render before interacting
const serverTab = page.getByRole('button', { name: 'Server' });
await expect(serverTab).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('step-1-landing.png') });
const landingCards = page.locator('[data-testid="landing-repo-card"]');
const preferredLandingCard = landingCards
.filter({ hasText: /GitNexus|local-integration/ })
.first();
try {
await landingCards.first().waitFor({ state: 'visible', timeout: 15_000 });
const landingCard =
(await preferredLandingCard.count()) > 0 ? preferredLandingCard : landingCards.first();
await landingCard.click();
} catch {
// Landing screen may not appear (e.g. ?server auto-connect)
}
// Click "Server" tab and wait for the input to appear
await serverTab.click();
const serverInput = page.locator('input[name="server-url-input"]');
await expect(serverInput).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('step-2-server-tab.png') });
await serverInput.fill(BACKEND_URL);
await page.screenshot({ path: testInfo.outputPath('step-3-url-filled.png') });
await page.getByRole('button', { name: /Connect/ }).click();
// Wait for graph to load — status bar shows "Ready"
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/\d+ nodes/).first()).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('step-4-graph-loaded.png') });
const statusBar = page.getByRole('contentinfo');
await expect(statusBar.getByText('Ready', { exact: true })).toBeVisible({ timeout: 45_000 });
await expect(statusBar).toContainText(/nodes/, {
timeout: 20_000,
});
}
test.describe('Server Connection & Graph Loading', () => {
test('connects to server and loads graph', async ({ page }, testInfo) => {
await connectAndWaitForGraph(page, testInfo);
await page.screenshot({ path: testInfo.outputPath('graph-loaded.png'), fullPage: true });
test('selects a repo from landing and loads graph', async ({ page }) => {
await waitForGraphLoaded(page);
});
});
test.describe('Nexus AI', () => {
test('panel opens and agent initializes without error', async ({ page }, testInfo) => {
await connectAndWaitForGraph(page, testInfo);
test('panel opens and agent initializes without error', async ({ page }) => {
await waitForGraphLoaded(page);
// Click Nexus AI button to open the panel
await page.getByRole('button', { name: 'Nexus AI' }).click();
// Should see the Nexus AI tab content
await expect(page.getByText('Ask me anything')).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('nexus-ai-panel.png'), fullPage: true });
// "Database not ready" should NOT be visible
const errorBanner = page.getByText('Database not ready');
expect(await errorBanner.isVisible().catch(() => false)).toBe(false);
});
});
test.describe('Processes Panel', () => {
test('shows process list and View button works', async ({ page }, testInfo) => {
await connectAndWaitForGraph(page, testInfo);
test('shows process list and View button works', async ({ page }) => {
await waitForGraphLoaded(page);
// Open Nexus AI panel, switch to Processes tab
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
// Should show process count — wait for data-testid instead of fixed timeout
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: testInfo.outputPath('processes-panel.png'), fullPage: true });
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
// Hover first process item to reveal View button, then click it
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
await processRow.hover();
@@ -108,21 +117,18 @@ test.describe('Processes Panel', () => {
const viewBtn = processRow.locator('[data-testid="process-view-button"]');
await viewBtn.waitFor({ state: 'visible', timeout: 5_000 });
await viewBtn.click();
// Wait for modal to appear
await expect(page.locator('[data-testid="process-modal"]')).toBeVisible({ timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('process-view-clicked.png'), fullPage: true });
});
test('lightbulb highlights nodes in graph', async ({ page }, testInfo) => {
await connectAndWaitForGraph(page, testInfo);
test('lightbulb highlights nodes in graph', async ({ page }) => {
await waitForGraphLoaded(page);
await page.getByRole('button', { name: 'Nexus AI' }).click();
await page.getByText('Processes').click();
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-testid="process-list-loaded"]')).toBeVisible({
timeout: 15_000,
});
await page.screenshot({ path: testInfo.outputPath('before-highlight.png'), fullPage: true });
// Hover first process to reveal lightbulb
const processRow = page.locator('[data-testid="process-row"]').first();
await expect(processRow).toBeVisible({ timeout: 10_000 });
await processRow.hover();
@@ -130,36 +136,28 @@ test.describe('Processes Panel', () => {
const lightbulb = processRow.locator('[data-testid="process-highlight-button"]');
await lightbulb.waitFor({ state: 'visible', timeout: 5_000 });
await lightbulb.click();
// Wait for highlight to apply — the process row gets amber styling when focused
await expect(processRow).toHaveClass(/bg-amber-950/, { timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('after-highlight.png'), fullPage: true });
});
});
test.describe('Turn Off All Highlights', () => {
test('selecting a node dims others, button clears it', async ({ page }, testInfo) => {
await connectAndWaitForGraph(page, testInfo);
test('selecting a node dims others, button clears it', async ({ page }) => {
await waitForGraphLoaded(page);
// Wait for graph to fully render by checking for canvas element
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 10_000 });
await page.screenshot({ path: testInfo.outputPath('before-select.png'), fullPage: true });
// Click a file in the file tree to select a node
const fileItem = page.getByText('package.json').first();
await expect(fileItem).toBeVisible({ timeout: 10_000 });
await fileItem.click();
// Wait for highlight toggle to show "Turn off" (indicates highlights are active)
const highlightToggle = page.locator('[data-testid="ai-highlights-toggle"]');
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', { timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('node-selected.png'), fullPage: true });
await expect(highlightToggle).toHaveAttribute('title', 'Turn off all highlights', {
timeout: 5_000,
});
// Click the toggle to clear all highlights
await highlightToggle.click();
// Verify highlights are now off — button title changes to "Turn on"
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', { timeout: 5_000 });
await page.screenshot({ path: testInfo.outputPath('highlights-cleared.png'), fullPage: true });
await expect(highlightToggle).toHaveAttribute('title', 'Turn on AI highlights', {
timeout: 5_000,
});
});
});
+6 -3
View File
@@ -4,9 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitNexus</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
+54 -1835
View File
File diff suppressed because it is too large Load Diff
+1 -13
View File
@@ -18,9 +18,7 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
"@isomorphic-git/lightning-fs": "^4.6.2",
"@ladybugdb/wasm-core": "^0.15.1",
"gitnexus-shared": "file:../gitnexus-shared",
"@langchain/anthropic": "^1.3.10",
"@langchain/core": "^1.1.15",
"@langchain/google-genai": "^2.1.10",
@@ -30,8 +28,6 @@
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"buffer": "^6.0.3",
"comlink": "^4.4.2",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
"graphology": "^0.26.0",
@@ -40,13 +36,10 @@
"graphology-layout-forceatlas2": "^0.10.1",
"graphology-layout-noverlap": "^0.4.2",
"graphology-utils": "^2.3.0",
"isomorphic-git": "^1.36.1",
"jszip": "^3.10.1",
"langchain": "^1.2.10",
"lru-cache": "^11.2.4",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.2",
"minisearch": "^7.2.0",
"mnemonist": "^0.39.0",
"pandemonium": "^2.4.0",
"react": "^18.3.1",
@@ -58,9 +51,6 @@
"sigma": "^3.0.2",
"tailwindcss": "^4.1.18",
"uuid": "^13.0.0",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0",
"web-tree-sitter": "^0.20.8",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -70,7 +60,6 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/jszip": "^3.4.0",
"@types/node": "^24.10.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
@@ -82,7 +71,6 @@
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
"vite": "^5.2.0",
"vite-plugin-static-copy": "^3.1.4",
"vitest": "^3.2.4",
"wait-on": "^8.0.5"
}
+1 -4
View File
@@ -40,9 +40,6 @@ export default defineConfig({
use: { browserName: 'chromium' },
},
],
reporter: [
['list'],
['html', { open: 'never', outputFolder: 'playwright-report' }],
],
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
outputDir: 'test-results',
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+193 -176
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppStateProvider, useAppState } from './hooks/useAppState';
import { DropZone } from './components/DropZone';
import { LoadingOverlay } from './components/LoadingOverlay';
@@ -9,10 +9,17 @@ import { SettingsPanel } from './components/SettingsPanel';
import { StatusBar } from './components/StatusBar';
import { FileTreePanel } from './components/FileTreePanel';
import { CodeReferencesPanel } from './components/CodeReferencesPanel';
import { FileEntry } from './services/zip';
import { getActiveProviderConfig } from './core/llm/settings-service';
import { createKnowledgeGraph } from './core/graph/graph';
import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection';
import {
connectToServer,
fetchRepos,
normalizeServerUrl,
connectHeartbeat,
BackendError,
type ConnectResult,
type BackendRepo,
} from './services/backend-client';
import { ERROR_RESET_DELAY_MS } from './config/ui-constants';
const AppContent = () => {
@@ -20,19 +27,15 @@ const AppContent = () => {
viewMode,
setViewMode,
setGraph,
setFileContents,
setProgress,
setProjectName,
progress,
isRightPanelOpen,
runPipeline,
runPipelineFromFiles,
isSettingsPanelOpen,
setSettingsPanelOpen,
refreshLLMSettings,
initializeAgent,
startEmbeddingsWithFallback,
embeddingStatus,
codeReferences,
selectedNode,
isCodePanelOpen,
@@ -41,185 +44,143 @@ const AppContent = () => {
availableRepos,
setAvailableRepos,
switchRepo,
loadServerGraph,
setCurrentRepo,
} = useAppState();
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
const [serverDisconnected, setServerDisconnected] = useState(false);
const handleFileSelect = useCallback(async (file: File) => {
const projectName = file.name.replace('.zip', '');
setProjectName(projectName);
setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to extract files' });
setViewMode('loading');
const handleServerConnect = useCallback(
async (result: ConnectResult): Promise<void> => {
// Use the canonical repo name from the server response so all subsequent
// backend calls (queries, search, grep, readFile) scope to this repo.
const repoName = result.repoInfo.name;
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
// Normalize both Windows (\) and Unix (/) path separators before splitting
const projectName =
result.repoInfo.name ||
(repoPath || '').replace(/\\/g, '/').split('/').filter(Boolean).pop() ||
'server-project';
setProjectName(projectName);
setCurrentRepo(projectName);
try {
const result = await runPipeline(file, (progress) => {
setProgress(progress);
});
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
setGraph(result.graph);
setFileContents(result.fileContents);
// Persist the active project in the URL for bookmarkability and F5 refresh resilience
const urlObj = new URL(window.location.href);
urlObj.searchParams.set('project', projectName);
window.history.replaceState(null, '', urlObj.toString());
// Transition directly to exploring view
setViewMode('exploring');
// Initialize (or re-initialize) the agent AFTER a repo loads so it captures
// the current codebase context (file contents + graph tools) in the worker.
if (getActiveProviderConfig()) {
initializeAgent(projectName);
}
// Auto-start embeddings pipeline in background
// Uses WebGPU if available, falls back to WASM
startEmbeddingsWithFallback();
} catch (error) {
console.error('Pipeline error:', error);
setProgress({
phase: 'error',
percent: 0,
message: 'Error processing file',
detail: error instanceof Error ? error.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipeline, startEmbeddingsWithFallback, initializeAgent]);
const handleGitClone = useCallback(async (files: FileEntry[], repoName?: string) => {
let projectName = repoName;
if (!projectName) {
const firstPath = files[0]?.path || 'repository';
projectName = firstPath.split('/')[0].replace(/-\d+$/, '') || 'repository';
}
setProjectName(projectName);
setProgress({ phase: 'extracting', percent: 0, message: 'Starting...', detail: 'Preparing to process files' });
setViewMode('loading');
try {
const result = await runPipelineFromFiles(files, (progress) => {
setProgress(progress);
});
setGraph(result.graph);
setFileContents(result.fileContents);
setViewMode('exploring');
if (getActiveProviderConfig()) {
initializeAgent(projectName);
}
startEmbeddingsWithFallback();
} catch (error) {
console.error('Pipeline error:', error);
setProgress({
phase: 'error',
percent: 0,
message: 'Error processing repository',
detail: error instanceof Error ? error.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
}
}, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddingsWithFallback, initializeAgent]);
const handleServerConnect = useCallback((result: ConnectToServerResult): Promise<void> => {
// Extract project name from repoPath
const repoPath = result.repoInfo.repoPath;
const parts = repoPath.split('/').filter(p => p && !p.startsWith('.'));
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
setProjectName(projectName);
// Build KnowledgeGraph from server data for visualization
const graph = createKnowledgeGraph();
for (const node of result.nodes) {
graph.addNode(node);
}
for (const rel of result.relationships) {
graph.addRelationship(rel);
}
setGraph(graph);
// Set file contents from extracted File node content
const fileMap = new Map<string, string>();
for (const [path, content] of Object.entries(result.fileContents)) {
fileMap.set(path, content);
}
setFileContents(fileMap);
// Transition directly to exploring view
setViewMode('exploring');
// Load graph into LadybugDB (in-browser WASM database) for Nexus AI queries,
// then initialize agent once the database is ready
const loadGraphPromise = loadServerGraph(result.nodes, result.relationships, result.fileContents)
.then(() => {
// Initialize agent with backend queries, then start embeddings
try {
if (getActiveProviderConfig()) {
return initializeAgent(projectName);
await initializeAgent(projectName);
}
})
.then(() => {
startEmbeddingsWithFallback();
})
.catch((err) => {
console.warn('Failed to load graph into LadybugDB:', err);
// Agent won't work but graph visualization still does
});
} catch (err) {
console.warn('Failed to initialize agent:', err);
}
},
[
setViewMode,
setGraph,
setProjectName,
setCurrentRepo,
initializeAgent,
startEmbeddingsWithFallback,
],
);
return loadGraphPromise;
}, [setViewMode, setGraph, setFileContents, setProjectName, loadServerGraph, initializeAgent, startEmbeddingsWithFallback]);
// Auto-connect when ?server query param is present (bookmarkable shortcut)
// Auto-connect when ?server or ?project query param is present (bookmarkable shortcut)
const autoConnectRan = useRef(false);
useEffect(() => {
if (autoConnectRan.current) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('server')) return;
const serverUrlParam = params.get('server');
const projectParam = params.get('project');
if (!serverUrlParam && !projectParam) return;
autoConnectRan.current = true;
// Clean the URL so a refresh won't re-trigger
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState(null, '', cleanUrl);
setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' });
setProgress({
phase: 'extracting',
percent: 0,
message: 'Connecting to server...',
detail: 'Validating server',
});
setViewMode('loading');
const serverUrl = params.get('server') || window.location.origin;
const serverUrl = serverUrlParam || window.location.origin;
const baseUrl = normalizeServerUrl(serverUrl);
connectToServer(serverUrl, (phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' });
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` });
} else if (phase === 'extracting') {
setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' });
}
}).then(async (result) => {
await handleServerConnect(result);
setProgress(null);
setServerBaseUrl(baseUrl);
fetchRepos(baseUrl)
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
}).catch((err) => {
console.error('Auto-connect failed:', err);
setProgress({
phase: 'error',
percent: 0,
message: 'Failed to connect to server',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
const tryConnect = async () => {
return await connectToServer(
serverUrl,
(phase, downloaded, total) => {
if (phase === 'validating') {
setProgress({
phase: 'extracting',
percent: 5,
message: 'Connecting to server...',
detail: 'Validating server',
});
} else if (phase === 'downloading') {
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
const mb = (downloaded / (1024 * 1024)).toFixed(1);
setProgress({
phase: 'extracting',
percent: pct,
message: 'Downloading graph...',
detail: `${mb} MB downloaded`,
});
} else if (phase === 'extracting') {
setProgress({
phase: 'extracting',
percent: 97,
message: 'Processing...',
detail: 'Extracting file contents',
});
}
},
undefined,
projectParam || undefined,
{ awaitAnalysis: true }, // enable backend hold-queue for repos still being analyzed
);
};
tryConnect()
.then(async (result) => {
await handleServerConnect(result);
setProgress(null);
}, ERROR_RESET_DELAY_MS);
});
setServerBaseUrl(baseUrl);
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
})
.catch((err) => {
console.error('Auto-connect failed:', err);
setProgress({
phase: 'error',
percent: 0,
message: 'Failed to connect to server',
detail: err instanceof Error ? err.message : 'Unknown error',
});
setTimeout(() => {
setViewMode('onboarding');
setProgress(null);
}, ERROR_RESET_DELAY_MS);
});
}, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]);
const handleFocusNode = useCallback((nodeId: string) => {
@@ -233,21 +194,38 @@ const AppContent = () => {
initializeAgent();
}, [refreshLLMSettings, initializeAgent]);
// ── Server heartbeat: detect when server goes down while exploring ────────
// Uses SSE (EventSource) for instant detection — no polling delay.
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
// The heartbeat retries indefinitely with capped backoff and recovers automatically.
useEffect(() => {
if (viewMode !== 'exploring') return;
const cleanup = connectHeartbeat(
() => setServerDisconnected(false),
() => setServerDisconnected(true),
);
return cleanup;
}, [viewMode]);
// Render based on view mode
if (viewMode === 'onboarding') {
return (
<DropZone
onFileSelect={handleFileSelect}
onGitClone={handleGitClone}
onServerConnect={async (result, serverUrl) => {
// Refresh repo list before transitioning so it's ready in the header
const repos = await fetchRepos().catch(() => [] as BackendRepo[]);
setAvailableRepos(repos);
await handleServerConnect(result);
setProgress(null);
if (serverUrl) {
const baseUrl = normalizeServerUrl(serverUrl);
setServerBaseUrl(baseUrl);
fetchRepos(baseUrl)
.then((repos) => setAvailableRepos(repos))
.catch((e) => console.warn('Failed to fetch repo list:', e));
const base = normalizeServerUrl(serverUrl);
setServerBaseUrl(base);
// Add ?server= so F5 reconnects to this server
const url = new URL(window.location.href);
url.searchParams.set('server', base);
window.history.replaceState(null, '', url.toString());
}
}}
/>
@@ -260,20 +238,54 @@ const AppContent = () => {
// Exploring view
return (
<div className="flex flex-col h-screen bg-void overflow-hidden">
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
<div className="flex h-screen flex-col overflow-hidden bg-void">
<Header
onFocusNode={handleFocusNode}
availableRepos={availableRepos}
onSwitchRepo={switchRepo}
onReposChanged={(repos) => setAvailableRepos(repos)}
onAnalyzeComplete={async (repoName) => {
// A new repo was just indexed via the header dropdown.
// Refresh the repo list, connect to the new repo, and switch to it.
// Retry once after 1s if the repo isn't found yet (server may still
// be reinitializing after the worker completed).
const url = serverBaseUrl ?? 'http://localhost:4747';
for (let attempt = 0; attempt < 2; attempt++) {
try {
const repos = await fetchRepos();
setAvailableRepos(repos);
const result = await connectToServer(url, undefined, undefined, repoName);
await handleServerConnect(result);
setServerBaseUrl(normalizeServerUrl(url));
setProgress(null);
return;
} catch (err: unknown) {
if (attempt === 0 && err instanceof BackendError && err.status === 404) {
// Server may still be reinitializing — wait and retry
await new Promise((r) => setTimeout(r, 1500));
continue;
}
console.error('Failed to connect after analyze:', err);
fetchRepos()
.then((repos) => setAvailableRepos(repos))
.catch(() => {});
return;
}
}
}}
/>
<main className="flex-1 flex min-h-0">
<main className="flex min-h-0 flex-1">
{/* Left Panel - File Tree */}
<FileTreePanel onFocusNode={handleFocusNode} />
{/* Graph area - takes remaining space */}
<div className="flex-1 relative min-w-0">
<div className="relative min-w-0 flex-1">
<GraphCanvas ref={graphCanvasRef} />
{/* Code References Panel (overlay) - does NOT resize the graph, it overlaps on top */}
{isCodePanelOpen && (codeReferences.length > 0 || !!selectedNode) && (
<div className="absolute inset-y-0 left-0 z-30 pointer-events-auto">
<div className="pointer-events-auto absolute inset-y-0 left-0 z-30">
<CodeReferencesPanel onFocusNode={handleFocusNode} />
</div>
)}
@@ -285,13 +297,18 @@ const AppContent = () => {
<StatusBar />
{serverDisconnected && (
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
Server connection lost — reconnecting&hellip;
</div>
)}
{/* Settings Panel (modal) */}
<SettingsPanel
isOpen={isSettingsPanelOpen}
onClose={() => setSettingsPanelOpen(false)}
onSettingsSaved={handleSettingsSaved}
/>
</div>
);
};
@@ -0,0 +1,70 @@
/**
* AnalyzeOnboarding
*
* The "empty state" card rendered inside DropZone's Crossfade when the server
* is connected but zero repos are indexed. Replaces the generic error message
* with a first-class GitHub URL input flow.
*
* Rendering context:
* DropZone (Crossfade, phase="analyze")
* └─ AnalyzeOnboarding
* └─ RepoAnalyzer (variant="onboarding")
*
* When the analysis job completes, onComplete fires with the repoName, and
* DropZone's handleAutoConnect re-runs (now that repos > 0), transitioning
* the app to the graph explorer.
*/
import { Sparkles, Github } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
interface AnalyzeOnboardingProps {
/** Called when analysis finishes and the repo is ready to load. */
onComplete: (repoName: string) => void;
}
export const AnalyzeOnboarding = ({ onComplete }: AnalyzeOnboardingProps) => {
return (
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */}
<div className="relative mb-6">
<div className="text-center">
{/* Eyebrow */}
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
{/* Icon */}
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Github className="h-7 w-7 text-accent" />
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Analyze your first repository
</h2>
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
Paste a GitHub URL and GitNexus will clone it, parse the code, and build a live
knowledge graph — right in your browser.
</p>
</div>
</div>
{/* Analyzer form */}
<div className="relative">
<RepoAnalyzer variant="onboarding" onComplete={onComplete} />
</div>
{/* Footer hint */}
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
Public repos only &middot; Cloned locally by the server &middot; No data leaves your machine
</p>
</div>
);
};
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react';
import { X } from '@/lib/lucide-icons';
import type { JobProgress as AnalyzeJobProgress } from '../services/backend-client';
interface AnalyzeProgressProps {
progress: AnalyzeJobProgress;
onCancel: () => void;
}
const PHASE_LABELS: Record<string, string> = {
queued: 'Queued',
cloning: 'Cloning repository',
pulling: 'Pulling latest',
extracting: 'Scanning files',
structure: 'Building structure',
parsing: 'Parsing code',
imports: 'Resolving imports',
calls: 'Tracing calls',
heritage: 'Extracting inheritance',
communities: 'Detecting communities',
processes: 'Detecting processes',
complete: 'Pipeline complete',
lbug: 'Loading into database',
fts: 'Creating search indexes',
embeddings: 'Generating embeddings',
done: 'Done',
retrying: 'Retrying after crash',
};
export const AnalyzeProgress = ({ progress, onCancel }: AnalyzeProgressProps) => {
const [startTime] = useState(() => Date.now());
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const timer = setInterval(() => setElapsed(Date.now() - startTime), 1000);
return () => clearInterval(timer);
}, [startTime]);
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`;
};
const label = PHASE_LABELS[progress.phase] || progress.message || progress.phase;
const pct = Math.max(0, Math.min(100, progress.percent));
return (
<div className="space-y-4">
{/* Phase label + elapsed */}
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-text-secondary">{label}</span>
<span className="font-mono text-xs text-text-muted">{formatElapsed(elapsed)}</span>
</div>
{/* Progress bar */}
<div className="h-2 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${pct}%` }}
/>
</div>
{/* Percent + cancel */}
<div className="flex items-center justify-between">
<span className="font-mono text-xs text-text-muted">{pct}%</span>
<button
onClick={onCancel}
className="flex items-center gap-1.5 rounded-lg bg-red-500/10 px-3 py-1.5 text-xs text-red-400 transition-all duration-200 hover:bg-red-500/20"
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
</div>
);
};
@@ -1,88 +0,0 @@
import { Server, ArrowRight } from '@/lib/lucide-icons';
import { BackendRepo } from '../services/backend';
interface BackendRepoSelectorProps {
repos: BackendRepo[];
onSelectRepo: (repoName: string) => void;
backendUrl: string;
isConnected: boolean;
}
export const BackendRepoSelector = ({
repos,
onSelectRepo,
backendUrl,
isConnected,
}: BackendRepoSelectorProps) => {
return (
<div className="p-8 bg-surface border border-border-default rounded-3xl">
{/* Icon */}
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-2xl shadow-glow">
<Server className="w-10 h-10 text-white" />
</div>
{/* Title */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
Local Repositories
</h2>
<p className="text-sm text-text-secondary text-center mb-4">
Select an indexed repository from your local GitNexus server
</p>
{/* Connected status badge */}
{isConnected && (
<div className="flex items-center justify-center gap-2 mb-6">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-xs text-green-400">Connected to {backendUrl}</span>
</div>
)}
{/* Repo list or empty state */}
{repos.length > 0 ? (
<div className="max-h-80 overflow-y-auto space-y-2">
{repos.map((repo) => (
<button
key={repo.name}
onClick={() => onSelectRepo(repo.name)}
className="w-full p-4 bg-elevated border border-border-subtle rounded-xl hover:border-accent/50 hover:bg-hover transition-all text-left group"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-text-primary group-hover:text-accent transition-colors">
{repo.name}
</span>
<ArrowRight className="w-4 h-4 text-text-muted group-hover:text-accent transition-colors" />
</div>
<div className="flex items-center gap-3 text-xs text-text-muted">
{repo.stats?.files != null && <span>{repo.stats.files} files</span>}
{repo.stats?.nodes != null && <span>{repo.stats.nodes} nodes</span>}
{repo.stats?.edges != null && <span>{repo.stats.edges} edges</span>}
</div>
<div className="text-xs text-text-muted mt-1">
Indexed {new Date(repo.indexedAt).toLocaleDateString()}
</div>
</button>
))}
</div>
) : (
<div className="text-center text-text-muted py-8">
<p className="text-sm mb-2">No indexed repositories found</p>
<p className="text-xs">
Run{' '}
<code className="px-1 py-0.5 bg-elevated rounded">gitnexus analyze</code>{' '}
in a repository
</p>
</div>
)}
{/* Bottom hints */}
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
{repos.length} {repos.length === 1 ? 'repo' : 'repos'}
</span>
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
Pre-indexed
</span>
</div>
</div>
);
};
@@ -1,45 +1,26 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, PanelLeftClose, PanelLeft, Trash2, X, Target, FileCode, Sparkles, MousePointerClick } from '@/lib/lucide-icons';
import {
Code,
PanelLeftClose,
PanelLeft,
Trash2,
X,
Target,
FileCode,
Sparkles,
MousePointerClick,
Loader2,
} from '@/lib/lucide-icons';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { useAppState } from '../hooks/useAppState';
import type { GraphNode } from '../core/graph/types';
import { type GraphNode, getSyntaxLanguageFromFilename } from 'gitnexus-shared';
import { NODE_COLORS } from '../lib/constants';
import { readFile, type ReadFileResult } from '../services/backend-client';
/** Map file extension to Prism syntax highlighter language identifier */
const getSyntaxLanguage = (filePath: string | undefined): string => {
if (!filePath) return 'text';
const ext = filePath.split('.').pop()?.toLowerCase();
switch (ext) {
case 'js': case 'jsx': case 'mjs': case 'cjs': return 'javascript';
case 'ts': case 'tsx': case 'mts': case 'cts': return 'typescript';
case 'py': case 'pyw': return 'python';
case 'rb': case 'rake': case 'gemspec': return 'ruby';
case 'java': return 'java';
case 'go': return 'go';
case 'rs': return 'rust';
case 'c': case 'h': return 'c';
case 'cpp': case 'cc': case 'cxx': case 'hpp': case 'hxx': case 'hh': return 'cpp';
case 'cs': return 'csharp';
case 'php': return 'php';
case 'kt': case 'kts': return 'kotlin';
case 'swift': return 'swift';
case 'json': return 'json';
case 'yaml': case 'yml': return 'yaml';
case 'md': case 'mdx': return 'markdown';
case 'html': case 'htm': case 'erb': return 'markup';
case 'css': case 'scss': case 'sass': return 'css';
case 'sh': case 'bash': case 'zsh': return 'bash';
case 'sql': return 'sql';
case 'xml': return 'xml';
default: break;
}
// Handle extensionless Ruby files
const basename = filePath.split('/').pop() || '';
if (['Rakefile', 'Gemfile', 'Guardfile', 'Vagrantfile', 'Brewfile'].includes(basename)) return 'ruby';
if (['Makefile'].includes(basename)) return 'makefile';
if (['Dockerfile'].includes(basename)) return 'docker';
return 'text';
return getSyntaxLanguageFromFilename(filePath);
};
// Match the code theme used elsewhere in the app
@@ -67,18 +48,18 @@ export interface CodeReferencesPanelProps {
export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) => {
const {
graph,
fileContents,
selectedNode,
codeReferences,
removeCodeReference,
clearCodeReferences,
setSelectedNode,
codeReferenceFocus,
projectName,
} = useAppState();
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
return new Map(graph.nodes.map((n) => [n.id, n]));
}, [graph]);
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -116,34 +97,40 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
}
}, [panelWidth]);
const startResize = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startWidth: panelWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const startResize = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startWidth: panelWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const state = resizeRef.current;
if (!state) return;
const delta = ev.clientX - state.startX;
const next = Math.max(420, Math.min(state.startWidth + delta, 900));
setPanelWidth(next);
};
const onMove = (ev: MouseEvent) => {
const state = resizeRef.current;
if (!state) return;
const delta = ev.clientX - state.startX;
const next = Math.max(420, Math.min(state.startWidth + delta, 900));
setPanelWidth(next);
};
const onUp = () => {
resizeRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
const onUp = () => {
resizeRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
}, [panelWidth]);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
},
[panelWidth],
);
const aiReferences = useMemo(() => codeReferences.filter(r => r.source === 'ai'), [codeReferences]);
const aiReferences = useMemo(
() => codeReferences.filter((r) => r.source === 'ai'),
[codeReferences],
);
// When the user clicks a citation badge in chat, focus the corresponding snippet card:
// - expand the panel if collapsed
@@ -157,12 +144,9 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
const { filePath, startLine, endLine } = codeReferenceFocus;
const target =
aiReferences.find(r =>
r.filePath === filePath &&
r.startLine === startLine &&
r.endLine === endLine
) ??
aiReferences.find(r => r.filePath === filePath);
aiReferences.find(
(r) => r.filePath === filePath && r.startLine === startLine && r.endLine === endLine,
) ?? aiReferences.find((r) => r.filePath === filePath);
if (!target) return;
@@ -189,64 +173,139 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
rafIds.push(outerRafId);
return () => {
rafIds.forEach(id => cancelAnimationFrame(id));
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [codeReferenceFocus?.ts, aiReferences]);
}, [codeReferenceFocus, aiReferences]);
const refsWithSnippets = useMemo(() => {
return aiReferences.map((ref) => {
const content = fileContents.get(ref.filePath);
if (!content) {
return { ref, content: null as string | null, start: 0, end: 0, highlightStart: 0, highlightEnd: 0, totalLines: 0 };
}
const lines = content.split('\n');
const totalLines = lines.length;
const startLine = ref.startLine ?? 0;
const endLine = ref.endLine ?? startLine;
const contextBefore = 3;
const contextAfter = 20;
const start = Math.max(0, startLine - contextBefore);
const end = Math.min(totalLines - 1, endLine + contextAfter);
return {
ref,
content: lines.slice(start, end + 1).join('\n'),
start,
end,
highlightStart: Math.max(0, startLine - start),
highlightEnd: Math.max(0, endLine - start),
totalLines,
content: null as string | null,
start: 0,
end: 0,
highlightStart: 0,
highlightEnd: 0,
totalLines: 0,
};
});
}, [aiReferences, fileContents]);
}, [aiReferences]);
const selectedFilePath = selectedNode?.properties?.filePath;
const selectedFileContent = selectedFilePath ? fileContents.get(selectedFilePath) : undefined;
const selectedIsFile = selectedNode?.label === 'File' && !!selectedFilePath;
const showSelectedViewer = !!selectedNode && !!selectedFilePath;
const showCitations = aiReferences.length > 0;
// Fetch file content from the server when a node with a filePath is selected.
// For non-File nodes (functions, classes, etc.), fetch a buffer around the symbol
// instead of the entire file. For File nodes, fetch the whole file.
const CONTEXT_LINES = 50; // lines of context above and below the symbol
const [fileResult, setFileResult] = useState<ReadFileResult | null>(null);
const [isLoadingFile, setIsLoadingFile] = useState(false);
const selectedViewerRef = useRef<HTMLDivElement>(null);
const selectedFileContent = fileResult?.content;
const fileStartLine = fileResult?.startLine ?? 0;
useEffect(() => {
if (!selectedFilePath) {
setFileResult(null);
return;
}
let cancelled = false;
setIsLoadingFile(true);
setFileResult(null);
// Determine read range: full file for File nodes, buffered for symbols
const startLine = selectedNode?.properties?.startLine as number | undefined;
const endLine = selectedNode?.properties?.endLine as number | undefined;
const isWholeFile = selectedIsFile || startLine === undefined;
const options = isWholeFile
? { repo: projectName }
: {
startLine: Math.max(0, startLine - CONTEXT_LINES),
endLine: (endLine ?? startLine) + CONTEXT_LINES,
repo: projectName,
};
readFile(selectedFilePath, { ...options, repo: projectName || undefined })
.then((result) => {
if (!cancelled) {
setFileResult(result);
setIsLoadingFile(false);
}
})
.catch(() => {
if (!cancelled) {
setFileResult(null);
setIsLoadingFile(false);
}
});
return () => {
cancelled = true;
};
}, [
selectedFilePath,
selectedNode?.properties?.startLine,
selectedNode?.properties?.endLine,
selectedIsFile,
projectName,
]);
// Scroll to the selected node's startLine after content loads
useEffect(() => {
if (!selectedFileContent || !selectedNode?.properties?.startLine) return;
const startLine = selectedNode.properties.startLine as number;
// Double rAF: wait for SyntaxHighlighter to fully render before scrolling
let cancelled = false;
const outerRaf = requestAnimationFrame(() => {
const innerRaf = requestAnimationFrame(() => {
if (cancelled) return;
const container = selectedViewerRef.current;
if (!container) return;
const lineEl =
(container.querySelector(`[data-line-number="${startLine + 1}"]`) as HTMLElement) ??
(container.querySelectorAll('.linenumber')[startLine] as HTMLElement);
if (lineEl) {
lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Fallback: estimate scroll position based on line height
const lineHeight = 20.8; // 13px font * 1.6 line-height
container.scrollTop = Math.max(0, startLine * lineHeight - container.clientHeight / 3);
}
});
rafIds.push(innerRaf);
});
const rafIds = [outerRaf];
return () => {
cancelled = true;
rafIds.forEach((id) => cancelAnimationFrame(id));
};
}, [selectedFileContent, selectedNode?.properties?.startLine]);
if (isCollapsed) {
return (
<aside className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2 flex-shrink-0">
<aside className="flex h-full w-12 flex-shrink-0 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
<button
onClick={() => setIsCollapsed(false)}
className="p-2 text-text-secondary hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-colors"
className="rounded p-2 text-text-secondary transition-colors hover:bg-cyan-500/10 hover:text-cyan-400"
title="Expand Code Panel"
>
<PanelLeft className="w-5 h-5" />
<PanelLeft className="h-5 w-5" />
</button>
<div className="w-6 h-px bg-border-subtle my-1" />
<div className="my-1 h-px w-6 bg-border-subtle" />
{showSelectedViewer && (
<div className="text-[9px] text-amber-400 rotate-90 whitespace-nowrap font-medium tracking-wide">
<div className="rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-amber-400">
SELECTED
</div>
)}
{showCitations && (
<div className="text-[9px] text-cyan-400 rotate-90 whitespace-nowrap font-medium tracking-wide mt-4">
<div className="mt-4 rotate-90 text-[9px] font-medium tracking-wide whitespace-nowrap text-cyan-400">
AI • {aiReferences.length}
</div>
)}
@@ -256,70 +315,80 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
return (
<aside
ref={(el) => { panelRef.current = el; }}
className="h-full bg-surface/95 backdrop-blur-md border-r border-border-subtle flex flex-col animate-slide-in relative shadow-2xl"
ref={(el) => {
panelRef.current = el;
}}
className="relative flex h-full animate-slide-in flex-col border-r border-border-subtle bg-surface/95 shadow-2xl backdrop-blur-md"
style={{ width: panelWidth }}
>
{/* Resize handle */}
<div
onMouseDown={startResize}
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent hover:bg-cyan-500/25 transition-colors"
className="absolute top-0 right-0 h-full w-2 cursor-col-resize bg-transparent transition-colors hover:bg-cyan-500/25"
title="Drag to resize"
/>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60">
<div className="flex items-center justify-between border-b border-border-subtle bg-gradient-to-r from-elevated/60 to-surface/60 px-3 py-2.5">
<div className="flex items-center gap-2">
<Code className="w-4 h-4 text-cyan-400" />
<Code className="h-4 w-4 text-cyan-400" />
<span className="text-sm font-semibold text-text-primary">Code Inspector</span>
</div>
<div className="flex items-center gap-1.5">
{showCitations && (
<button
onClick={() => clearCodeReferences()}
className="p-1.5 text-text-muted hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400"
title="Clear AI citations"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="h-4 w-4" />
</button>
)}
<button
onClick={() => setIsCollapsed(true)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Collapse Panel"
>
<PanelLeftClose className="w-4 h-4" />
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{/* Top: Selected file viewer (when a node is selected) */}
{showSelectedViewer && (
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} min-h-0 flex flex-col`}>
<div className="px-3 py-2 bg-gradient-to-r from-amber-500/8 to-orange-500/5 border-b border-amber-500/20 flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-amber-500/15 rounded-md border border-amber-500/25">
<MousePointerClick className="w-3 h-3 text-amber-400" />
<span className="text-[10px] text-amber-300 font-semibold uppercase tracking-wide">Selected</span>
<div className={`${showCitations ? 'h-[42%]' : 'flex-1'} flex min-h-0 flex-col`}>
<div className="flex items-center gap-2 border-b border-amber-500/20 bg-gradient-to-r from-amber-500/8 to-orange-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-amber-500/25 bg-amber-500/15 px-2 py-0.5">
<MousePointerClick className="h-3 w-3 text-amber-400" />
<span className="text-[10px] font-semibold tracking-wide text-amber-300 uppercase">
Selected
</span>
</div>
<FileCode className="w-3.5 h-3.5 text-amber-400/70 ml-1" />
<span className="text-xs text-text-primary font-mono truncate flex-1">
{selectedNode?.properties?.filePath?.split('/').pop() ?? selectedNode?.properties?.name}
<FileCode className="ml-1 h-3.5 w-3.5 text-amber-400/70" />
<span className="flex-1 truncate font-mono text-xs text-text-primary">
{selectedNode?.properties?.filePath?.split('/').pop() ??
selectedNode?.properties?.name}
</span>
<button
onClick={() => setSelectedNode(null)}
className="p-1 text-text-muted hover:text-amber-400 hover:bg-amber-500/10 rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-amber-500/10 hover:text-amber-400"
title="Clear selection"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto scrollbar-thin">
{selectedFileContent ? (
<div ref={selectedViewerRef} className="scrollbar-thin min-h-0 flex-1 overflow-auto">
{isLoadingFile ? (
<div className="flex items-center justify-center gap-2 py-8 text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading source...</span>
</div>
) : selectedFileContent ? (
<SyntaxHighlighter
language={getSyntaxLanguage(selectedFilePath)}
style={customTheme as any}
showLineNumbers
startingLineNumber={1}
startingLineNumber={fileStartLine + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
@@ -328,12 +397,12 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const startLine = selectedNode?.properties?.startLine;
const endLine = selectedNode?.properties?.endLine ?? startLine;
const symStart = selectedNode?.properties?.startLine;
const symEnd = selectedNode?.properties?.endLine ?? symStart;
const isHighlighted =
typeof startLine === 'number' &&
lineNumber >= startLine + 1 &&
lineNumber <= (endLine ?? startLine) + 1;
typeof symStart === 'number' &&
lineNumber >= symStart + 1 &&
lineNumber <= (symEnd ?? symStart) + 1;
return {
style: {
display: 'block',
@@ -351,7 +420,10 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
) : (
<div className="px-3 py-3 text-sm text-text-muted">
{selectedIsFile ? (
<>Code not available in memory for <span className="font-mono">{selectedFilePath}</span></>
<>
Code not available in memory for{' '}
<span className="font-mono">{selectedFilePath}</span>
</>
) : (
<>Select a file node to preview its contents.</>
)}
@@ -368,128 +440,147 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
{/* Bottom: AI citations list */}
{showCitations && (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex min-h-0 flex-1 flex-col">
{/* AI Citations Section Header */}
<div className="px-3 py-2 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 border-b border-cyan-500/20 flex items-center gap-2">
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-cyan-500/15 rounded-md border border-cyan-500/25">
<Sparkles className="w-3 h-3 text-cyan-400" />
<span className="text-[10px] text-cyan-300 font-semibold uppercase tracking-wide">AI Citations</span>
</div>
<span className="text-xs text-text-muted ml-1">{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin p-3 space-y-3">
{refsWithSnippets.map(({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label ? (NODE_COLORS as any)[ref.label] || '#6b7280' : '#6b7280';
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
return (
<div
key={ref.id}
ref={(el) => { refCardEls.current.set(ref.id, el); }}
className={[
'bg-elevated border border-border-subtle rounded-xl overflow-hidden transition-all',
isGlowing ? 'ring-2 ring-cyan-300/70 shadow-[0_0_0_6px_rgba(34,211,238,0.14)] animate-pulse' : '',
].join(' ')}
>
<div className="px-3 py-2 border-b border-border-subtle bg-surface/40 flex items-start gap-2">
<span
className="mt-0.5 px-2 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide flex-shrink-0"
style={{ backgroundColor: nodeColor, color: '#06060a' }}
title={ref.label ?? 'Code'}
>
{ref.label ?? 'Code'}
<div className="flex items-center gap-2 border-b border-cyan-500/20 bg-gradient-to-r from-cyan-500/8 to-teal-500/5 px-3 py-2">
<div className="flex items-center gap-1.5 rounded-md border border-cyan-500/25 bg-cyan-500/15 px-2 py-0.5">
<Sparkles className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold tracking-wide text-cyan-300 uppercase">
AI Citations
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-text-primary font-medium truncate">
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
</div>
<div className="text-[11px] text-text-muted font-mono truncate">
{ref.filePath}
{startDisplay !== undefined && (
<span className="text-text-secondary">
{' '}
• L{startDisplay}
{endDisplay !== startDisplay ? `–${endDisplay}` : ''}
</span>
)}
{totalLines > 0 && <span className="text-text-muted"> • {totalLines} lines</span>}
</div>
</div>
<div className="flex items-center gap-1">
{ref.nodeId && (
<button
onClick={() => {
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);
}}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Focus in graph"
>
<Target className="w-4 h-4" />
</button>
)}
<button
onClick={() => removeCodeReference(ref.id)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
title="Remove"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
{content ? (
<SyntaxHighlighter
language={language}
style={customTheme as any}
showLineNumbers
startingLineNumber={start + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const isHighlighted =
hasRange &&
lineNumber >= start + highlightStart + 1 &&
lineNumber <= start + highlightEnd + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted ? 'rgba(6, 182, 212, 0.14)' : 'transparent',
borderLeft: isHighlighted ? '3px solid #06b6d4' : '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{content}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
Code not available in memory for <span className="font-mono">{ref.filePath}</span>
</div>
)}
</div>
<span className="ml-1 text-xs text-text-muted">
{aiReferences.length} reference{aiReferences.length !== 1 ? 's' : ''}
</span>
</div>
);
})}
<div className="scrollbar-thin min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
{refsWithSnippets.map(
({ ref, content, start, highlightStart, highlightEnd, totalLines }) => {
const nodeColor = ref.label
? (NODE_COLORS as any)[ref.label] || '#6b7280'
: '#6b7280';
const hasRange = typeof ref.startLine === 'number';
const startDisplay = hasRange ? (ref.startLine ?? 0) + 1 : undefined;
const endDisplay = hasRange ? (ref.endLine ?? ref.startLine ?? 0) + 1 : undefined;
const language = getSyntaxLanguage(ref.filePath);
const isGlowing = glowRefId === ref.id;
return (
<div
key={ref.id}
ref={(el) => {
refCardEls.current.set(ref.id, el);
}}
className={[
'overflow-hidden rounded-xl border border-border-subtle bg-elevated transition-all',
isGlowing
? 'animate-pulse shadow-[0_0_0_6px_rgba(34,211,238,0.14)] ring-2 ring-cyan-300/70'
: '',
].join(' ')}
>
<div className="flex items-start gap-2 border-b border-border-subtle bg-surface/40 px-3 py-2">
<span
className="mt-0.5 flex-shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase"
style={{ backgroundColor: nodeColor, color: '#06060a' }}
title={ref.label ?? 'Code'}
>
{ref.label ?? 'Code'}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium text-text-primary">
{ref.name ?? ref.filePath.split('/').pop() ?? ref.filePath}
</div>
<div className="truncate font-mono text-[11px] text-text-muted">
{ref.filePath}
{startDisplay !== undefined && (
<span className="text-text-secondary">
{' '}
• L{startDisplay}
{endDisplay !== startDisplay ? `–${endDisplay}` : ''}
</span>
)}
{totalLines > 0 && (
<span className="text-text-muted"> • {totalLines} lines</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{ref.nodeId && (
<button
onClick={() => {
const nodeId = ref.nodeId!;
// Sync selection + focus graph
if (graph) {
const node = nodeById.get(nodeId);
if (node) setSelectedNode(node);
}
onFocusNode(nodeId);
}}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Focus in graph"
>
<Target className="h-4 w-4" />
</button>
)}
<button
onClick={() => removeCodeReference(ref.id)}
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Remove"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="overflow-x-auto">
{content ? (
<SyntaxHighlighter
language={language}
style={customTheme as any}
showLineNumbers
startingLineNumber={start + 1}
lineNumberStyle={{
minWidth: '3em',
paddingRight: '1em',
color: '#5a5a70',
textAlign: 'right',
userSelect: 'none',
}}
lineProps={(lineNumber) => {
const isHighlighted =
hasRange &&
lineNumber >= start + highlightStart + 1 &&
lineNumber <= start + highlightEnd + 1;
return {
style: {
display: 'block',
backgroundColor: isHighlighted
? 'rgba(6, 182, 212, 0.14)'
: 'transparent',
borderLeft: isHighlighted
? '3px solid #06b6d4'
: '3px solid transparent',
paddingLeft: '12px',
paddingRight: '16px',
},
};
}}
wrapLines
>
{content}
</SyntaxHighlighter>
) : (
<div className="px-3 py-3 text-sm text-text-muted">
Code not available in memory for{' '}
<span className="font-mono">{ref.filePath}</span>
</div>
)}
</div>
</div>
);
},
)}
</div>
</div>
)}
+293 -555
View File
@@ -1,578 +1,316 @@
import { useState, useCallback, useRef, DragEvent } from 'react';
import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from '@/lib/lucide-icons';
import { cloneRepository, parseGitHubUrl } from '../services/git-clone';
import { connectToServer, type ConnectToServerResult } from '../services/server-connection';
import { FileEntry } from '../services/zip';
import { useState, useRef, useEffect } from 'react';
import { Loader2, Check, Sparkles } from '@/lib/lucide-icons';
import {
connectToServer,
fetchRepos,
type ConnectResult,
type BackendRepo,
} from '../services/backend-client';
import { useBackend } from '../hooks/useBackend';
import { OnboardingGuide } from './OnboardingGuide';
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
import { RepoLanding } from './RepoLanding';
interface DropZoneProps {
onFileSelect: (file: File) => void;
onGitClone?: (files: FileEntry[], repoName?: string) => void;
onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void;
onServerConnect?: (result: ConnectResult, serverUrl?: string) => void | Promise<void>;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ── Crossfade wrapper ───────────────────────────────────────────────────────
// Captures the outgoing children during fade-out, then swaps to the new children on fade-in.
export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => {
const [isDragging, setIsDragging] = useState(false);
const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip');
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [showToken, setShowToken] = useState(false);
const [isCloning, setIsCloning] = useState(false);
const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 });
const [error, setError] = useState<string | null>(null);
function Crossfade({ activeKey, children }: { activeKey: string; children: React.ReactNode }) {
const [displayedKey, setDisplayedKey] = useState(activeKey);
const [isTransitioning, setIsTransitioning] = useState(false);
const snapshotRef = useRef<React.ReactNode>(children);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Server tab state
const [serverUrl, setServerUrl] = useState(() => {
try {
return localStorage.getItem('gitnexus-server-url') || '';
} catch {
return '';
// Keep snapshot up to date when NOT transitioning
if (!isTransitioning && activeKey === displayedKey) {
snapshotRef.current = children;
}
useEffect(() => {
if (activeKey !== displayedKey) {
setIsTransitioning(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
snapshotRef.current = null; // clear snapshot — new children will render
setDisplayedKey(activeKey);
setIsTransitioning(false);
}, 300);
}
});
const [isConnecting, setIsConnecting] = useState(false);
const [serverProgress, setServerProgress] = useState<{
phase: string;
downloaded: number;
total: number | null;
}>({ phase: '', downloaded: 0, total: null });
const abortControllerRef = useRef<AbortController | null>(null);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.zip')) {
onFileSelect(file);
} else {
setError('Please drop a .zip file');
}
}
}, [onFileSelect]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.zip')) {
onFileSelect(file);
} else {
setError('Please select a .zip file');
}
}
}, [onFileSelect]);
const handleGitClone = async () => {
if (!githubUrl.trim()) {
setError('Please enter a GitHub URL');
return;
}
const parsed = parseGitHubUrl(githubUrl);
if (!parsed) {
setError('Invalid GitHub URL. Use format: https://github.com/owner/repo');
return;
}
setError(null);
setIsCloning(true);
setCloneProgress({ phase: 'starting', percent: 0 });
try {
const files = await cloneRepository(
githubUrl,
(phase, percent) => setCloneProgress({ phase, percent }),
githubToken || undefined
);
setGithubToken('');
if (onGitClone) {
onGitClone(files, parsed.repo);
}
} catch (err) {
console.error('Clone failed:', err);
const message = err instanceof Error ? err.message : 'Failed to clone repository';
if (message.includes('401') || message.includes('403') || message.includes('Authentication')) {
if (!githubToken) {
setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.');
} else {
setError('Authentication failed. Check your token permissions (needs repo access).');
}
} else if (message.includes('404') || message.includes('not found')) {
setError('Repository not found. Check the URL or it might be private (needs PAT).');
} else {
setError(message);
}
} finally {
setIsCloning(false);
}
};
const handleServerConnect = async () => {
const urlToUse = serverUrl.trim() || window.location.origin;
if (!urlToUse) {
setError('Please enter a server URL');
return;
}
// Persist URL to localStorage
try {
localStorage.setItem('gitnexus-server-url', serverUrl);
} catch {
// localStorage may be unavailable (e.g. private browsing, quota exceeded)
}
setError(null);
setIsConnecting(true);
setServerProgress({ phase: 'validating', downloaded: 0, total: null });
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const result = await connectToServer(
urlToUse,
(phase, downloaded, total) => {
setServerProgress({ phase, downloaded, total });
},
abortController.signal
);
if (onServerConnect) {
onServerConnect(result, urlToUse);
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
// User cancelled
return;
}
console.error('Server connect failed:', err);
const message = err instanceof Error ? err.message : 'Failed to connect to server';
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
setError('Cannot reach server. Check the URL and ensure the server is running.');
} else {
setError(message);
}
} finally {
setIsConnecting(false);
abortControllerRef.current = null;
}
};
const handleCancelConnect = () => {
abortControllerRef.current?.abort();
setIsConnecting(false);
};
const serverProgressPercent = serverProgress.total
? Math.round((serverProgress.downloaded / serverProgress.total) * 100)
: null;
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [activeKey, displayedKey]);
return (
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
<div
className="transition-[opacity,transform] duration-300 ease-out"
style={{
opacity: isTransitioning ? 0 : 1,
transform: isTransitioning ? 'scale(0.97) translateY(8px)' : 'scale(1) translateY(0)',
}}
>
{isTransitioning ? snapshotRef.current : children}
</div>
);
}
// ── Phase cards ─────────────────────────────────────────────────────────────
function SuccessCard() {
return (
<div
className="relative overflow-hidden rounded-3xl border border-emerald-500/20 bg-surface p-7"
role="status"
aria-live="polite"
>
{/* Success glow */}
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-emerald-500/8 blur-3xl" />
<div className="relative">
{/* Animated check icon */}
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/20 to-emerald-600/10 shadow-[0_0_30px_rgba(16,185,129,0.15)]">
<Check className="h-8 w-8 text-emerald-400" />
</div>
<h2 className="mb-2 text-center text-lg font-semibold text-emerald-400">
Server Connected
</h2>
<p className="text-center text-sm leading-relaxed text-text-secondary">
Preparing your code knowledge graph...
</p>
{/* Subtle progress hint */}
<div className="mt-6 flex items-center justify-center gap-2">
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<div
key={i}
className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400/60"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</div>
</div>
</div>
</div>
);
}
function LoadingCard({ message }: { message: string }) {
return (
<div
className="relative overflow-hidden rounded-3xl border border-accent/20 bg-surface p-7"
role="status"
aria-live="polite"
>
{/* Loading glow */}
<div className="pointer-events-none absolute -top-20 left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-accent/8 blur-3xl" />
<div className="relative">
{/* Spinner */}
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl border border-accent/30 bg-gradient-to-br from-accent/20 to-accent-dim/10 shadow-glow-soft">
<Loader2 className="h-8 w-8 animate-spin text-accent" />
</div>
<h2 className="mb-2 text-center text-lg font-semibold text-text-primary">
{message || 'Connecting...'}
</h2>
<p className="text-center text-sm leading-relaxed text-text-secondary">
This may take a moment for large repositories
</p>
{/* Decorative sparkle */}
<div className="mt-5 flex items-center justify-center">
<Sparkles className="h-4 w-4 text-accent/30" />
</div>
</div>
</div>
);
}
// ── DropZone ─────────────────────────────────────────────────────────────────
export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const [error, setError] = useState<string | null>(null);
// Backend polling for server detection
const {
isConnected,
isProbing,
startPolling,
stopPolling,
isPolling,
backendUrl: detectedBackendUrl,
} = useBackend();
const [initialProbeComplete, setInitialProbeComplete] = useState(false);
const autoConnectRan = useRef(false);
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Connection state
// 'analyze' = server up but zero repos indexed — show URL input
// 'landing' = server up with indexed repos — show repo picker + analyze
const [phase, setPhase] = useState<'onboarding' | 'analyze' | 'landing' | 'success' | 'loading'>(
'onboarding',
);
const [loadingMessage, setLoadingMessage] = useState('');
const abortControllerRef = useRef<AbortController | null>(null);
const [detectedRepos, setDetectedRepos] = useState<BackendRepo[]>([]);
// Auto-connect to the detected server — fetch repo list and show the
// appropriate screen (landing with repo cards, or analyze for zero repos).
const handleAutoConnect = async () => {
setPhase('loading');
setLoadingMessage('Connecting...');
setError(null);
try {
const repos = await fetchRepos();
if (repos.length === 0) {
setPhase('analyze');
autoConnectRan.current = false;
return;
}
// Show landing screen so the user can choose which repo to explore
setDetectedRepos(repos);
setPhase('landing');
} catch (err) {
if ((err as Error).name === 'AbortError') return;
const message = err instanceof Error ? err.message : 'Failed to connect';
setError(message);
setPhase('onboarding');
}
};
const handleAutoConnectRef = useRef(handleAutoConnect);
handleAutoConnectRef.current = handleAutoConnect;
// Shared handler: connect to a specific repo by name (used by both repo
// card selection on the landing screen and post-analysis completion).
const connectToRepo = (repoName: string) => {
autoConnectRan.current = true;
setPhase('loading');
setLoadingMessage('Loading graph...');
setError(null);
(async () => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const result = await connectToServer(
detectedBackendUrl,
(p, downloaded, total) => {
if (p === 'validating') {
setLoadingMessage('Validating server...');
} 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`);
} else if (p === 'extracting') {
setLoadingMessage('Processing graph...');
}
},
abortController.signal,
repoName,
);
if (onServerConnect) {
await onServerConnect(result, detectedBackendUrl);
}
} catch (err) {
if ((err as Error).name === 'AbortError') return;
setError(err instanceof Error ? err.message : 'Failed to load graph');
setPhase(detectedRepos.length > 0 ? 'landing' : 'analyze');
} finally {
abortControllerRef.current = null;
}
})();
};
// Track when the initial probe finishes
useEffect(() => {
if (!isProbing && !initialProbeComplete) {
setInitialProbeComplete(true);
}
}, [isProbing, initialProbeComplete]);
// Start polling once after initial probe fails
useEffect(() => {
if (initialProbeComplete && !isConnected && !isPolling && !autoConnectRan.current) {
startPolling();
}
}, [initialProbeComplete, isConnected, isPolling, startPolling]);
// Auto-connect when server is detected
useEffect(() => {
if (isConnected && !autoConnectRan.current) {
autoConnectRan.current = true;
stopPolling();
setPhase('success');
autoConnectTimerRef.current = setTimeout(() => {
autoConnectTimerRef.current = null;
handleAutoConnectRef.current();
}, 1200); // hold success state long enough to register
}
// Server went away — reset to onboarding (or analyze if we were on analyze)
if (!isConnected && autoConnectRan.current && !isProbing) {
autoConnectRan.current = false;
if (autoConnectTimerRef.current !== null) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
setPhase('onboarding');
setError(null);
}
// NOTE: No cleanup return here. The autoConnectTimerRef must survive effect
// re-runs (e.g. isProbing flipping false while the 1200ms window is active).
// The unmount cleanup effect below is the sole owner of timer cancellation.
}, [isConnected, isProbing, stopPolling]);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (autoConnectTimerRef.current !== null) clearTimeout(autoConnectTimerRef.current);
abortControllerRef.current?.abort();
};
}, []);
// Don't render until initial probe completes
const displayPhase = !initialProbeComplete ? null : phase;
return (
<div className="flex min-h-screen items-center justify-center bg-void p-8">
{/* Background gradient effects */}
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-accent/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl" />
<div className="pointer-events-none fixed inset-0">
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-accent/10 blur-3xl" />
<div className="absolute right-1/4 bottom-1/4 h-96 w-96 rounded-full bg-node-interface/10 blur-3xl" />
</div>
<div className="relative w-full max-w-lg">
{/* Tab Switcher */}
<div className="flex mb-4 bg-surface border border-border-default rounded-xl p-1">
<button
onClick={() => { setActiveTab('zip'); setError(null); }}
className={`
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
text-sm font-medium transition-all duration-200
${activeTab === 'zip'
? 'bg-accent text-white shadow-md'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
}
`}
>
<FileArchive className="w-4 h-4" />
ZIP Upload
</button>
<button
onClick={() => { setActiveTab('github'); setError(null); }}
className={`
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
text-sm font-medium transition-all duration-200
${activeTab === 'github'
? 'bg-accent text-white shadow-md'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
}
`}
>
<Github className="w-4 h-4" />
GitHub URL
</button>
<button
onClick={() => { setActiveTab('server'); setError(null); }}
className={`
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
text-sm font-medium transition-all duration-200
${activeTab === 'server'
? 'bg-accent text-white shadow-md'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
}
`}
>
<Globe className="w-4 h-4" />
Server
</button>
</div>
{/* Error Message */}
{/* Error — floats above the card */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm text-center">
<div className="mb-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-center text-sm text-red-400">
{error}
</div>
)}
{/* ZIP Upload Tab */}
{activeTab === 'zip' && (
<>
<div
className={`
relative p-16
bg-surface border-2 border-dashed rounded-3xl
transition-all duration-300 cursor-pointer
${isDragging
? 'border-accent bg-elevated scale-105 shadow-glow'
: 'border-border-default hover:border-accent/50 hover:bg-elevated/50 animate-breathe'
}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => document.getElementById('file-input')?.click()}
>
<input
id="file-input"
type="file"
accept=".zip"
className="hidden"
onChange={handleFileInput}
{/* Crossfade between phases */}
{displayPhase && (
<Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
{displayPhase === 'landing' && (
<RepoLanding
repos={detectedRepos}
onSelectRepo={connectToRepo}
onAnalyzeComplete={connectToRepo}
/>
{/* Icon */}
<div className={`
mx-auto w-20 h-20 mb-6
flex items-center justify-center
bg-gradient-to-br from-accent to-node-interface
rounded-2xl shadow-glow
transition-transform duration-300
${isDragging ? 'scale-110' : ''}
`}>
{isDragging ? (
<Upload className="w-10 h-10 text-white" />
) : (
<FileArchive className="w-10 h-10 text-white" />
)}
</div>
{/* Text */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
{isDragging ? 'Drop it here!' : 'Drop your codebase'}
</h2>
<p className="text-sm text-text-secondary text-center mb-6">
Drag & drop a .zip file to generate a knowledge graph
</p>
{/* Hints */}
<div className="flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
.zip
</span>
</div>
</div>
</>
)}
{/* GitHub URL Tab */}
{activeTab === 'github' && (
<div className="p-8 bg-surface border border-border-default rounded-3xl">
{/* Icon */}
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-[#333] to-[#24292e] rounded-2xl shadow-lg">
<Github className="w-10 h-10 text-white" />
</div>
{/* Text */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
Clone from GitHub
</h2>
<p className="text-sm text-text-secondary text-center mb-6">
Enter a repository URL to clone directly
</p>
{/* Inputs - wrapped in div to prevent form autofill */}
<div className="space-y-3" data-form-type="other">
<input
type="url"
name="github-repo-url-input"
value={githubUrl}
onChange={(e) => setGithubUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isCloning && handleGitClone()}
placeholder="https://github.com/owner/repo"
disabled={isCloning}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="
w-full px-4 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
/>
{/* Token input for private repos */}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted">
<Key className="w-4 h-4" />
</div>
<input
type={showToken ? 'text' : 'password'}
name="github-pat-token-input"
value={githubToken}
onChange={(e) => setGithubToken(e.target.value)}
placeholder="GitHub PAT (optional, for private repos)"
disabled={isCloning}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="
w-full pl-10 pr-10 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors"
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={handleGitClone}
disabled={isCloning || !githubUrl.trim()}
className="
w-full flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
>
{isCloning ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{cloneProgress.phase === 'cloning'
? `Cloning... ${cloneProgress.percent}%`
: cloneProgress.phase === 'reading'
? 'Reading files...'
: 'Starting...'
}
</>
) : (
<>
Clone Repository
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</div>
{/* Progress bar */}
{isCloning && (
<div className="mt-4">
<div className="h-2 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${cloneProgress.percent}%` }}
/>
</div>
</div>
)}
{/* Security note */}
{githubToken && (
<p className="mt-3 text-xs text-text-muted text-center">
Token stays in your browser only, never sent to any server
</p>
)}
{/* Hints */}
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
{githubToken ? 'Private + Public' : 'Public repos'}
</span>
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
Shallow clone
</span>
</div>
</div>
)}
{/* Server Tab */}
{activeTab === 'server' && (
<div className="p-8 bg-surface border border-border-default rounded-3xl">
{/* Icon */}
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
<Globe className="w-10 h-10 text-white" />
</div>
{/* Text */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
Connect to Server
</h2>
<p className="text-sm text-text-secondary text-center mb-6">
Load a pre-built knowledge graph from a running GitNexus server
</p>
{/* Inputs */}
<div className="space-y-3" data-form-type="other">
<input
type="url"
name="server-url-input"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()}
placeholder={window.location.origin}
disabled={isConnecting}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
data-form-type="other"
className="
w-full px-4 py-3
bg-elevated border border-border-default rounded-xl
text-text-primary placeholder-text-muted
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
/>
<div className="flex gap-2">
<button
onClick={handleServerConnect}
disabled={isConnecting}
className="
flex-1 flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
>
{isConnecting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{serverProgress.phase === 'validating'
? 'Validating...'
: serverProgress.phase === 'downloading'
? serverProgressPercent !== null
? `Downloading... ${serverProgressPercent}%`
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
: serverProgress.phase === 'extracting'
? 'Processing...'
: 'Connecting...'
}
</>
) : (
<>
Connect
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
{isConnecting && (
<button
onClick={handleCancelConnect}
className="
flex items-center justify-center
px-4 py-3
bg-red-500/20 hover:bg-red-500/30
text-red-400 font-medium rounded-xl
transition-all duration-200
"
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* Progress bar */}
{isConnecting && serverProgress.phase === 'downloading' && (
<div className="mt-4">
<div className="h-2 bg-elevated rounded-full overflow-hidden">
<div
className={`h-full bg-accent transition-all duration-300 ease-out ${
serverProgressPercent === null ? 'animate-pulse' : ''
}`}
style={{
width: serverProgressPercent !== null
? `${serverProgressPercent}%`
: '100%',
}}
/>
</div>
{serverProgress.total && (
<p className="mt-1 text-xs text-text-muted text-center">
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
</p>
)}
</div>
)}
{/* Hints */}
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
Pre-indexed
</span>
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
No WASM needed
</span>
</div>
</div>
{displayPhase === 'success' && <SuccessCard />}
{displayPhase === 'loading' && <LoadingCard message={loadingMessage} />}
</Crossfade>
)}
</div>
</div>
+36 -67
View File
@@ -1,4 +1,4 @@
import { Brain, Loader2, Check, AlertCircle, Zap, FlaskConical } from '@/lib/lucide-icons';
import { Brain, Loader2, Check, AlertCircle, Zap } from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { useState } from 'react';
import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
@@ -8,17 +8,9 @@ import { WebGPUFallbackDialog } from './WebGPUFallbackDialog';
* Shows in header when graph is loaded
*/
export const EmbeddingStatus = () => {
const {
embeddingStatus,
embeddingProgress,
startEmbeddings,
graph,
viewMode,
serverBaseUrl,
testArrayParams,
} = useAppState();
const { embeddingStatus, embeddingProgress, startEmbeddings, graph, viewMode, serverBaseUrl } =
useAppState();
const [testResult, setTestResult] = useState<string | null>(null);
const [showFallbackDialog, setShowFallbackDialog] = useState(false);
// Only show when exploring a loaded graph; hide in backend mode (no WASM DB)
@@ -26,13 +18,15 @@ export const EmbeddingStatus = () => {
const nodeCount = graph.nodes.length;
const handleStartEmbeddings = async (forceDevice?: 'webgpu' | 'wasm') => {
const handleStartEmbeddings = async (_forceDevice?: 'webgpu' | 'wasm') => {
try {
await startEmbeddings(forceDevice);
await startEmbeddings();
} catch (error: any) {
// Check if it's a WebGPU not available error
if (error?.name === 'WebGPUNotAvailableError' ||
error?.message?.includes('WebGPU not available')) {
if (
error?.name === 'WebGPUNotAvailableError' ||
error?.message?.includes('WebGPU not available')
) {
setShowFallbackDialog(true);
} else {
console.error('Embedding failed:', error);
@@ -49,18 +43,6 @@ export const EmbeddingStatus = () => {
setShowFallbackDialog(false);
// Just close - user can try again later if they want
};
const handleTestArrayParams = async () => {
setTestResult('Testing...');
const result = await testArrayParams();
if (result.success) {
setTestResult('✅ Array params WORK!');
console.log('✅ Array params test passed!');
} else {
setTestResult(`❌ ${result.error}`);
console.error('❌ Array params test failed:', result.error);
}
};
// WebGPU fallback dialog - rendered independently of state
const fallbackDialog = (
@@ -78,26 +60,14 @@ export const EmbeddingStatus = () => {
return (
<>
<div className="flex items-center gap-2">
{/* Test button (dev only) */}
{import.meta.env.DEV && (
<button
onClick={handleTestArrayParams}
className="flex items-center gap-1 px-2 py-1.5 bg-surface border border-border-subtle rounded-lg text-xs text-text-muted hover:bg-hover hover:text-text-secondary transition-all"
title="Test if LadybugDB supports array params"
>
<FlaskConical className="w-3 h-3" />
{testResult || 'Test'}
</button>
)}
<button
onClick={() => handleStartEmbeddings()}
className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary hover:bg-hover hover:text-text-primary hover:border-accent/50 transition-all group"
className="group flex items-center gap-2 rounded-lg border border-border-subtle bg-surface px-3 py-1.5 text-sm text-text-secondary transition-all hover:border-accent/50 hover:bg-hover hover:text-text-primary"
title="Generate embeddings for semantic search"
>
<Brain className="w-4 h-4 text-node-interface group-hover:text-accent transition-colors" />
<Brain className="h-4 w-4 text-node-interface transition-colors group-hover:text-accent" />
<span className="hidden sm:inline">Enable Semantic Search</span>
<Zap className="w-3 h-3 text-text-muted" />
<Zap className="h-3 w-3 text-text-muted" />
</button>
</div>
{fallbackDialog}
@@ -107,16 +77,16 @@ export const EmbeddingStatus = () => {
// Loading model
if (embeddingStatus === 'loading') {
const downloadPercent = embeddingProgress?.modelDownloadPercent ?? 0;
const downloadPercent = embeddingProgress?.percent ?? 0;
return (
<>
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-accent/30 rounded-lg text-sm">
<Loader2 className="w-4 h-4 text-accent animate-spin" />
<div className="flex items-center gap-2.5 rounded-lg border border-accent/30 bg-surface px-3 py-1.5 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-accent" />
<div className="flex flex-col gap-0.5">
<span className="text-text-secondary text-xs">Loading AI model...</span>
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
<span className="text-xs text-text-secondary">Loading AI model...</span>
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
style={{ width: `${downloadPercent}%` }}
/>
</div>
@@ -129,20 +99,20 @@ export const EmbeddingStatus = () => {
// Embedding in progress
if (embeddingStatus === 'embedding') {
const processed = embeddingProgress?.nodesProcessed ?? 0;
const total = embeddingProgress?.totalNodes ?? 0;
const processed = 0;
const total = 0;
const percent = embeddingProgress?.percent ?? 0;
return (
<div className="flex items-center gap-2.5 px-3 py-1.5 bg-surface border border-node-function/30 rounded-lg text-sm">
<Loader2 className="w-4 h-4 text-node-function animate-spin" />
<div className="flex items-center gap-2.5 rounded-lg border border-node-function/30 bg-surface px-3 py-1.5 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-node-function" />
<div className="flex flex-col gap-0.5">
<span className="text-text-secondary text-xs">
<span className="text-xs text-text-secondary">
Embedding {processed}/{total} nodes
</span>
<div className="w-24 h-1 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-node-function to-accent rounded-full transition-all duration-300"
<div className="h-1 w-24 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-node-function to-accent transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
@@ -154,8 +124,8 @@ export const EmbeddingStatus = () => {
// Indexing
if (embeddingStatus === 'indexing') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-node-interface/30 rounded-lg text-sm text-text-secondary">
<Loader2 className="w-4 h-4 text-node-interface animate-spin" />
<div className="flex items-center gap-2 rounded-lg border border-node-interface/30 bg-surface px-3 py-1.5 text-sm text-text-secondary">
<Loader2 className="h-4 w-4 animate-spin text-node-interface" />
<span className="text-xs">Creating vector index...</span>
</div>
);
@@ -164,11 +134,11 @@ export const EmbeddingStatus = () => {
// Ready
if (embeddingStatus === 'ready') {
return (
<div
className="flex items-center gap-2 px-3 py-1.5 bg-node-function/10 border border-node-function/30 rounded-lg text-sm text-node-function"
<div
className="flex items-center gap-2 rounded-lg border border-node-function/30 bg-node-function/10 px-3 py-1.5 text-sm text-node-function"
title="Semantic search is ready! Use natural language in the AI chat."
>
<Check className="w-4 h-4" />
<Check className="h-4 w-4" />
<span className="text-xs font-medium">Semantic Ready</span>
</div>
);
@@ -180,10 +150,10 @@ export const EmbeddingStatus = () => {
<>
<button
onClick={() => handleStartEmbeddings()}
className="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/20 transition-colors"
title={embeddingProgress?.error || 'Embedding failed. Click to retry.'}
className="flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 transition-colors hover:bg-red-500/20"
title="Embedding failed. Click to retry."
>
<AlertCircle className="w-4 h-4" />
<AlertCircle className="h-4 w-4" />
<span className="text-xs">Failed - Retry</span>
</button>
{fallbackDialog}
@@ -193,4 +163,3 @@ export const EmbeddingStatus = () => {
return null;
};
+147 -116
View File
@@ -19,8 +19,8 @@ import {
Type,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
import { GraphNode, NodeLabel } from '../core/graph/types';
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO } from '../lib/constants';
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
// Tree node structure
interface TreeNode {
@@ -38,12 +38,12 @@ const buildFileTree = (nodes: GraphNode[]): TreeNode[] => {
const pathMap = new Map<string, TreeNode>();
// Filter to only folders and files
const fileNodes = nodes.filter(n => n.label === 'Folder' || n.label === 'File');
const fileNodes = nodes.filter((n) => n.label === 'Folder' || n.label === 'File');
// Sort by path to ensure parents come before children
fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath));
fileNodes.forEach(node => {
fileNodes.forEach((node) => {
const parts = node.properties.filePath.split('/').filter(Boolean);
let currentPath = '';
let currentLevel = root;
@@ -107,9 +107,9 @@ const TreeItem = ({
const searchLower = searchQuery.toLowerCase();
const matchesSearch = (node: TreeNode, query: string): boolean => {
if (node.name.toLowerCase().includes(query)) return true;
return node.children?.some(child => matchesSearch(child, query)) ?? false;
return node.children?.some((child) => matchesSearch(child, query)) ?? false;
};
return node.children.filter(child => matchesSearch(child, searchLower));
return node.children.filter((child) => matchesSearch(child, searchLower));
}, [node.children, searchQuery]);
// Check if this node matches search
@@ -126,20 +126,15 @@ const TreeItem = ({
<div>
<button
onClick={handleClick}
className={`
w-full flex items-center gap-1.5 px-2 py-1 text-left text-sm
hover:bg-hover transition-colors rounded relative
${isSelected ? 'bg-amber-500/15 text-amber-300 border-l-2 border-amber-400' : 'text-text-secondary hover:text-text-primary border-l-2 border-transparent'}
${matchesSearch ? 'bg-accent/10' : ''}
`}
className={`relative flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-hover ${isSelected ? 'border-l-2 border-amber-400 bg-amber-500/15 text-amber-300' : 'border-l-2 border-transparent text-text-secondary hover:text-text-primary'} ${matchesSearch ? 'bg-accent/10' : ''} `}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{/* Expand/collapse icon */}
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-text-muted" />
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-text-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-text-muted" />
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-muted" />
)
) : (
<span className="w-3.5" />
@@ -148,12 +143,12 @@ const TreeItem = ({
{/* Node icon */}
{node.type === 'folder' ? (
isExpanded ? (
<FolderOpen className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
<FolderOpen className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
) : (
<Folder className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
<Folder className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
)
) : (
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
<FileCode className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.File }} />
)}
{/* Name */}
@@ -163,7 +158,7 @@ const TreeItem = ({
{/* Children */}
{isExpanded && filteredChildren.length > 0 && (
<div>
{filteredChildren.map(child => (
{filteredChildren.map((child) => (
<TreeItem
key={child.id}
node={child}
@@ -184,18 +179,30 @@ const TreeItem = ({
// Icon for node types
const getNodeTypeIcon = (label: NodeLabel) => {
switch (label) {
case 'Folder': return Folder;
case 'File': return FileCode;
case 'Class': return Box;
case 'Function': return Braces;
case 'Method': return Braces;
case 'Interface': return Hash;
case 'Enum': return List;
case 'Type': return Type;
case 'Decorator': return AtSign;
case 'Import': return FileCode;
case 'Variable': return Variable;
default: return Variable;
case 'Folder':
return Folder;
case 'File':
return FileCode;
case 'Class':
return Box;
case 'Function':
return Braces;
case 'Method':
return Braces;
case 'Interface':
return Hash;
case 'Enum':
return List;
case 'Type':
return Type;
case 'Decorator':
return AtSign;
case 'Import':
return FileCode;
case 'Variable':
return Variable;
default:
return Variable;
}
};
@@ -204,7 +211,18 @@ interface FileTreePanelProps {
}
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter } = useAppState();
const {
graph,
visibleLabels,
toggleLabelVisibility,
visibleEdgeTypes,
toggleEdgeVisibility,
selectedNode,
setSelectedNode,
openCodePanel,
depthFilter,
setDepthFilter,
} = useAppState();
const [isCollapsed, setIsCollapsed] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -220,7 +238,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
// Auto-expand first level on initial load
useEffect(() => {
if (fileTree.length > 0 && expandedPaths.size === 0) {
const firstLevel = new Set(fileTree.map(n => n.path));
const firstLevel = new Set(fileTree.map((n) => n.path));
setExpandedPaths(firstLevel);
}
}, [fileTree.length]); // Only run when tree first loads
@@ -242,16 +260,16 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
}
if (pathsToExpand.length > 0) {
setExpandedPaths(prev => {
setExpandedPaths((prev) => {
const next = new Set(prev);
pathsToExpand.forEach(p => next.add(p));
pathsToExpand.forEach((p) => next.add(p));
return next;
});
}
}, [selectedNode?.id]); // Trigger when selected node changes
const toggleExpanded = useCallback((path: string) => {
setExpandedPaths(prev => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
@@ -262,106 +280,115 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
});
}, []);
const handleNodeClick = useCallback((treeNode: TreeNode) => {
if (treeNode.graphNode) {
// Only focus if selecting a different node
const isSameNode = selectedNode?.id === treeNode.graphNode.id;
setSelectedNode(treeNode.graphNode);
openCodePanel();
if (!isSameNode) {
onFocusNode(treeNode.graphNode.id);
const handleNodeClick = useCallback(
(treeNode: TreeNode) => {
if (treeNode.graphNode) {
// Only focus if selecting a different node
const isSameNode = selectedNode?.id === treeNode.graphNode.id;
setSelectedNode(treeNode.graphNode);
openCodePanel();
if (!isSameNode) {
onFocusNode(treeNode.graphNode.id);
}
}
}
}, [setSelectedNode, openCodePanel, onFocusNode, selectedNode]);
},
[setSelectedNode, openCodePanel, onFocusNode, selectedNode],
);
const selectedPath = selectedNode?.properties.filePath || null;
if (isCollapsed) {
return (
<div className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2">
<div className="flex h-full w-12 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
<button
onClick={() => setIsCollapsed(false)}
className="p-2 text-text-secondary hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-2 text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Expand Panel"
>
<PanelLeft className="w-5 h-5" />
<PanelLeft className="h-5 w-5" />
</button>
<div className="w-6 h-px bg-border-subtle my-1" />
<div className="my-1 h-px w-6 bg-border-subtle" />
<button
onClick={() => { setIsCollapsed(false); setActiveTab('files'); }}
className={`p-2 rounded transition-colors ${activeTab === 'files' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
onClick={() => {
setIsCollapsed(false);
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"
>
<Folder className="w-5 h-5" />
<Folder className="h-5 w-5" />
</button>
<button
onClick={() => { setIsCollapsed(false); setActiveTab('filters'); }}
className={`p-2 rounded transition-colors ${activeTab === 'filters' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
onClick={() => {
setIsCollapsed(false);
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"
>
<Filter className="w-5 h-5" />
<Filter className="h-5 w-5" />
</button>
</div>
);
}
return (
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
<div className="flex h-full w-64 animate-slide-in flex-col border-r border-border-subtle bg-surface">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2">
<div className="flex items-center gap-1">
<button
onClick={() => setActiveTab('files')}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`rounded px-2 py-1 text-xs transition-colors ${
activeTab === 'files'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
Explorer
</button>
<button
onClick={() => setActiveTab('filters')}
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
}`}
className={`rounded px-2 py-1 text-xs transition-colors ${
activeTab === 'filters'
? 'bg-accent/20 text-accent'
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
}`}
>
Filters
</button>
</div>
<button
onClick={() => setIsCollapsed(true)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Collapse Panel"
>
<PanelLeftClose className="w-4 h-4" />
<PanelLeftClose className="h-4 w-4" />
</button>
</div>
{activeTab === 'files' && (
<>
{/* Search */}
<div className="px-3 py-2 border-b border-border-subtle">
<div className="border-b border-border-subtle px-3 py-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
<Search className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 bg-elevated border border-border-subtle rounded text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
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"
/>
</div>
</div>
{/* File tree */}
<div className="flex-1 overflow-y-auto scrollbar-thin py-2">
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
{fileTree.length === 0 ? (
<div className="px-3 py-4 text-center text-text-muted text-xs">
No files loaded
</div>
<div className="px-3 py-4 text-center text-xs text-text-muted">No files loaded</div>
) : (
fileTree.map(node => (
fileTree.map((node) => (
<TreeItem
key={node.id}
node={node}
@@ -379,12 +406,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
)}
{activeTab === 'filters' && (
<div className="flex-1 overflow-y-auto scrollbar-thin p-3">
<div className="scrollbar-thin flex-1 overflow-y-auto p-3">
<div className="mb-3">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
Node Types
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Toggle visibility of node types in the graph
</p>
</div>
@@ -398,23 +425,21 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={label}
onClick={() => toggleLabelVisibility(label)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
} `}
>
<div
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
className={`flex h-5 w-5 items-center justify-center rounded ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
>
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
<Icon className="h-3 w-3" style={{ color: NODE_COLORS[label] }} />
</div>
<span className="text-xs flex-1">{label}</span>
<span className="flex-1 text-xs">{label}</span>
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
);
@@ -422,11 +447,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{/* Edge Type Toggles */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
Edge Types
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Toggle visibility of relationship types
</p>
@@ -439,21 +464,19 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={edgeType}
onClick={() => toggleEdgeVisibility(edgeType)}
className={`
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
${isVisible
className={`flex items-center gap-2.5 rounded px-2 py-1.5 text-left transition-colors ${
isVisible
? 'bg-elevated text-text-primary'
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
}
`}
} `}
>
<div
className={`w-6 h-1.5 rounded-full ${isVisible ? '' : 'opacity-40'}`}
className={`h-1.5 w-6 rounded-full ${isVisible ? '' : 'opacity-40'}`}
style={{ backgroundColor: info.color }}
/>
<span className="text-xs flex-1">{info.label}</span>
<span className="flex-1 text-xs">{info.label}</span>
<div
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
/>
</button>
);
@@ -462,12 +485,12 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{/* Depth Filter */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
<Target className="w-3 h-3 inline mr-1.5" />
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
<Target className="mr-1.5 inline h-3 w-3" />
Focus Depth
</h3>
<p className="text-[11px] text-text-muted mb-3">
<p className="mb-3 text-[11px] text-text-muted">
Show nodes within N hops of selection
</p>
@@ -482,13 +505,11 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
<button
key={label}
onClick={() => setDepthFilter(value)}
className={`
px-2 py-1 text-xs rounded transition-colors
${depthFilter === value
className={`rounded px-2 py-1 text-xs transition-colors ${
depthFilter === value
? 'bg-accent text-white'
: 'bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
}
`}
} `}
>
{label}
</button>
@@ -496,22 +517,33 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
{depthFilter !== null && !selectedNode && (
<p className="mt-2 text-[10px] text-amber-400">
Select a node to apply depth filter
</p>
<p className="mt-2 text-[10px] text-amber-400">Select a node to apply depth filter</p>
)}
</div>
{/* Legend */}
<div className="mt-6 pt-4 border-t border-border-subtle">
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-3">
<div className="mt-6 border-t border-border-subtle pt-4">
<h3 className="mb-3 text-xs font-medium tracking-wide text-text-secondary uppercase">
Color Legend
</h3>
<div className="grid grid-cols-2 gap-2">
{(['Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Function', 'Method', 'Variable', 'Decorator'] as NodeLabel[]).map(label => (
{(
[
'Folder',
'File',
'Class',
'Interface',
'Enum',
'Type',
'Function',
'Method',
'Variable',
'Decorator',
] as NodeLabel[]
).map((label) => (
<div key={label} className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: NODE_COLORS[label] }}
/>
<span className="text-[10px] text-text-muted">{label}</span>
@@ -524,7 +556,7 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
{/* Stats footer */}
{graph && (
<div className="px-3 py-2 border-t border-border-subtle bg-elevated/50">
<div className="border-t border-border-subtle bg-elevated/50 px-3 py-2">
<div className="flex items-center justify-between text-[10px] text-text-muted">
<span>{graph.nodes.length} nodes</span>
<span>{graph.relationships.length} edges</span>
@@ -534,4 +566,3 @@ export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
</div>
);
};
+111 -78
View File
@@ -1,9 +1,24 @@
import { useEffect, useCallback, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Focus, RotateCcw, Play, Pause, Lightbulb, LightbulbOff } from '@/lib/lucide-icons';
import {
ZoomIn,
ZoomOut,
Maximize2,
Focus,
RotateCcw,
Play,
Pause,
Lightbulb,
LightbulbOff,
} from '@/lib/lucide-icons';
import { useSigma } from '../hooks/useSigma';
import { useAppState } from '../hooks/useAppState';
import { knowledgeGraphToGraphology, filterGraphByDepth, SigmaNodeAttributes, SigmaEdgeAttributes } from '../lib/graph-adapter';
import type { GraphNode } from '../core/graph/types';
import {
knowledgeGraphToGraphology,
filterGraphByDepth,
SigmaNodeAttributes,
SigmaEdgeAttributes,
} from '../lib/graph-adapter';
import type { GraphNode } from 'gitnexus-shared';
import { QueryFAB } from './QueryFAB';
import Graph from 'graphology';
@@ -41,7 +56,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
for (const id of aiToolHighlightedNodeIds) next.add(id);
// Note: blast radius nodes are handled separately with red color
return next;
}, [highlightedNodeIds, aiCitationHighlightedNodeIds, aiToolHighlightedNodeIds, isAIHighlightsEnabled]);
}, [
highlightedNodeIds,
aiCitationHighlightedNodeIds,
aiToolHighlightedNodeIds,
isAIHighlightsEnabled,
]);
// Blast radius nodes (only when AI highlights enabled)
const effectiveBlastRadiusNodeIds = useMemo(() => {
@@ -57,26 +77,32 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
const nodeById = useMemo(() => {
if (!graph) return new Map<string, GraphNode>();
return new Map(graph.nodes.map(n => [n.id, n]));
return new Map(graph.nodes.map((n) => [n.id, n]));
}, [graph]);
const handleNodeClick = useCallback((nodeId: string) => {
if (!graph) return;
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
}, [graph, nodeById, setSelectedNode, openCodePanel]);
const handleNodeClick = useCallback(
(nodeId: string) => {
if (!graph) return;
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
},
[graph, nodeById, setSelectedNode, openCodePanel],
);
const handleNodeHover = useCallback((nodeId: string | null) => {
if (!nodeId || !graph) {
setHoveredNodeName(null);
return;
}
const node = nodeById.get(nodeId);
setHoveredNodeName(node ? node.properties.name : null);
}, [graph, nodeById]);
const handleNodeHover = useCallback(
(nodeId: string | null) => {
if (!nodeId || !graph) {
setHoveredNodeName(null);
return;
}
const node = nodeById.get(nodeId);
setHoveredNodeName(node ? node.properties.name : null);
},
[graph, nodeById],
);
const handleStageClick = useCallback(() => {
setSelectedNode(null);
@@ -91,7 +117,14 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
setSigmaSelectedNode(null);
}
toggleAIHighlights();
}, [isAIHighlightsEnabled, clearAIToolHighlights, clearAICitationHighlights, clearBlastRadius, setSelectedNode, toggleAIHighlights]);
}, [
isAIHighlightsEnabled,
clearAIToolHighlights,
clearAICitationHighlights,
clearBlastRadius,
setSelectedNode,
toggleAIHighlights,
]);
const {
containerRef,
@@ -117,19 +150,23 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
});
// Expose focusNode to parent via ref
useImperativeHandle(ref, () => ({
focusNode: (nodeId: string) => {
// Also update app state so the selection syncs properly
if (graph) {
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
useImperativeHandle(
ref,
() => ({
focusNode: (nodeId: string) => {
// Also update app state so the selection syncs properly
if (graph) {
const node = nodeById.get(nodeId);
if (node) {
setSelectedNode(node);
openCodePanel();
}
}
}
focusNode(nodeId);
}
}), [focusNode, graph, nodeById, setSelectedNode, openCodePanel]);
focusNode(nodeId);
},
}),
[focusNode, graph, nodeById, setSelectedNode, openCodePanel],
);
// Update Sigma graph when KnowledgeGraph changes
useEffect(() => {
@@ -138,7 +175,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
// Build communityMemberships map from MEMBER_OF relationships
// MEMBER_OF edges: nodeId -> communityId (stored as targetId)
const communityMemberships = new Map<string, number>();
graph.relationships.forEach(rel => {
graph.relationships.forEach((rel) => {
if (rel.type === 'MEMBER_OF') {
// Find the community node to get its index
const communityNode = nodeById.get(rel.targetId);
@@ -165,7 +202,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
filterGraphByDepth(sigmaGraph, appSelectedNode?.id || null, depthFilter, visibleLabels);
sigma.refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
// eslint-disable-next-line react-hooks/exhaustive-deps -- sigmaRef identity never changes
}, [visibleLabels, depthFilter, appSelectedNode]);
// Sync app selected node with sigma
@@ -192,16 +229,16 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
}, [setSelectedNode, setSigmaSelectedNode, resetZoom]);
return (
<div className="relative w-full h-full bg-void">
<div className="relative h-full w-full bg-void">
{/* Background gradient */}
<div className="absolute inset-0 pointer-events-none">
<div className="pointer-events-none absolute inset-0">
<div
className="absolute inset-0"
style={{
background: `
radial-gradient(circle at 50% 50%, rgba(124, 58, 237, 0.03) 0%, transparent 70%),
linear-gradient(to bottom, #06060a, #0a0a10)
`
`,
}}
/>
</div>
@@ -209,29 +246,27 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{/* Sigma container */}
<div
ref={containerRef}
className="sigma-container w-full h-full cursor-grab active:cursor-grabbing"
className="sigma-container h-full w-full cursor-grab active:cursor-grabbing"
/>
{/* Hovered node tooltip - only show when NOT selected */}
{hoveredNodeName && !sigmaSelectedNode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-elevated/95 border border-border-subtle rounded-lg backdrop-blur-sm z-20 pointer-events-none animate-fade-in">
<div className="pointer-events-none absolute top-4 left-1/2 z-20 -translate-x-1/2 animate-fade-in rounded-lg border border-border-subtle bg-elevated/95 px-3 py-1.5 backdrop-blur-sm">
<span className="font-mono text-sm text-text-primary">{hoveredNodeName}</span>
</div>
)}
{/* Selection info bar */}
{sigmaSelectedNode && appSelectedNode && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 bg-accent/20 border border-accent/30 rounded-xl backdrop-blur-sm z-20 animate-slide-up">
<div className="w-2 h-2 bg-accent rounded-full animate-pulse" />
<div className="absolute top-4 left-1/2 z-20 flex -translate-x-1/2 animate-slide-up items-center gap-2 rounded-xl border border-accent/30 bg-accent/20 px-4 py-2 backdrop-blur-sm">
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
<span className="font-mono text-sm text-text-primary">
{appSelectedNode.properties.name}
</span>
<span className="text-xs text-text-muted">
({appSelectedNode.label})
</span>
<span className="text-xs text-text-muted">({appSelectedNode.label})</span>
<button
onClick={handleClearSelection}
className="ml-2 px-2 py-0.5 text-xs text-text-secondary hover:text-text-primary hover:bg-white/10 rounded transition-colors"
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
</button>
@@ -239,40 +274,40 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
)}
{/* Graph Controls - Bottom Right */}
<div className="absolute bottom-4 right-4 flex flex-col gap-1 z-10">
<div className="absolute right-4 bottom-4 z-10 flex flex-col gap-1">
<button
onClick={zoomIn}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Zoom In"
>
<ZoomIn className="w-4 h-4" />
<ZoomIn className="h-4 w-4" />
</button>
<button
onClick={zoomOut}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Zoom Out"
>
<ZoomOut className="w-4 h-4" />
<ZoomOut className="h-4 w-4" />
</button>
<button
onClick={resetZoom}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Fit to Screen"
>
<Maximize2 className="w-4 h-4" />
<Maximize2 className="h-4 w-4" />
</button>
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
<div className="my-1 h-px bg-border-subtle" />
{/* Focus on selected */}
{appSelectedNode && (
<button
onClick={handleFocusSelected}
className="w-9 h-9 flex items-center justify-center bg-accent/20 border border-accent/30 rounded-md text-accent hover:bg-accent/30 transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-accent/30 bg-accent/20 text-accent transition-colors hover:bg-accent/30"
title="Focus on Selected Node"
>
<Focus className="w-4 h-4" />
<Focus className="h-4 w-4" />
</button>
)}
@@ -280,41 +315,35 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
{sigmaSelectedNode && (
<button
onClick={handleClearSelection}
className="w-9 h-9 flex items-center justify-center bg-elevated border border-border-subtle rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 items-center justify-center rounded-md border border-border-subtle bg-elevated text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="Clear Selection"
>
<RotateCcw className="w-4 h-4" />
<RotateCcw className="h-4 w-4" />
</button>
)}
{/* Divider */}
<div className="h-px bg-border-subtle my-1" />
<div className="my-1 h-px bg-border-subtle" />
{/* Layout control */}
<button
onClick={isLayoutRunning ? stopLayout : startLayout}
className={`
w-9 h-9 flex items-center justify-center border rounded-md transition-all
${isLayoutRunning
? 'bg-accent border-accent text-white shadow-glow animate-pulse'
: 'bg-elevated border-border-subtle text-text-secondary hover:bg-hover hover:text-text-primary'
}
`}
className={`flex h-9 w-9 items-center justify-center rounded-md border transition-all ${
isLayoutRunning
? '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'}
>
{isLayoutRunning ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
{isLayoutRunning ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
</div>
{/* Layout running indicator */}
{isLayoutRunning && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 border border-emerald-500/30 rounded-full backdrop-blur-sm z-10 animate-fade-in">
<div className="w-2 h-2 bg-emerald-400 rounded-full animate-ping" />
<span className="text-xs text-emerald-400 font-medium">Layout optimizing...</span>
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/20 px-3 py-1.5 backdrop-blur-sm">
<div className="h-2 w-2 animate-ping rounded-full bg-emerald-400" />
<span className="text-xs font-medium text-emerald-400">Layout optimizing...</span>
</div>
)}
@@ -327,13 +356,17 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle>((_, ref) => {
onClick={handleToggleAIHighlights}
className={
isAIHighlightsEnabled
? 'w-10 h-10 flex items-center justify-center bg-cyan-500/15 border border-cyan-400/40 rounded-lg text-cyan-200 hover:bg-cyan-500/20 hover:border-cyan-300/60 transition-colors'
: 'w-10 h-10 flex items-center justify-center bg-elevated border border-border-subtle rounded-lg text-text-muted hover:bg-hover hover:text-text-primary transition-colors'
? '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'}
data-testid="ai-highlights-toggle"
>
{isAIHighlightsEnabled ? <Lightbulb className="w-4 h-4" /> : <LightbulbOff className="w-4 h-4" />}
{isAIHighlightsEnabled ? (
<Lightbulb className="h-4 w-4" />
) : (
<LightbulbOff className="h-4 w-4" />
)}
</button>
</div>
</div>
+273 -79
View File
@@ -1,9 +1,29 @@
import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from '@/lib/lucide-icons';
import {
Search,
Settings,
HelpCircle,
Sparkles,
Github,
Star,
FolderOpen,
ChevronDown,
Trash2,
RefreshCw,
Loader2,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import type { RepoSummary } from '../services/server-connection';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { GraphNode } from '../core/graph/types';
import {
deleteRepo,
fetchRepos,
startAnalyze,
streamAnalyzeProgress,
type BackendRepo,
type JobProgress,
} from '../services/backend-client';
import { useState, useMemo, useRef, useEffect } from 'react';
import { GraphNode } from 'gitnexus-shared';
import { EmbeddingStatus } from './EmbeddingStatus';
import { RepoAnalyzer } from './RepoAnalyzer';
// Color mapping for node types in search results
const NODE_TYPE_COLORS: Record<string, string> = {
@@ -20,11 +40,21 @@ const NODE_TYPE_COLORS: Record<string, string> = {
interface HeaderProps {
onFocusNode?: (nodeId: string) => void;
availableRepos?: RepoSummary[];
availableRepos?: BackendRepo[];
onSwitchRepo?: (repoName: string) => void;
/** Called when a newly-analyzed repo is ready; triggers connectToServer. */
onAnalyzeComplete?: (repoName: string) => void;
/** Called after a repo is deleted or list needs refresh. */
onReposChanged?: (repos: BackendRepo[]) => void;
}
export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => {
export const Header = ({
onFocusNode,
availableRepos = [],
onSwitchRepo,
onAnalyzeComplete,
onReposChanged,
}: HeaderProps) => {
const {
projectName,
graph,
@@ -32,11 +62,15 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
isRightPanelOpen,
rightPanelTab,
setSettingsPanelOpen,
setHelpDialogBoxOpen
setHelpDialogBoxOpen,
} = useAppState();
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false);
const [showAnalyzer, setShowAnalyzer] = useState(false);
const [reanalyzing, setReanalyzing] = useState<string | null>(null); // repo name being re-analyzed
const [reanalyzeProgress, setReanalyzeProgress] = useState<JobProgress | null>(null);
const reanalyzeSseRef = useRef<AbortController | null>(null);
const repoDropdownRef = useRef<HTMLDivElement>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const searchRef = useRef<HTMLDivElement>(null);
@@ -51,11 +85,11 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
const query = searchQuery.toLowerCase();
return graph.nodes
.filter(node => node.properties.name.toLowerCase().includes(query))
.filter((node) => node.properties.name.toLowerCase().includes(query))
.slice(0, 10); // Limit to 10 results
}, [graph, searchQuery]);
// Handle clicking outside to close dropdowns
// Handle clicking outside search or repo dropdown to close them
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
@@ -63,12 +97,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
}
if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) {
setIsRepoDropdownOpen(false);
setShowAnalyzer(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Cleanup re-analyze SSE on unmount
useEffect(() => {
return () => {
reanalyzeSseRef.current?.abort();
};
}, []);
// Keyboard shortcut (Cmd+K / Ctrl+K)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -92,10 +134,10 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, searchResults.length - 1));
setSelectedIndex((i) => Math.min(i + 1, searchResults.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const selected = searchResults[selectedIndex];
@@ -114,59 +156,215 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
};
return (
<header className="flex items-center justify-between px-5 py-3 bg-deep border-b border-dashed border-border-subtle">
<header className="flex items-center justify-between border-b border-dashed border-border-subtle bg-deep px-5 py-3">
{/* Left section */}
<div className="flex items-center gap-4">
{/* Logo */}
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-md shadow-glow text-white text-sm font-bold">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-gradient-to-br from-accent to-node-interface text-sm font-bold text-white shadow-glow">
◇
</div>
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
<span className="text-[15px] font-semibold tracking-tight">GitNexus</span>
</div>
{/* Project badge / Repo selector dropdown */}
{/* Project badge + repo dropdown */}
{projectName && (
<div className="relative" ref={repoDropdownRef}>
<button
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
onClick={() => {
setIsRepoDropdownOpen((prev) => !prev);
setShowAnalyzer(false);
}}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-1.5 text-sm transition-all ${
isRepoDropdownOpen
? 'border-accent/40 bg-accent/10 text-text-primary'
: 'border-border-subtle bg-surface text-text-secondary hover:border-border-default hover:bg-hover'
} `}
>
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[200px]">{projectName}</span>
{availableRepos.length >= 2 && (
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
)}
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-node-function" />
<span className="max-w-[160px] truncate">{projectName}</span>
<ChevronDown
className={`h-3 w-3 text-text-muted transition-transform duration-200 ${isRepoDropdownOpen ? 'rotate-180' : ''}`}
/>
</button>
{/* Repo dropdown */}
{isRepoDropdownOpen && availableRepos.length >= 2 && (
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
{availableRepos.map((repo) => {
const isCurrent = repo.name === projectName;
return (
<button
key={repo.name}
onClick={() => {
if (!isCurrent && onSwitchRepo) {
onSwitchRepo(repo.name);
}
{isRepoDropdownOpen && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-80 animate-slide-up overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
{showAnalyzer ? (
<div className="p-4">
<RepoAnalyzer
variant="sheet"
onComplete={(repoName) => {
setShowAnalyzer(false);
setIsRepoDropdownOpen(false);
onAnalyzeComplete?.(repoName);
}}
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
{repo.name}
onCancel={() => setShowAnalyzer(false)}
/>
</div>
) : (
<>
{/* Repo list */}
{availableRepos.length > 0 && (
<div>
<div className="px-3 pt-2.5 pb-1.5 text-[10px] font-medium tracking-wider text-text-muted uppercase">
Repositories
</div>
<div className="text-xs text-text-muted mt-0.5">
{repo.stats?.nodes ?? '?'} nodes &middot; {repo.stats?.files ?? '?'} files
{availableRepos.map((repo) => (
<div
key={repo.name}
className={`group flex items-center gap-2 px-4 py-2 transition-colors ${
repo.name === projectName
? 'border-l-2 border-accent bg-accent/10'
: 'hover:bg-hover'
}`}
>
<button
onClick={() => {
if (repo.name !== projectName) onSwitchRepo?.(repo.name);
setIsRepoDropdownOpen(false);
}}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-node-folder" />
<span className="flex-1 truncate font-mono text-sm text-text-primary">
{repo.name}
</span>
{repo.name === projectName && (
<span className="shrink-0 font-mono text-[10px] text-accent">
active
</span>
)}
</button>
{/* Re-analyze */}
<button
onClick={async (e) => {
e.stopPropagation();
if (reanalyzing) return; // already running
setReanalyzing(repo.name);
setReanalyzeProgress({
phase: 'queued',
percent: 0,
message: 'Starting...',
});
try {
const { jobId } = await startAnalyze({
path: repo.path,
force: true,
});
reanalyzeSseRef.current = streamAnalyzeProgress(
jobId,
(p) => setReanalyzeProgress(p),
() => {
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
onAnalyzeComplete?.(repo.name);
},
(errMsg) => {
console.error('Re-analyze failed:', errMsg);
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
},
);
} catch (err) {
console.error('Failed to start re-analysis:', err);
setReanalyzing(null);
setReanalyzeProgress(null);
}
}}
disabled={!!reanalyzing}
className={`cursor-pointer rounded p-1 transition-all ${
reanalyzing === repo.name
? 'text-accent'
: 'text-text-muted/0 group-hover:text-text-muted hover:!text-accent'
}`}
title={
reanalyzing === repo.name
? 'Re-analyzing...'
: `Re-analyze ${repo.name}`
}
>
<RefreshCw
className={`h-3.5 w-3.5 ${reanalyzing === repo.name ? 'animate-spin' : ''}`}
/>
</button>
{/* Delete */}
<button
onClick={async (e) => {
e.stopPropagation();
// Abort any running re-analysis for this repo
if (reanalyzing === repo.name) {
reanalyzeSseRef.current?.abort();
setReanalyzing(null);
setReanalyzeProgress(null);
reanalyzeSseRef.current = null;
}
try {
await deleteRepo(repo.name);
const updated = await fetchRepos();
onReposChanged?.(updated);
// If we deleted the active repo, switch to first available
if (repo.name === projectName && updated.length > 0) {
onSwitchRepo?.(updated[0].name);
} else if (updated.length === 0) {
// No repos left — go back to onboarding
window.location.reload();
}
} catch (err) {
console.error('Failed to delete repo:', err);
}
}}
className="cursor-pointer rounded p-1 text-text-muted/0 transition-all group-hover:text-text-muted hover:!text-red-400"
title={`Delete ${repo.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Re-analyze progress bar */}
{reanalyzing && reanalyzeProgress && (
<div className="border-t border-border-subtle bg-accent/5 px-4 py-2.5">
<div className="mb-1.5 flex items-center gap-2">
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-accent" />
<span className="truncate text-xs text-text-secondary">
Re-analyzing {reanalyzing}: {reanalyzeProgress.message}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-accent transition-all duration-300"
style={{ width: `${Math.max(2, reanalyzeProgress.percent)}%` }}
/>
</div>
</div>
</button>
);
})}
)}
{/* Analyze new */}
<div
className={
availableRepos.length > 0 || reanalyzing
? 'border-t border-border-subtle'
: ''
}
>
<button
onClick={() => setShowAnalyzer(true)}
disabled={!!reanalyzing}
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
>
<Sparkles className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="text-sm text-text-secondary">
Analyze a new repository...
</span>
</button>
</div>
</>
)}
</div>
)}
</div>
@@ -174,9 +372,9 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
</div>
{/* Center - Search */}
<div className="flex-1 max-w-md mx-6 relative" ref={searchRef}>
<div className="flex items-center gap-2.5 px-3.5 py-2 bg-surface border border-border-subtle rounded-lg transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="w-4 h-4 text-text-muted flex-shrink-0" />
<div className="relative mx-6 max-w-md flex-1" ref={searchRef}>
<div className="flex items-center gap-2.5 rounded-lg border border-border-subtle bg-surface px-3.5 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="h-4 w-4 flex-shrink-0 text-text-muted" />
<input
ref={inputRef}
type="text"
@@ -189,19 +387,19 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
}}
onFocus={() => setIsSearchOpen(true)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
/>
<kbd className="px-1.5 py-0.5 bg-elevated border border-border-subtle rounded text-[10px] text-text-muted font-mono">
<kbd className="rounded border border-border-subtle bg-elevated px-1.5 py-0.5 font-mono text-[10px] text-text-muted">
⌘K
</kbd>
</div>
{/* Search Results Dropdown */}
{isSearchOpen && searchQuery.trim() && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-border-subtle bg-surface shadow-xl">
{searchResults.length === 0 ? (
<div className="px-4 py-3 text-sm text-text-muted">
No nodes found for "{searchQuery}"
No nodes found for &ldquo;{searchQuery}&rdquo;
</div>
) : (
<div className="max-h-80 overflow-y-auto">
@@ -209,22 +407,20 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
<button
key={node.id}
onClick={() => handleSelectNode(node)}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${index === selectedIndex
? 'bg-accent/20 text-text-primary'
: 'hover:bg-hover text-text-secondary'
}`}
className={`flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition-colors ${
index === selectedIndex
? 'bg-accent/20 text-text-primary'
: 'text-text-secondary hover:bg-hover'
}`}
>
{/* Node type indicator */}
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{ backgroundColor: NODE_TYPE_COLORS[node.label] || '#6b7280' }}
/>
{/* Node name */}
<span className="flex-1 truncate text-sm font-medium">
{node.properties.name}
</span>
{/* Node type badge */}
<span className="text-xs text-text-muted px-2 py-0.5 bg-elevated rounded">
<span className="rounded bg-elevated px-2 py-0.5 text-xs text-text-muted">
{node.label}
</span>
</button>
@@ -242,17 +438,17 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
href="https://github.com/abhigyanpatwari/GitNexus"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3.5 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl hover:-translate-y-0.5 transition-all duration-200 group"
className="group flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-pink-600 px-3.5 py-2 text-sm font-medium text-white shadow-lg transition-all duration-200 hover:-translate-y-0.5 hover:from-purple-500 hover:to-pink-500 hover:shadow-xl"
>
<Github className="w-4 h-4" />
<Github className="h-4 w-4" />
<span className="hidden sm:inline">Star if cool</span>
<Star className="w-3.5 h-3.5 group-hover:fill-yellow-300 group-hover:text-yellow-300 transition-all" />
<Star className="h-3.5 w-3.5 transition-all group-hover:fill-yellow-300 group-hover:text-yellow-300" />
<span className="hidden sm:inline">✨</span>
</a>
{/* Stats */}
{graph && (
<div className="flex items-center gap-4 mr-2 text-xs text-text-muted">
<div className="mr-2 flex items-center gap-4 text-xs text-text-muted">
<span>{nodeCount} nodes</span>
<span>{edgeCount} edges</span>
</div>
@@ -264,34 +460,32 @@ export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: Heade
{/* Icon buttons */}
<button
onClick={() => setSettingsPanelOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors"
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
title="AI Settings"
>
<Settings className="w-4.5 h-4.5" />
<Settings className="h-4.5 w-4.5" />
</button>
<button
title="Help"
onClick={() => setHelpDialogBoxOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-md text-text-secondary hover:bg-hover hover:text-text-primary transition-colors">
<HelpCircle className="w-4.5 h-4.5" />
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
<HelpCircle className="h-4.5 w-4.5" />
</button>
{/* AI Button */}
<button
onClick={openChatPanel}
className={`
flex items-center gap-1.5 px-3.5 py-2 rounded-lg text-sm font-medium transition-all
${isRightPanelOpen && rightPanelTab === 'chat'
className={`flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium transition-all ${
isRightPanelOpen && rightPanelTab === 'chat'
? 'bg-accent text-white shadow-glow'
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:shadow-lg hover:-translate-y-0.5'
}
`}
: 'bg-gradient-to-r from-accent to-accent-dim text-white shadow-glow hover:-translate-y-0.5 hover:shadow-lg'
} `}
>
<Sparkles className="w-4 h-4" />
<Sparkles className="h-4 w-4" />
<span>Nexus AI</span>
</button>
</div>
</header>
);
};
File diff suppressed because it is too large Load Diff
+19 -22
View File
@@ -1,4 +1,4 @@
import { PipelineProgress } from '../types/pipeline';
import type { PipelineProgress } from 'gitnexus-shared';
interface LoadingOverlayProps {
progress: PipelineProgress;
@@ -6,24 +6,24 @@ interface LoadingOverlayProps {
export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
return (
<div className="fixed inset-0 flex flex-col items-center justify-center bg-void z-50">
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-void">
{/* Background gradient effects */}
<div className="absolute inset-0 pointer-events-none">
<div className="absolute top-1/3 left-1/3 w-96 h-96 bg-accent/10 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-1/3 right-1/3 w-96 h-96 bg-node-interface/10 rounded-full blur-3xl animate-pulse" />
<div className="pointer-events-none absolute inset-0">
<div className="absolute top-1/3 left-1/3 h-96 w-96 animate-pulse rounded-full bg-accent/10 blur-3xl" />
<div className="absolute right-1/3 bottom-1/3 h-96 w-96 animate-pulse rounded-full bg-node-interface/10 blur-3xl" />
</div>
{/* Pulsing orb */}
<div className="relative mb-10">
<div className="w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full animate-pulse-glow" />
<div className="absolute inset-0 w-28 h-28 bg-gradient-to-br from-accent to-node-interface rounded-full blur-xl opacity-50" />
<div className="h-28 w-28 animate-pulse-glow rounded-full bg-gradient-to-br from-accent to-node-interface" />
<div className="absolute inset-0 h-28 w-28 rounded-full bg-gradient-to-br from-accent to-node-interface opacity-50 blur-xl" />
</div>
{/* Progress bar */}
<div className="w-80 mb-4">
<div className="h-1.5 bg-elevated rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300 ease-out"
<div className="mb-4 w-80">
<div className="h-1.5 overflow-hidden rounded-full bg-elevated">
<div
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300 ease-out"
style={{ width: `${progress.percent}%` }}
/>
</div>
@@ -31,14 +31,12 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
{/* Status text */}
<div className="text-center">
<p className="font-mono text-sm text-text-secondary mb-1">
<p className="mb-1 font-mono text-sm text-text-secondary">
{progress.message}
<span className="animate-pulse">|</span>
</p>
{progress.detail && (
<p className="font-mono text-xs text-text-muted truncate max-w-md">
{progress.detail}
</p>
<p className="max-w-md truncate font-mono text-xs text-text-muted">{progress.detail}</p>
)}
</div>
@@ -46,21 +44,20 @@ export const LoadingOverlay = ({ progress }: LoadingOverlayProps) => {
{progress.stats && (
<div className="mt-8 flex items-center gap-6 text-xs text-text-muted">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-node-file rounded-full" />
<span>{progress.stats.filesProcessed} / {progress.stats.totalFiles} files</span>
<span className="h-2 w-2 rounded-full bg-node-file" />
<span>
{progress.stats.filesProcessed} / {progress.stats.totalFiles} files
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-node-function rounded-full" />
<span className="h-2 w-2 rounded-full bg-node-function" />
<span>{progress.stats.nodesCreated} nodes</span>
</div>
</div>
)}
{/* Percent */}
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">
{progress.percent}%
</p>
<p className="mt-4 font-mono text-3xl font-semibold text-text-primary">{progress.percent}%</p>
</div>
);
};
+198 -189
View File
@@ -9,213 +9,222 @@ import { Copy, Check } from '@/lib/lucide-icons';
// Custom syntax theme
const customTheme = {
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
...vscDarkPlus,
'pre[class*="language-"]': {
...vscDarkPlus['pre[class*="language-"]'],
background: '#0a0a10',
margin: 0,
padding: '16px 0',
fontSize: '13px',
lineHeight: '1.6',
},
'code[class*="language-"]': {
...vscDarkPlus['code[class*="language-"]'],
background: 'transparent',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
},
};
interface MarkdownRendererProps {
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean;
content: string;
onLinkClick?: (href: string) => void;
toolCalls?: any[]; // Keep flexible for now
showCopyButton?: boolean;
}
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
onLinkClick,
toolCalls,
showCopyButton = false
content,
onLinkClick,
toolCalls,
showCopyButton = false,
}) => {
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [copied, setCopied] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
useEffect(() => {
return () => {
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
};
}, []);
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]]
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-–]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
if (copyTimerRef.current) {
clearTimeout(copyTimerRef.current);
}
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Pattern 2: Node grounding - [[Type:Name]]
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
}
);
}
return parts.join('```');
};
const handleLinkClick = React.useCallback((e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
}, [onLinkClick]);
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
const markdownComponents = React.useMemo(() => ({
a: ({ href, children, ...props }: any) => {
const hrefStr = href || '';
// Grounding links (Code refs & Node refs)
if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) {
const isNodeRef = hrefStr.startsWith('node-ref:');
const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes.
// Styles
const baseParams = "code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors";
const colorParams = isNodeRef
? "border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70"
: "border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70";
return (
<a
href={hrefStr}
onClick={(e) => handleLinkClick(e, hrefStr)}
className={`${baseParams} ${colorParams}`}
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
{...props}
>
<span className="text-inherit">{children}</span>
</a>
);
}
// External links
return (
<a
href={hrefStr}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
// Helper to format text for display (convert [[links]] to markdown links)
const formatMarkdownForDisplay = (md: string) => {
// Avoid rewriting inside fenced code blocks.
const parts = md.split('```');
for (let i = 0; i < parts.length; i += 2) {
// Pattern 1: File grounding - [[file.ext]]
parts[i] = parts[i].replace(
/\[\[([a-zA-Z0-9_\-./\\]+\.[a-zA-Z0-9]+(?::\d+(?:[-–]\d+)?)?)\]\]/g,
(_m, inner: string) => {
const trimmed = inner.trim();
const href = `code-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
},
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
);
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
// Render Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram code={codeContent} />;
}
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
// Pattern 2: Node grounding - [[Type:Name]]
parts[i] = parts[i].replace(
/\[\[(?:graph:)?(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):([^\]]+)\]\]/g,
(_m, nodeType: string, nodeName: string) => {
const trimmed = `${nodeType}:${nodeName.trim()}`;
const href = `node-ref:${encodeURIComponent(trimmed)}`;
return `[${trimmed}](${href})`;
},
pre: ({ children }: any) => <>{children}</>,
}), [handleLinkClick]);
);
}
return parts.join('```');
};
return (
<div className="text-text-primary text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={(url) => {
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
// Default behavior for http/https/etc
return url;
}}
components={markdownComponents}
const handleLinkClick = React.useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
if (href.startsWith('code-ref:') || href.startsWith('node-ref:')) {
e.preventDefault();
onLinkClick?.(href);
}
// External links open in new tab (default behavior)
},
[onLinkClick],
);
const formattedContent = React.useMemo(() => formatMarkdownForDisplay(content), [content]);
const markdownComponents = React.useMemo(
() => ({
a: ({ href, children, ...props }: any) => {
const hrefStr = href || '';
// Grounding links (Code refs & Node refs)
if (hrefStr.startsWith('code-ref:') || hrefStr.startsWith('node-ref:')) {
const isNodeRef = hrefStr.startsWith('node-ref:');
const inner = decodeURIComponent(hrefStr.slice(isNodeRef ? 9 : 9)); // length is same? wait.. code-ref: (9), node-ref: (9). Yes.
// Styles
const baseParams =
'code-ref-btn inline-flex items-center px-2 py-0.5 rounded-md font-mono text-[12px] !no-underline hover:!no-underline transition-colors';
const colorParams = isNodeRef
? 'border border-amber-300/55 bg-amber-400/10 !text-amber-200 visited:!text-amber-200 hover:bg-amber-400/15 hover:border-amber-200/70'
: 'border border-cyan-300/55 bg-cyan-400/10 !text-cyan-200 visited:!text-cyan-200 hover:bg-cyan-400/15 hover:border-cyan-200/70';
return (
<a
href={hrefStr}
onClick={(e) => handleLinkClick(e, hrefStr)}
className={`${baseParams} ${colorParams}`}
title={isNodeRef ? `View ${inner} in Code panel` : `Open in Code panel • ${inner}`}
{...props}
>
{formattedContent}
</ReactMarkdown>
<span className="text-inherit">{children}</span>
</a>
);
}
{/* Copy Button */}
{showCopyButton && (
<div className="mt-2 flex justify-end">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-2 py-1 text-xs text-text-muted hover:text-text-primary hover:bg-surface border border-transparent hover:border-border-subtle rounded transition-all"
title="Copy to clipboard"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
)}
// External links
return (
<a
href={hrefStr}
className="text-accent underline underline-offset-2 hover:text-purple-300"
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
);
},
code: ({ className, children, ...props }: any) => {
const match = /language-(\w+)/.exec(className || '');
const isInline = !className && !match;
const codeContent = String(children).replace(/\n$/, '');
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{toolCalls.map(tc => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
if (isInline) {
return <code {...props}>{children}</code>;
}
const language = match ? match[1] : 'text';
// Render Mermaid diagrams
if (language === 'mermaid') {
return <MermaidDiagram code={codeContent} />;
}
return (
<SyntaxHighlighter
style={customTheme}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '14px 16px',
borderRadius: '8px',
fontSize: '13px',
background: '#0a0a10',
border: '1px solid #1e1e2a',
}}
>
{codeContent}
</SyntaxHighlighter>
);
},
pre: ({ children }: any) => <>{children}</>,
}),
[handleLinkClick],
);
return (
<div className="text-sm text-text-primary">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={(url) => {
if (url.startsWith('code-ref:') || url.startsWith('node-ref:')) return url;
// Default behavior for http/https/etc
return url;
}}
components={markdownComponents}
>
{formattedContent}
</ReactMarkdown>
{/* Copy Button */}
{showCopyButton && (
<div className="mt-2 flex justify-end">
<button
onClick={handleCopy}
className="flex items-center gap-1.5 rounded border border-transparent px-2 py-1 text-xs text-text-muted transition-all hover:border-border-subtle hover:bg-surface hover:text-text-primary"
title="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
<span>{copied ? 'Copied' : 'Copy'}</span>
</button>
</div>
);
)}
{/* Tool Call Cards appended at the bottom if provided */}
{toolCalls && toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{toolCalls.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} defaultExpanded={false} />
))}
</div>
)}
</div>
);
};
+37 -35
View File
@@ -2,7 +2,6 @@ import { Suspense, useEffect, useRef, useState, lazy } from 'react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { AlertTriangle, Maximize2 } from '@/lib/lucide-icons';
import type { ProcessData } from '../lib/mermaid-generator';
const ProcessFlowModal = lazy(() =>
import('./ProcessFlowModal').then((m) => ({ default: m.ProcessFlowModal })),
@@ -71,11 +70,14 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
// Render the diagram
const { svg: renderedSvg } = await mermaid.render(id, code.trim());
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
const sanitizedSvg = DOMPurify.sanitize(renderedSvg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
setSvg(sanitizedSvg);
setError(null);
} catch (err) {
// Silent catch for streaming:
// Silent catch for streaming:
// If render fails (common during partial streaming), we:
// 1. Log to console for debugging
// 2. Do NOT set error state (avoids flashing red box)
@@ -93,29 +95,31 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
}, [code]);
// Create a pseudo ProcessData for the modal (with custom rawMermaid property)
const processData: any = showModal ? {
id: 'ai-generated',
label: 'AI Generated Diagram',
processType: 'intra_community',
steps: [], // Empty - we'll render raw mermaid
edges: [],
clusters: [],
rawMermaid: code, // Pass raw mermaid code
} : null;
const processData: any = showModal
? {
id: 'ai-generated',
label: 'AI Generated Diagram',
processType: 'intra_community',
steps: [], // Empty - we'll render raw mermaid
edges: [],
clusters: [],
rawMermaid: code, // Pass raw mermaid code
}
: null;
if (error) {
return (
<div className="my-3 p-4 bg-rose-500/10 border border-rose-500/30 rounded-lg">
<div className="flex items-center gap-2 text-rose-300 text-sm mb-2">
<AlertTriangle className="w-4 h-4" />
<div className="my-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-4">
<div className="mb-2 flex items-center gap-2 text-sm text-rose-300">
<AlertTriangle className="h-4 w-4" />
<span className="font-medium">Diagram Error</span>
</div>
<pre className="text-xs text-rose-200/70 font-mono whitespace-pre-wrap">{error}</pre>
<pre className="font-mono text-xs whitespace-pre-wrap text-rose-200/70">{error}</pre>
<details className="mt-2">
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary">
<summary className="cursor-pointer text-xs text-text-muted hover:text-text-secondary">
Show source
</summary>
<pre className="mt-2 p-2 bg-surface rounded text-xs text-text-muted overflow-x-auto">
<pre className="mt-2 overflow-x-auto rounded bg-surface p-2 text-xs text-text-muted">
{code}
</pre>
</details>
@@ -125,42 +129,40 @@ export const MermaidDiagram = ({ code }: MermaidDiagramProps) => {
return (
<>
<div className="my-3 relative group">
<div className="relative bg-gradient-to-b from-surface to-elevated border border-border-subtle rounded-xl overflow-hidden">
<div className="group relative my-3">
<div className="relative overflow-hidden rounded-xl border border-border-subtle bg-gradient-to-b from-surface to-elevated">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface/60 border-b border-border-subtle">
<span className="text-[10px] text-text-muted uppercase tracking-wider font-medium">
<div className="flex items-center justify-between border-b border-border-subtle bg-surface/60 px-3 py-2">
<span className="text-[10px] font-medium tracking-wider text-text-muted uppercase">
Diagram
</span>
<button
onClick={() => setShowModal(true)}
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Expand"
>
<Maximize2 className="w-3.5 h-3.5" />
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Diagram container */}
<div
ref={containerRef}
className="flex items-center justify-center p-4 overflow-auto max-h-[400px]"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] }) }}
className="flex max-h-[400px] items-center justify-center overflow-auto p-4"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
}),
}}
/>
</div>
</div>
{/* Use ProcessFlowModal for expansion */}
{showModal && processData && (
<Suspense
fallback={
<div className="p-4 text-sm text-text-muted">Loading diagram…</div>
}
>
<ProcessFlowModal
process={processData}
onClose={() => setShowModal(false)}
/>
<Suspense fallback={<div className="p-4 text-sm text-text-muted">Loading diagram…</div>}>
<ProcessFlowModal process={processData} onClose={() => setShowModal(false)} />
</Suspense>
)}
</>
@@ -0,0 +1,316 @@
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';
// ── Design constants ─────────────────────────────────────────────────────────
const isDev = import.meta.env.DEV;
// ── Copy-to-clipboard button ─────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard API requires secure context; localhost qualifies
}
};
return (
<button
onClick={handleCopy}
aria-label={copied ? 'Copied!' : 'Copy to clipboard'}
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 transition-all duration-200 focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:outline-none ${
copied
? 'bg-emerald-400/10 text-emerald-400'
: 'text-text-muted hover:bg-white/5 hover:text-text-primary'
} `}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
// ── Faux terminal window ─────────────────────────────────────────────────────
function TerminalWindow({
command,
label,
isActive = false,
}: {
command: string;
label: string;
isActive?: boolean;
}) {
return (
<div
className={`overflow-hidden rounded-xl border transition-all duration-300 ${
isActive
? 'border-accent/40 shadow-glow-soft'
: 'border-border-default hover:border-accent/20 hover:shadow-glow-soft'
} `}
>
{/* Title bar */}
<div className="flex items-center gap-2 border-b border-border-subtle bg-deep px-4 py-2.5">
<div className="flex gap-1.5">
<div className="h-2.5 w-2.5 rounded-full bg-red-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-yellow-500/60" />
<div className="h-2.5 w-2.5 rounded-full bg-emerald-500/60" />
</div>
<span className="flex-1 text-center font-mono text-[11px] text-text-muted">{label}</span>
<CopyButton text={command} />
</div>
{/* Command body */}
<div className="flex items-center gap-3 bg-void px-4 py-3.5 font-mono text-sm">
<span className="text-accent/60 select-none" aria-hidden="true">
$
</span>
<code className="flex-1 overflow-x-auto tracking-wide whitespace-nowrap text-text-primary">
{command}
</code>
</div>
</div>
);
}
// ── Step indicator ───────────────────────────────────────────────────────────
type StepState = 'waiting' | 'active' | 'done';
function StepDot({ state, number }: { state: StepState; number: number }) {
if (state === 'done') {
return (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-emerald-500/50 bg-emerald-500/20">
<Check className="h-3 w-3 text-emerald-400" />
</div>
);
}
if (state === 'active') {
return (
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center">
<div className="absolute inset-0 animate-ping rounded-full border border-accent/30" />
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-accent/60 bg-accent/20">
<span className="text-[10px] leading-none font-semibold text-accent">{number}</span>
</div>
</div>
);
}
return (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border-subtle bg-elevated">
<span className="text-[10px] leading-none font-semibold text-text-muted">{number}</span>
</div>
);
}
function StepRow({
state,
number,
title,
description,
children,
}: {
state: StepState;
number: number;
title: string;
description?: string;
children?: React.ReactNode;
}) {
const isVisible = state !== 'waiting';
return (
<div
className={`transition-all duration-300 ${state === 'waiting' ? 'opacity-40' : 'opacity-100'} `}
>
<div className="flex items-start gap-3">
<StepDot state={state} number={number} />
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-2">
<span
className={`text-sm font-medium transition-colors duration-200 ${
state === 'done'
? 'text-emerald-400'
: state === 'active'
? 'text-text-primary'
: 'text-text-muted'
}`}
>
{title}
</span>
{state === 'done' && (
<span className="animate-fade-in font-mono text-[10px] tracking-wider text-emerald-400/60 uppercase">
done
</span>
)}
</div>
{description && (
<p className="mt-0.5 text-xs leading-relaxed text-text-muted">{description}</p>
)}
{isVisible && children && <div className="mt-3 animate-slide-up">{children}</div>}
</div>
</div>
</div>
);
}
// ── Polling status bar ────────────────────────────────────────────────────────
function PollingBar() {
return (
<div
className="flex animate-fade-in items-center gap-3 rounded-xl border border-accent/15 bg-accent/5 px-4 py-3"
aria-live="polite"
role="status"
>
<div className="relative shrink-0">
<Zap className="h-4 w-4 text-accent/70" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-5 w-5 animate-pulse rounded-full border border-accent/25" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-text-secondary">
Listening for server
<span className="ml-0.5 inline-flex text-text-muted">
<span className="animate-pulse">...</span>
</span>
</p>
<p className="mt-0.5 text-[11px] text-text-muted">Will auto-connect when detected</p>
</div>
</div>
);
}
// ── OnboardingGuide ───────────────────────────────────────────────────────────
interface OnboardingGuideProps {
isPolling?: boolean;
}
export const OnboardingGuide = ({ isPolling }: OnboardingGuideProps) => {
const primary = isDev ? 'npm run --prefix gitnexus serve' : 'npx gitnexus@latest serve';
const termLabel = isDev ? 'Start backend' : '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.
const step1State: StepState = isPolling ? 'done' : 'active';
const step2State: StepState = isPolling ? 'active' : 'waiting';
const step3State: StepState = 'waiting';
return (
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient background glows */}
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* ── Headline ─────────────────────────────────────────────── */}
<div className="relative mb-6">
<div className="text-center">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Start your local server
</h2>
<p className="mx-auto mt-1 max-w-xs text-sm leading-relaxed text-text-secondary">
{isDev
? 'Fire up the Express backend in a separate terminal to unlock the full graph.'
: 'One command is all it takes. The browser connects automatically.'}
</p>
</div>
</div>
{/* ── Step-by-step flow ───────────────────────────────────────── */}
<div className="relative space-y-5">
{/* Vertical connector line behind the dots */}
<div
className="pointer-events-none absolute top-6 bottom-6 left-[11px] w-px bg-border-subtle"
aria-hidden="true"
/>
{/* Step 1 — Copy the command */}
<StepRow
state={step1State}
number={1}
title="Copy the command"
description={isPolling ? undefined : 'Click the icon in the terminal to copy.'}
>
<TerminalWindow command={primary} label={termLabel} isActive={step1State === 'active'} />
{/* Secondary global-install option — production only */}
{!isDev && (
<>
<div className="my-3 flex items-center gap-3">
<div className="h-px flex-1 bg-border-subtle" />
<span className="text-[11px] tracking-widest text-text-muted uppercase">
or install globally
</span>
<div className="h-px flex-1 bg-border-subtle" />
</div>
<TerminalWindow
command="npm install -g gitnexus && gitnexus serve"
label="Global install"
isActive={false}
/>
</>
)}
</StepRow>
{/* Step 2 — Run and wait */}
<StepRow
state={step2State}
number={2}
title={isPolling ? 'Waiting for server to start' : 'Paste and run in your terminal'}
description={
isPolling ? undefined : 'Open a terminal at the project root, paste, and hit Enter.'
}
>
{isPolling && <PollingBar />}
</StepRow>
{/* Step 3 — Auto-connect */}
<StepRow
state={step3State}
number={3}
title="Auto-connects and opens the graph"
description="No refresh needed — the page detects the server automatically."
/>
</div>
{/* ── Prerequisite footnote ────────────────────────────────────── */}
<div className="mt-6 flex items-center justify-center gap-1.5 border-t border-border-subtle pt-5 text-xs text-text-muted">
<Server className="h-3 w-3 shrink-0" />
<span>
Requires{' '}
<a
href="https://nodejs.org"
target="_blank"
rel="noopener noreferrer"
className="text-accent transition-colors hover:text-accent/80 hover:underline"
>
Node.js {REQUIRED_NODE_VERSION}+
</a>
</span>
<span className="mx-1 text-border-default">·</span>
<Terminal className="h-3 w-3 shrink-0" />
<span>Port 4747</span>
</div>
</div>
);
};
+267 -250
View File
@@ -1,305 +1,322 @@
/**
* Process Flow Modal
*
*
* Displays a Mermaid flowchart for a process in a centered modal popup.
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { X, GitBranch, Copy, Focus, Layers, ZoomIn, ZoomOut } from 'lucide-react';
import { Copy, Focus, ZoomIn, ZoomOut } from 'lucide-react';
import mermaid from 'mermaid';
import DOMPurify from 'dompurify';
import { ProcessData, generateProcessMermaid } from '../lib/mermaid-generator';
interface ProcessFlowModalProps {
process: ProcessData | null;
onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean;
process: ProcessData | null;
onClose: () => void;
onFocusInGraph?: (nodeIds: string[], processId: string) => void;
isFullScreen?: boolean;
}
// Initialize mermaid with cyan/purple theme matching GitNexus
// Initialize mermaid with cyan/purple theme matching GitNexus
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base',
themeVariables: {
primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee',
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 50,
nodeSpacing: 120,
rankSpacing: 140,
htmlLabels: true,
},
startOnLoad: false,
suppressErrorRendering: true, // Try to suppress if supported
maxTextSize: 900000, // Increase from default 50000 to handle large combined diagrams
theme: 'base',
themeVariables: {
primaryColor: '#1e293b', // node bg
primaryTextColor: '#f1f5f9',
primaryBorderColor: '#22d3ee',
lineColor: '#94a3b8',
secondaryColor: '#1e293b',
tertiaryColor: '#0f172a',
mainBkg: '#1e293b', // background
nodeBorder: '#22d3ee',
clusterBkg: '#1e293b',
clusterBorder: '#475569',
titleColor: '#f1f5f9',
edgeLabelBackground: '#0f172a',
},
flowchart: {
curve: 'basis',
padding: 50,
nodeSpacing: 120,
rankSpacing: 140,
htmlLabels: true,
},
});
// Suppress distinct syntax error overlay
mermaid.parseError = (err) => {
// Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err);
// Suppress visual error - we handle errors in the render try/catch
console.debug('Mermaid parse error (suppressed):', err);
};
export const ProcessFlowModal = ({ process, onClose, onFocusInGraph, isFullScreen = false }: ProcessFlowModalProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Full process map gets higher default zoom (667%) and max zoom (3000%)
const defaultZoom = isFullScreen ? 6.67 : 1;
const maxZoom = isFullScreen ? 30 : 10;
const [zoom, setZoom] = useState(defaultZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Reset zoom when switching between full screen and regular mode
useEffect(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [isFullScreen, defaultZoom]);
export const ProcessFlowModal = ({
process,
onClose,
onFocusInGraph,
isFullScreen = false,
}: ProcessFlowModalProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const diagramRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Handle zoom with scroll wheel
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom(prev => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
// Full process map gets higher default zoom (667%) and max zoom (3000%)
const defaultZoom = isFullScreen ? 6.67 : 1;
const maxZoom = isFullScreen ? 30 : 10;
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
const [zoom, setZoom] = useState(defaultZoom);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '+' || e.key === '=') {
setZoom(prev => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
setZoom(prev => Math.max(prev - 0.2, 0.1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [maxZoom]);
// Reset zoom when switching between full screen and regular mode
useEffect(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [isFullScreen, defaultZoom]);
// Zoom in/out handlers
const handleZoomIn = useCallback(() => {
setZoom(prev => Math.min(prev + 0.25, maxZoom));
}, [maxZoom]);
// Handle zoom with scroll wheel
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY * -0.001;
setZoom((prev) => Math.min(Math.max(0.1, prev + delta), maxZoom));
};
const handleZoomOut = useCallback(() => {
setZoom(prev => Math.max(prev - 0.25, 0.1));
}, []);
const container = scrollContainerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}
}, [process, maxZoom]); // Re-attach when process or maxZoom changes
// Handle pan with mouse drag
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
}, [pan]);
// Handle keyboard zoom
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === '+' || e.key === '=') {
setZoom((prev) => Math.min(prev + 0.2, maxZoom));
} else if (e.key === '-' || e.key === '_') {
setZoom((prev) => Math.max(prev - 0.2, 0.1));
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [maxZoom]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
}, [isPanning, panStart]);
// Zoom in/out handlers
const handleZoomIn = useCallback(() => {
setZoom((prev) => Math.min(prev + 0.25, maxZoom));
}, [maxZoom]);
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
const handleZoomOut = useCallback(() => {
setZoom((prev) => Math.max(prev - 0.25, 0.1));
}, []);
const resetView = useCallback(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
// Handle pan with mouse drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsPanning(true);
setPanStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
},
[pan],
);
// Render mermaid diagram
useEffect(() => {
if (!process || !diagramRef.current) return;
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanning) return;
setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y });
},
[isPanning, panStart],
);
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// Clear previous content
diagramRef.current!.innerHTML = '';
const resetView = useCallback(() => {
setZoom(defaultZoom);
setPan({ x: 0, y: 0 });
}, [defaultZoom]);
const { svg } = await mermaid.render(id, mermaidCode);
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, ADD_TAGS: ['foreignObject'] });
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
// Render mermaid diagram
useEffect(() => {
if (!process || !diagramRef.current) return;
diagramRef.current!.innerHTML = `
const renderDiagram = async () => {
try {
// Check if we have raw mermaid code (from AI chat) or need to generate it
const mermaidCode = process.rawMermaid
? process.rawMermaid
: generateProcessMermaid(process);
const id = `mermaid-${Date.now()}`;
// Clear previous content
diagramRef.current!.innerHTML = '';
const { svg } = await mermaid.render(id, mermaidCode);
if (!diagramRef.current) return;
diagramRef.current!.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
} catch (error) {
console.error('Mermaid render error:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isSizeError = errorMessage.includes('Maximum') || errorMessage.includes('exceeded');
diagramRef.current!.innerHTML = `
<div class="text-center p-8">
<div class="text-red-400 text-sm font-medium mb-2">
${isSizeError ? '📊 Diagram Too Large' : '⚠️ Render Error'}
</div>
<div class="text-slate-400 text-xs max-w-md">
${isSizeError
? `This diagram has ${process.steps?.length || 0} steps and is too complex to render. Try viewing individual processes instead of "All Processes".`
: `Unable to render diagram. Steps: ${process.steps?.length || 0}`
}
${
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}`
}
</div>
</div>
`;
}
};
}
};
renderDiagram();
}, [process]);
renderDiagram();
}, [process]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose();
}
}, [onClose]);
// Copy mermaid code to clipboard
const handleCopyMermaid = useCallback(async () => {
if (!process) return;
const mermaidCode = generateProcessMermaid(process);
await navigator.clipboard.writeText(mermaidCode);
}, [process]);
// Focus in graph
const handleFocusInGraph = useCallback(() => {
if (!process || !onFocusInGraph) return;
const nodeIds = process.steps.map(s => s.id);
onFocusInGraph(nodeIds, process.id);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose();
}, [process, onFocusInGraph, onClose]);
}
},
[onClose],
);
if (!process) return null;
// Copy mermaid code to clipboard
const handleCopyMermaid = useCallback(async () => {
if (!process) return;
const mermaidCode = generateProcessMermaid(process);
await navigator.clipboard.writeText(mermaidCode);
}, [process]);
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 animate-fade-in"
onClick={handleBackdropClick}
data-testid="process-modal"
>
{/* Glassmorphism Modal */}
<div className={`bg-slate-900/60 backdrop-blur-2xl border border-white/10 rounded-3xl shadow-2xl shadow-cyan-500/10 flex flex-col animate-scale-in overflow-hidden relative ${isFullScreen
? 'w-[98%] h-[95vh] max-w-none'
: 'w-[95%] max-w-5xl max-h-[90vh]'
}`}>
{/* Subtle gradient overlay for extra glass feel */}
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
// Focus in graph
const handleFocusInGraph = useCallback(() => {
if (!process || !onFocusInGraph) return;
const nodeIds = process.steps.map((s) => s.id);
onFocusInGraph(nodeIds, process.id);
onClose();
}, [process, onFocusInGraph, onClose]);
{/* Header */}
<div className="px-6 py-5 border-b border-white/10 relative z-10">
<h2 className="text-lg font-semibold text-white">
Process: {process.label}
</h2>
</div>
if (!process) return null;
{/* Diagram */}
<div
ref={scrollContainerRef}
className={`flex-1 p-8 flex items-center justify-center relative z-10 overflow-hidden ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
>
<div
ref={diagramRef}
className="[&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400 transition-transform origin-center w-fit h-fit"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
/>
</div>
return (
<div
ref={containerRef}
className="fixed inset-0 z-50 flex animate-fade-in items-center justify-center bg-black/20"
onClick={handleBackdropClick}
data-testid="process-modal"
>
{/* Glassmorphism Modal */}
<div
className={`animate-scale-in relative flex flex-col overflow-hidden rounded-3xl border border-white/10 bg-slate-900/60 shadow-2xl shadow-cyan-500/10 backdrop-blur-2xl ${
isFullScreen ? 'h-[95vh] w-[98%] max-w-none' : 'max-h-[90vh] w-[95%] max-w-5xl'
}`}
>
{/* Subtle gradient overlay for extra glass feel */}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/5 to-transparent" />
{/* Footer Actions */}
<div className="flex items-center justify-center gap-3 px-6 py-4 border-t border-white/10 bg-slate-900/50 relative z-10">
{/* Zoom controls */}
<div className="flex items-center gap-1 bg-white/5 border border-white/10 rounded-lg p-1">
<button
onClick={handleZoomOut}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom out (-)"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="px-2 text-xs text-slate-400 font-mono min-w-[3rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 text-slate-300 hover:text-white hover:bg-white/10 rounded-md transition-all"
title="Zoom in (+)"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
<button
onClick={resetView}
className="flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
title="Reset zoom and pan"
>
Reset View
</button>
{onFocusInGraph && (
<button
onClick={handleFocusInGraph}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-slate-900 bg-cyan-400 hover:bg-cyan-300 rounded-lg transition-all shadow-lg shadow-cyan-500/20"
>
<Focus className="w-4 h-4" />
Toggle Focus
</button>
)}
<button
onClick={handleCopyMermaid}
className="flex items-center gap-2 px-5 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-500 rounded-lg transition-all shadow-lg shadow-purple-500/20"
>
<Copy className="w-4 h-4" />
Copy Mermaid
</button>
<button
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-slate-300 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg transition-all"
>
Close
</button>
</div>
</div>
{/* Header */}
<div className="relative z-10 border-b border-white/10 px-6 py-5">
<h2 className="text-lg font-semibold text-white">Process: {process.label}</h2>
</div>
);
{/* Diagram */}
<div
ref={scrollContainerRef}
className={`relative z-10 flex flex-1 items-center justify-center overflow-hidden p-8 ${isFullScreen ? 'min-h-[70vh]' : 'min-h-[400px]'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
>
<div
ref={diagramRef}
className="h-fit w-fit origin-center transition-transform [&_.edgePath_.path]:stroke-slate-400 [&_.edgePath_.path]:stroke-2 [&_.marker]:fill-slate-400"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
}}
/>
</div>
{/* Footer Actions */}
<div className="relative z-10 flex items-center justify-center gap-3 border-t border-white/10 bg-slate-900/50 px-6 py-4">
{/* Zoom controls */}
<div className="flex items-center gap-1 rounded-lg border border-white/10 bg-white/5 p-1">
<button
onClick={handleZoomOut}
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Zoom out (-)"
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="min-w-[3rem] px-2 text-center font-mono text-xs text-slate-400">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="rounded-md p-2 text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Zoom in (+)"
>
<ZoomIn className="h-4 w-4" />
</button>
</div>
<button
onClick={resetView}
className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
title="Reset zoom and pan"
>
Reset View
</button>
{onFocusInGraph && (
<button
onClick={handleFocusInGraph}
className="flex items-center gap-2 rounded-lg bg-cyan-400 px-5 py-2.5 text-sm font-medium text-slate-900 shadow-lg shadow-cyan-500/20 transition-all hover:bg-cyan-300"
>
<Focus className="h-4 w-4" />
Toggle Focus
</button>
)}
<button
onClick={handleCopyMermaid}
className="flex items-center gap-2 rounded-lg bg-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg shadow-purple-500/20 transition-all hover:bg-purple-500"
>
<Copy className="h-4 w-4" />
Copy Mermaid
</button>
<button
onClick={onClose}
className="rounded-lg border border-white/10 bg-white/5 px-5 py-2.5 text-sm font-medium text-slate-300 transition-all hover:bg-white/10 hover:text-white"
>
Close
</button>
</div>
</div>
</div>
);
};
+481 -438
View File
@@ -1,12 +1,23 @@
/**
* Processes Panel
*
*
* Lists all detected processes grouped by type (cross-community / intra-community).
* Clicking a process opens the ProcessFlowModal with a flowchart.
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { GitBranch, Search, Eye, Zap, Home, ChevronDown, ChevronRight, Sparkles, Lightbulb, Layers } from 'lucide-react';
import {
GitBranch,
Search,
Eye,
Zap,
Home,
ChevronDown,
ChevronRight,
Sparkles,
Lightbulb,
Layers,
} from 'lucide-react';
import { useAppState } from '../hooks/useAppState';
import { ProcessFlowModal } from './ProcessFlowModal';
import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
@@ -15,502 +26,534 @@ import type { ProcessData, ProcessStep } from '../lib/mermaid-generator';
const isSafeId = (id: string): boolean => /^[a-zA-Z0-9_:.\-/@]+$/.test(id);
export const ProcessesPanel = () => {
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['cross', 'intra']));
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
const { graph, runQuery, setHighlightedNodeIds, highlightedNodeIds } = useAppState();
const [searchQuery, setSearchQuery] = useState('');
const [selectedProcess, setSelectedProcess] = useState<ProcessData | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['cross', 'intra']),
);
const [loadingProcess, setLoadingProcess] = useState<string | null>(null);
const [focusedProcessId, setFocusedProcessId] = useState<string | null>(null);
// Extract processes from graph
const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] };
// Extract processes from graph
const processes = useMemo(() => {
if (!graph) return { cross: [], intra: [] };
const processNodes = graph.nodes.filter(n => n.label === 'Process');
const processNodes = graph.nodes.filter((n) => n.label === 'Process');
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const cross: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
const intra: Array<{ id: string; label: string; stepCount: number; clusters: string[] }> = [];
for (const node of processNodes) {
const item = {
id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [],
};
for (const node of processNodes) {
const item = {
id: node.id,
label: node.properties.heuristicLabel || node.properties.name || node.id,
stepCount: node.properties.stepCount || 0,
clusters: node.properties.communities || [],
};
if (node.properties.processType === 'cross_community') {
cross.push(item);
} else {
intra.push(item);
}
}
if (node.properties.processType === 'cross_community') {
cross.push(item);
} else {
intra.push(item);
}
}
// Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount);
// Sort by step count (most complex first)
cross.sort((a, b) => b.stepCount - a.stepCount);
intra.sort((a, b) => b.stepCount - a.stepCount);
return { cross, intra };
}, [graph]);
return { cross, intra };
}, [graph]);
// Filter by search
const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes;
// Filter by search
const filteredProcesses = useMemo(() => {
if (!searchQuery.trim()) return processes;
const query = searchQuery.toLowerCase();
return {
cross: processes.cross.filter(p => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter(p => p.label.toLowerCase().includes(query)),
};
}, [processes, searchQuery]);
const query = searchQuery.toLowerCase();
return {
cross: processes.cross.filter((p) => p.label.toLowerCase().includes(query)),
intra: processes.intra.filter((p) => p.label.toLowerCase().includes(query)),
};
}, [processes, searchQuery]);
// Toggle section expansion
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// Toggle section expansion
const toggleSection = useCallback((section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
// Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all');
// Load ALL processes and combine into one mega-diagram
const handleViewAllProcesses = useCallback(async () => {
setLoadingProcess('all');
try {
const allProcessIds = [...processes.cross, ...processes.intra].map(p => p.id).filter(isSafeId);
try {
const allProcessIds = [...processes.cross, ...processes.intra]
.map((p) => p.id)
.filter(isSafeId);
if (allProcessIds.length === 0) return;
if (allProcessIds.length === 0) return;
// Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = [];
// Collect all steps from all processes
const allStepsMap = new Map<string, ProcessStep>();
const allEdges: Array<{ from: string; to: string; type: string }> = [];
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
// Optimization: Fetch all steps in one query if possible
const allStepsQuery = `
// Fetch steps for all processes concurrently in batches if needed, but for now sequentially to be safe
// Optimization: Fetch all steps in one query if possible
const allStepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE p.id IN [${allProcessIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE p.id IN [${allProcessIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
`;
const stepsResult = await runQuery(allStepsQuery);
const stepsResult = await runQuery(allStepsQuery);
for (const row of stepsResult) {
const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, {
id: stepId,
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
});
}
}
for (const row of stepsResult) {
const stepId = row.id || row[0];
if (!allStepsMap.has(stepId)) {
allStepsMap.set(stepId, {
id: stepId,
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
});
}
}
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map(s => s.id).filter(isSafeId);
const allSteps = Array.from(allStepsMap.values());
const stepIds = allSteps.map((s) => s.id).filter(isSafeId);
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
// Batch query if too many steps
const edgesQuery = `
// Query for all CALLS edges between the combined steps
if (stepIds.length > 0) {
// Batch query if too many steps
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
allEdges.push(...edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to));
} catch (err) {
console.warn('Could not fetch combined edges:', err);
}
}
const combinedProcessData: ProcessData = {
id: 'combined-all',
label: `All Processes (${allProcessIds.length} combined)`,
processType: 'cross_community', // Treat as cross-community for styling
steps: allSteps,
edges: allEdges,
clusters: [],
};
setSelectedProcess(combinedProcessData);
} catch (error) {
console.error('Failed to load combined processes:', error);
} finally {
setLoadingProcess(null);
}
}, [processes, runQuery]);
// Load process steps and open modal
const handleViewProcess = useCallback(async (processId: string, label: string, processType: string) => {
if (!isSafeId(processId)) return;
setLoadingProcess(processId);
try {
// Query for process steps
const stepsQuery = `
const edgesResult = await runQuery(edgesQuery);
allEdges.push(
...edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter((edge) => edge.from !== edge.to),
);
} catch (err) {
console.warn('Could not fetch combined edges:', err);
}
}
const combinedProcessData: ProcessData = {
id: 'combined-all',
label: `All Processes (${allProcessIds.length} combined)`,
processType: 'cross_community', // Treat as cross-community for styling
steps: allSteps,
edges: allEdges,
clusters: [],
};
setSelectedProcess(combinedProcessData);
} catch (error) {
console.error('Failed to load combined processes:', error);
} finally {
setLoadingProcess(null);
}
}, [processes, runQuery]);
// Load process steps and open modal
const handleViewProcess = useCallback(
async (processId: string, label: string, processType: string) => {
if (!isSafeId(processId)) return;
setLoadingProcess(processId);
try {
// Query for process steps
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id, s.name AS name, s.filePath AS filePath, r.step AS stepNumber
ORDER BY r.step
`;
const stepsResult = await runQuery(stepsQuery);
const stepsResult = await runQuery(stepsQuery);
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0],
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
}));
const steps: ProcessStep[] = stepsResult.map((row: any) => ({
id: row.id || row[0],
name: row.name || row[1] || 'Unknown',
filePath: row.filePath || row[2],
stepNumber: row.stepNumber || row.step || row[3] || 0,
}));
// Get step IDs for edge query
const stepIds = steps.map(s => s.id).filter(isSafeId);
// Get step IDs for edge query
const stepIds = steps.map((s) => s.id).filter(isSafeId);
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) {
const edgesQuery = `
// Query for CALLS edges between the steps in this process
let edges: Array<{ from: string; to: string; type: string }> = [];
if (stepIds.length > 0) {
const edgesQuery = `
MATCH (from)-[r:CodeRelation {type: 'CALLS'}]->(to)
WHERE from.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map(id => `'${id.replace(/'/g, "''")}'`).join(',')}]
WHERE from.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
AND to.id IN [${stepIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(',')}]
RETURN from.id AS fromId, to.id AS toId, r.type AS type
`;
try {
const edgesResult = await runQuery(edgesQuery);
edges = edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter(edge => edge.from !== edge.to); // Remove self-loops
} catch (err) {
console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear
}
}
// Get clusters for this process
const processNode = graph?.nodes.find(n => n.id === processId);
const clusters = processNode?.properties.communities || [];
const processData: ProcessData = {
id: processId,
label,
processType: processType as 'cross_community' | 'intra_community',
steps,
edges,
clusters,
};
setSelectedProcess(processData);
} catch (error) {
console.error('Failed to load process steps:', error);
} finally {
setLoadingProcess(null);
}
}, [runQuery, graph]);
// Cache for process steps (so we don't re-query when toggling focus)
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(async (processId: string) => {
if (!isSafeId(processId)) return;
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
return;
try {
const edgesResult = await runQuery(edgesQuery);
edges = edgesResult
.map((row: any) => ({
from: row.fromId || row[0],
to: row.toId || row[1],
type: row.type || row[2] || 'CALLS',
}))
.filter((edge) => edge.from !== edge.to); // Remove self-loops
} catch (err) {
console.warn('Could not fetch edges:', err);
// Continue with empty edges - will fallback to linear
}
}
// Check if we have cached steps
if (processStepsCache.has(processId)) {
const stepIds = processStepsCache.get(processId)!;
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Get clusters for this process
const processNode = graph?.nodes.find((n) => n.id === processId);
const clusters = processNode?.properties.communities || [];
// Load steps for this process
setLoadingProcess(processId);
try {
const stepsQuery = `
const processData: ProcessData = {
id: processId,
label,
processType: processType as 'cross_community' | 'intra_community',
steps,
edges,
clusters,
};
setSelectedProcess(processData);
} catch (error) {
console.error('Failed to load process steps:', error);
} finally {
setLoadingProcess(null);
}
},
[runQuery, graph],
);
// Cache for process steps (so we don't re-query when toggling focus)
const [processStepsCache, setProcessStepsCache] = useState<Map<string, string[]>>(new Map());
// Toggle focus for any process - loads steps on demand
const handleToggleFocusForProcess = useCallback(
async (processId: string) => {
if (!isSafeId(processId)) return;
// If already focused on this process, turn off
if (focusedProcessId === processId) {
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
return;
}
// Check if we have cached steps
if (processStepsCache.has(processId)) {
const stepIds = processStepsCache.get(processId)!;
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
return;
}
// Load steps for this process
setLoadingProcess(processId);
try {
const stepsQuery = `
MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process {id: '${processId.replace(/'/g, "''")}'})
RETURN s.id AS id
`;
const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
const stepsResult = await runQuery(stepsQuery);
const stepIds = stepsResult.map((row: any) => row.id || row[0]);
// Cache the result
setProcessStepsCache(prev => new Map(prev).set(processId, stepIds));
// Cache the result
setProcessStepsCache((prev) => new Map(prev).set(processId, stepIds));
// Set focus
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
} catch (error) {
console.error('Failed to load process steps for focus:', error);
} finally {
setLoadingProcess(null);
}
}, [focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds]);
// Set focus
setHighlightedNodeIds(new Set(stepIds));
setFocusedProcessId(processId);
} catch (error) {
console.error('Failed to load process steps for focus:', error);
} finally {
setLoadingProcess(null);
}
},
[focusedProcessId, processStepsCache, runQuery, setHighlightedNodeIds],
);
// Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback((nodeIds: string[], processId: string) => {
// Check if this process is already focused
if (focusedProcessId === processId) {
// Clear focus
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
} else {
// Set focus and cache
setHighlightedNodeIds(new Set(nodeIds));
setFocusedProcessId(processId);
setProcessStepsCache(prev => new Map(prev).set(processId, nodeIds));
}
}, [focusedProcessId, setHighlightedNodeIds]);
// Focus in graph callback - toggles highlight (used by modal)
const handleFocusInGraph = useCallback(
(nodeIds: string[], processId: string) => {
// Check if this process is already focused
if (focusedProcessId === processId) {
// Clear focus
setHighlightedNodeIds(new Set());
setFocusedProcessId(null);
} else {
// Set focus and cache
setHighlightedNodeIds(new Set(nodeIds));
setFocusedProcessId(processId);
setProcessStepsCache((prev) => new Map(prev).set(processId, nodeIds));
}
},
[focusedProcessId, setHighlightedNodeIds],
);
// Clear focused process when highlights are cleared externally
useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(null);
}
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-surface rounded-xl">
<GitBranch className="w-7 h-7 text-text-muted" />
</div>
<h3 className="text-base font-medium text-text-primary mb-2">No Processes Detected</h3>
<p className="text-sm text-text-secondary max-w-xs">
Processes are execution flows traced from entry points. Load a codebase to see detected processes.
</p>
</div>
);
// Clear focused process when highlights are cleared externally
useEffect(() => {
if (highlightedNodeIds.size === 0 && focusedProcessId !== null) {
setFocusedProcessId(null);
}
}, [highlightedNodeIds, focusedProcessId]);
const totalCount = processes.cross.length + processes.intra.length;
if (totalCount === 0) {
return (
<div className="flex flex-col h-full">
{/* Header with search */}
<div className="p-3 border-b border-border-subtle">
<div className="flex items-center gap-2 mb-2">
<div className="flex-1 flex items-center gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-lg focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="w-4 h-4 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter processes..."
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted" data-testid="process-list-loaded">
<span>{totalCount} processes detected</span>
</div>
</div>
{/* Process list */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button
onClick={handleViewAllProcesses}
disabled={loadingProcess !== null}
className="w-full flex items-center gap-3 p-3 bg-elevated/40 hover:bg-elevated/80 border border-border-subtle hover:border-cyan-500/30 rounded-xl transition-all group shadow-sm hover:shadow-cyan-900/10 text-left"
>
<div className="p-2 bg-cyan-500/10 rounded-lg group-hover:bg-cyan-500/20 transition-colors">
<Layers className="w-5 h-5 text-cyan-400" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">Full Process Map</h4>
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
</div>
{loadingProcess === 'all' ? (
<span className="animate-spin mr-1">
<Sparkles className="w-4 h-4 text-cyan-400" />
</span>
) : (
<Eye className="w-4 h-4 text-text-muted group-hover:text-cyan-400" />
)}
</button>
</div>
{/* Cross-Community Section */}
{filteredProcesses.cross.length > 0 && (
<div className="border-b border-border-subtle">
<button
onClick={() => toggleSection('cross')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('cross') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Zap className="w-4 h-4 text-amber-400" />
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.cross.length}
</span>
</button>
{expandedSections.has('cross') && (
<div className="pb-2">
{filteredProcesses.cross.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
{/* Intra-Community Section */}
{filteredProcesses.intra.length > 0 && (
<div>
<button
onClick={() => toggleSection('intra')}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-hover transition-colors"
>
{expandedSections.has('intra') ? (
<ChevronDown className="w-4 h-4 text-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted" />
)}
<Home className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
<span className="ml-auto text-xs text-text-muted bg-surface px-2 py-0.5 rounded-full">
{filteredProcesses.intra.length}
</span>
</button>
{expandedSections.has('intra') && (
<div className="pb-2">
{filteredProcesses.intra.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modal */}
<ProcessFlowModal
process={selectedProcess}
onClose={() => setSelectedProcess(null)}
onFocusInGraph={handleFocusInGraph}
isFullScreen={selectedProcess?.id === 'combined-all'}
/>
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-surface">
<GitBranch className="h-7 w-7 text-text-muted" />
</div>
<h3 className="mb-2 text-base font-medium text-text-primary">No Processes Detected</h3>
<p className="max-w-xs text-sm text-text-secondary">
Processes are execution flows traced from entry points. Load a codebase to see detected
processes.
</p>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Header with search */}
<div className="border-b border-border-subtle p-3">
<div className="mb-2 flex items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<Search className="h-4 w-4 text-text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filter processes..."
className="flex-1 border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
/>
</div>
</div>
<div
className="flex items-center gap-2 text-xs text-text-muted"
data-testid="process-list-loaded"
>
<span>{totalCount} processes detected</span>
</div>
</div>
{/* Process list */}
<div className="scrollbar-thin flex-1 overflow-y-auto">
{/* View All Processes Card */}
<div className="px-4 py-3">
<button
onClick={handleViewAllProcesses}
disabled={loadingProcess !== null}
className="group flex w-full items-center gap-3 rounded-xl border border-border-subtle bg-elevated/40 p-3 text-left shadow-sm transition-all hover:border-cyan-500/30 hover:bg-elevated/80 hover:shadow-cyan-900/10"
>
<div className="rounded-lg bg-cyan-500/10 p-2 transition-colors group-hover:bg-cyan-500/20">
<Layers className="h-5 w-5 text-cyan-400" />
</div>
<div className="flex-1">
<h4 className="text-sm font-medium text-text-primary group-hover:text-cyan-200">
Full Process Map
</h4>
<p className="text-xs text-text-muted">View combined map of {totalCount} processes</p>
</div>
{loadingProcess === 'all' ? (
<span className="mr-1 animate-spin">
<Sparkles className="h-4 w-4 text-cyan-400" />
</span>
) : (
<Eye className="h-4 w-4 text-text-muted group-hover:text-cyan-400" />
)}
</button>
</div>
{/* Cross-Community Section */}
{filteredProcesses.cross.length > 0 && (
<div className="border-b border-border-subtle">
<button
onClick={() => toggleSection('cross')}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
>
{expandedSections.has('cross') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
<Zap className="h-4 w-4 text-amber-400" />
<span className="text-sm font-medium text-text-primary">Cross-Community</span>
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
{filteredProcesses.cross.length}
</span>
</button>
{expandedSections.has('cross') && (
<div className="pb-2">
{filteredProcesses.cross.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'cross_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
{/* Intra-Community Section */}
{filteredProcesses.intra.length > 0 && (
<div>
<button
onClick={() => toggleSection('intra')}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left transition-colors hover:bg-hover"
>
{expandedSections.has('intra') ? (
<ChevronDown className="h-4 w-4 text-text-muted" />
) : (
<ChevronRight className="h-4 w-4 text-text-muted" />
)}
<Home className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-text-primary">Intra-Community</span>
<span className="ml-auto rounded-full bg-surface px-2 py-0.5 text-xs text-text-muted">
{filteredProcesses.intra.length}
</span>
</button>
{expandedSections.has('intra') && (
<div className="pb-2">
{filteredProcesses.intra.map((process) => (
<ProcessItem
key={process.id}
process={process}
isLoading={loadingProcess === process.id}
isSelected={selectedProcess?.id === process.id}
isFocused={focusedProcessId === process.id}
onView={() => handleViewProcess(process.id, process.label, 'intra_community')}
onToggleFocus={() => handleToggleFocusForProcess(process.id)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Modal */}
<ProcessFlowModal
process={selectedProcess}
onClose={() => setSelectedProcess(null)}
onFocusInGraph={handleFocusInGraph}
isFullScreen={selectedProcess?.id === 'combined-all'}
/>
</div>
);
};
// Individual process item
interface ProcessItemProps {
process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean;
isSelected: boolean;
isFocused: boolean;
onView: () => void;
onToggleFocus: () => void;
process: { id: string; label: string; stepCount: number; clusters: string[] };
isLoading: boolean;
isSelected: boolean;
isFocused: boolean;
onView: () => void;
onToggleFocus: () => void;
}
const ProcessItem = ({ process, isLoading, isSelected, isFocused, onView, onToggleFocus }: ProcessItemProps) => {
// 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'
: isSelected
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
: '';
const ProcessItem = ({
process,
isLoading,
isSelected,
isFocused,
onView,
onToggleFocus,
}: ProcessItemProps) => {
// 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'
: isSelected
? 'bg-cyan-950/40 border border-cyan-500/50 ring-1 ring-cyan-400/30'
: '';
return (
<div data-testid="process-row" className={`flex items-center gap-2 px-4 py-2 mx-2 rounded-lg hover:bg-hover group transition-all ${rowClass}`}>
<GitBranch className="w-4 h-4 text-text-muted flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm text-text-primary truncate">{process.label}</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{process.stepCount} steps</span>
{process.clusters.length > 0 && (
<>
<span>•</span>
<span>{process.clusters.length} clusters</span>
</>
)}
</div>
</div>
{/* Lightbulb icon - appears on hover, always visible when focused */}
<button
onClick={onToggleFocus}
className={`p-1.5 rounded-md transition-all ${isFocused
? 'text-amber-400 hover:text-amber-300 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-400/40 animate-pulse opacity-100'
: 'text-text-muted hover:text-cyan-400 bg-white/5 hover:bg-cyan-500/20 border border-white/10 hover:border-cyan-400/40 opacity-0 group-hover:opacity-100'
}`}
title={isFocused ? 'Click to remove highlight from graph' : 'Click to highlight in graph'}
data-testid="process-highlight-button"
>
<Lightbulb className="w-4 h-4" />
</button>
<button
onClick={onView}
disabled={isLoading}
data-testid="process-view-button"
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-all disabled:opacity-50 shadow-sm ${isSelected
? 'text-cyan-300 bg-cyan-900/60 border border-cyan-400/60 opacity-100'
: 'text-cyan-400 hover:text-cyan-300 bg-cyan-950/30 hover:bg-cyan-900/50 border border-cyan-500/30 hover:border-cyan-400/50 opacity-0 group-hover:opacity-100 shadow-cyan-900/20'
}`}
>
{isLoading ? (
<span className="animate-pulse">Loading...</span>
) : isSelected ? (
<>
<Eye className="w-3.5 h-3.5" />
Viewing
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
View
</>
)}
</button>
return (
<div
data-testid="process-row"
className={`group mx-2 flex items-center gap-2 rounded-lg px-4 py-2 transition-all hover:bg-hover ${rowClass}`}
>
<GitBranch className="h-4 w-4 flex-shrink-0 text-text-muted" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-text-primary">{process.label}</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<span>{process.stepCount} steps</span>
{process.clusters.length > 0 && (
<>
<span>•</span>
<span>{process.clusters.length} clusters</span>
</>
)}
</div>
);
</div>
{/* Lightbulb icon - appears on hover, always visible when focused */}
<button
onClick={onToggleFocus}
className={`rounded-md p-1.5 transition-all ${
isFocused
? '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'}
data-testid="process-highlight-button"
>
<Lightbulb className="h-4 w-4" />
</button>
<button
onClick={onView}
disabled={isLoading}
data-testid="process-view-button"
className={`flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all disabled:opacity-50 ${
isSelected
? 'border border-cyan-400/60 bg-cyan-900/60 text-cyan-300 opacity-100'
: 'border border-cyan-500/30 bg-cyan-950/30 text-cyan-400 opacity-0 shadow-cyan-900/20 group-hover:opacity-100 hover:border-cyan-400/50 hover:bg-cyan-900/50 hover:text-cyan-300'
}`}
>
{isLoading ? (
<span className="animate-pulse">Loading...</span>
) : isSelected ? (
<>
<Eye className="h-3.5 w-3.5" />
Viewing
</>
) : (
<>
<Eye className="h-3.5 w-3.5" />
View
</>
)}
</button>
</div>
);
};
+72 -103
View File
@@ -1,5 +1,14 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Play, X, ChevronDown, ChevronUp, Loader2, Sparkles, Table } from '@/lib/lucide-icons';
import {
Terminal,
Play,
X,
ChevronDown,
ChevronUp,
Loader2,
Sparkles,
Table,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
const EXAMPLE_QUERIES = [
@@ -26,7 +35,15 @@ const EXAMPLE_QUERIES = [
];
export const QueryFAB = () => {
const { setHighlightedNodeIds, setQueryResult, queryResult, clearQueryHighlights, graph, runQuery, isDatabaseReady } = useAppState();
const {
setHighlightedNodeIds,
setQueryResult,
queryResult,
clearQueryHighlights,
graph,
runQuery,
isDatabaseReady,
} = useAppState();
const [isExpanded, setIsExpanded] = useState(false);
const [query, setQuery] = useState('');
@@ -95,12 +112,12 @@ export const QueryFAB = () => {
const nodeIdPattern = /^(File|Function|Class|Method|Interface|Folder|CodeElement):/;
const nodeIds = rows
.flatMap(row => {
.flatMap((row) => {
const ids: string[] = [];
if (Array.isArray(row)) {
// Array format - check all elements for node ID patterns
row.forEach(val => {
row.forEach((val) => {
if (typeof val === 'string' && (nodeIdPattern.test(val) || val.includes(':'))) {
ids.push(val);
}
@@ -169,25 +186,12 @@ export const QueryFAB = () => {
return (
<button
onClick={() => setIsExpanded(true)}
className="
group absolute bottom-4 left-4 z-20
flex items-center gap-2 px-4 py-2.5
bg-gradient-to-r from-cyan-500 to-teal-500
rounded-xl text-white font-medium text-sm
shadow-[0_0_20px_rgba(6,182,212,0.4)]
hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]
hover:-translate-y-0.5
transition-all duration-200
"
className="group absolute bottom-4 left-4 z-20 flex items-center gap-2 rounded-xl bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-2.5 text-sm font-medium text-white shadow-[0_0_20px_rgba(6,182,212,0.4)] transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_0_30px_rgba(6,182,212,0.6)]"
>
<Terminal className="w-4 h-4" />
<Terminal className="h-4 w-4" />
<span>Query</span>
{queryResult && queryResult.nodeIds.length > 0 && (
<span className="
px-1.5 py-0.5 ml-1
bg-white/20 rounded-md
text-xs font-semibold
">
<span className="ml-1 rounded-md bg-white/20 px-1.5 py-0.5 text-xs font-semibold">
{queryResult.nodeIds.length}
</span>
)}
@@ -198,28 +202,20 @@ export const QueryFAB = () => {
return (
<div
ref={panelRef}
className="
absolute bottom-4 left-4 z-20
w-[480px] max-w-[calc(100%-2rem)]
bg-deep/95 backdrop-blur-md
border border-cyan-500/30
rounded-xl
shadow-[0_0_40px_rgba(6,182,212,0.2)]
animate-fade-in
"
className="absolute bottom-4 left-4 z-20 w-[480px] max-w-[calc(100%-2rem)] animate-fade-in rounded-xl border border-cyan-500/30 bg-deep/95 shadow-[0_0_40px_rgba(6,182,212,0.2)] backdrop-blur-md"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 flex items-center justify-center bg-gradient-to-br from-cyan-500 to-teal-500 rounded-lg">
<Terminal className="w-4 h-4 text-white" />
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-500 to-teal-500">
<Terminal className="h-4 w-4 text-white" />
</div>
<span className="font-medium text-sm">Cypher Query</span>
<span className="text-sm font-medium">Cypher Query</span>
</div>
<button
onClick={handleClose}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded-md transition-colors"
className="rounded-md p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
>
<X className="w-4 h-4" />
<X className="h-4 w-4" />
</button>
</div>
@@ -232,52 +228,30 @@ export const QueryFAB = () => {
onKeyDown={handleKeyDown}
placeholder="MATCH (n:Function) RETURN n.name, n.filePath LIMIT 10"
rows={3}
className="
w-full px-3 py-2.5
bg-surface border border-border-subtle rounded-lg
text-sm font-mono text-text-primary
placeholder:text-text-muted
focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20
outline-none resize-none
transition-all
"
className="w-full resize-none rounded-lg border border-border-subtle bg-surface px-3 py-2.5 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-cyan-500/50 focus:ring-2 focus:ring-cyan-500/20"
/>
</div>
<div className="flex items-center justify-between mt-3">
<div className="mt-3 flex items-center justify-between">
<div className="relative">
<button
onClick={() => setShowExamples(!showExamples)}
className="
flex items-center gap-1.5 px-3 py-1.5
text-xs text-text-secondary
hover:text-text-primary hover:bg-hover
rounded-md transition-colors
"
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
<Sparkles className="w-3.5 h-3.5" />
<Sparkles className="h-3.5 w-3.5" />
<span>Examples</span>
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`} />
<ChevronDown
className={`h-3.5 w-3.5 transition-transform ${showExamples ? 'rotate-180' : ''}`}
/>
</button>
{showExamples && (
<div className="
absolute bottom-full left-0 mb-2
w-64 py-1
bg-surface border border-border-subtle rounded-lg
shadow-xl
animate-fade-in
">
<div className="absolute bottom-full left-0 mb-2 w-64 animate-fade-in rounded-lg border border-border-subtle bg-surface py-1 shadow-xl">
{EXAMPLE_QUERIES.map((example) => (
<button
key={example.label}
onClick={() => handleSelectExample(example.query)}
className="
w-full px-3 py-2 text-left
text-sm text-text-secondary
hover:bg-hover hover:text-text-primary
transition-colors
"
className="w-full px-3 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
{example.label}
</button>
@@ -290,12 +264,7 @@ export const QueryFAB = () => {
{query && (
<button
onClick={handleClear}
className="
px-3 py-1.5
text-xs text-text-secondary
hover:text-text-primary hover:bg-hover
rounded-md transition-colors
"
className="rounded-md px-3 py-1.5 text-xs text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
>
Clear
</button>
@@ -303,76 +272,74 @@ export const QueryFAB = () => {
<button
onClick={handleRunQuery}
disabled={!query.trim() || isRunning}
className="
flex items-center gap-1.5 px-4 py-1.5
bg-gradient-to-r from-cyan-500 to-teal-500
rounded-md text-white text-sm font-medium
shadow-[0_0_15px_rgba(6,182,212,0.3)]
hover:shadow-[0_0_20px_rgba(6,182,212,0.5)]
disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none
transition-all
"
className="flex items-center gap-1.5 rounded-md bg-gradient-to-r from-cyan-500 to-teal-500 px-4 py-1.5 text-sm font-medium text-white shadow-[0_0_15px_rgba(6,182,212,0.3)] transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.5)] disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
>
{isRunning ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5" />
<Play className="h-3.5 w-3.5" />
)}
<span>Run</span>
<kbd className="ml-1 px-1 py-0.5 bg-white/20 rounded text-[10px]">⌘↵</kbd>
<kbd className="ml-1 rounded bg-white/20 px-1 py-0.5 text-[10px]">⌘↵</kbd>
</button>
</div>
</div>
</div>
{error && (
<div className="px-4 py-2 bg-red-500/10 border-t border-red-500/20">
<p className="text-xs text-red-400 font-mono">{error}</p>
<div className="border-t border-red-500/20 bg-red-500/10 px-4 py-2">
<p className="font-mono text-xs text-red-400">{error}</p>
</div>
)}
{queryResult && !error && (
<div className="border-t border-cyan-500/20">
<div className="px-4 py-2.5 bg-cyan-500/5 flex items-center justify-between">
<div className="flex items-center justify-between bg-cyan-500/5 px-4 py-2.5">
<div className="flex items-center gap-3 text-xs">
<span className="text-text-secondary">
<span className="text-cyan-400 font-semibold">{queryResult.rows.length}</span> rows
<span className="font-semibold text-cyan-400">{queryResult.rows.length}</span> rows
</span>
{queryResult.nodeIds.length > 0 && (
<span className="text-text-secondary">
<span className="text-cyan-400 font-semibold">{queryResult.nodeIds.length}</span> highlighted
<span className="font-semibold text-cyan-400">{queryResult.nodeIds.length}</span>{' '}
highlighted
</span>
)}
<span className="text-text-muted">
{queryResult.executionTime.toFixed(1)}ms
</span>
<span className="text-text-muted">{queryResult.executionTime.toFixed(1)}ms</span>
</div>
<div className="flex items-center gap-2">
{queryResult.nodeIds.length > 0 && (
<button
onClick={clearQueryHighlights}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
className="text-xs text-text-muted transition-colors hover:text-text-primary"
>
Clear
</button>
)}
<button
onClick={() => setShowResults(!showResults)}
className="flex items-center gap-1 text-xs text-text-muted hover:text-text-primary transition-colors"
className="flex items-center gap-1 text-xs text-text-muted transition-colors hover:text-text-primary"
>
<Table className="w-3 h-3" />
{showResults ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
<Table className="h-3 w-3" />
{showResults ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronUp className="h-3 w-3" />
)}
</button>
</div>
</div>
{showResults && queryResult.rows.length > 0 && (
<div className="max-h-48 overflow-auto scrollbar-thin border-t border-border-subtle">
<div className="scrollbar-thin max-h-48 overflow-auto border-t border-border-subtle">
<table className="w-full text-xs">
<thead className="bg-surface sticky top-0">
<thead className="sticky top-0 bg-surface">
<tr>
{Object.keys(queryResult.rows[0]).map((key) => (
<th key={key} className="px-3 py-2 text-left text-text-muted font-medium border-b border-border-subtle">
<th
key={key}
className="border-b border-border-subtle px-3 py-2 text-left font-medium text-text-muted"
>
{key}
</th>
))}
@@ -380,9 +347,12 @@ export const QueryFAB = () => {
</thead>
<tbody>
{queryResult.rows.slice(0, 50).map((row, i) => (
<tr key={i} className="hover:bg-hover/50 transition-colors">
<tr key={i} className="transition-colors hover:bg-hover/50">
{Object.values(row).map((val, j) => (
<td key={j} className="px-3 py-1.5 text-text-secondary border-b border-border-subtle/50 font-mono truncate max-w-[200px]">
<td
key={j}
className="max-w-[200px] truncate border-b border-border-subtle/50 px-3 py-1.5 font-mono text-text-secondary"
>
{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '')}
</td>
))}
@@ -391,7 +361,7 @@ export const QueryFAB = () => {
</tbody>
</table>
{queryResult.rows.length > 50 && (
<div className="px-3 py-2 text-xs text-text-muted bg-surface border-t border-border-subtle">
<div className="border-t border-border-subtle bg-surface px-3 py-2 text-xs text-text-muted">
Showing 50 of {queryResult.rows.length} rows
</div>
)}
@@ -402,4 +372,3 @@ export const QueryFAB = () => {
</div>
);
};
@@ -0,0 +1,437 @@
/**
* RepoAnalyzer
*
* Two input modes:
* - "github" → GitHub URL (https://github.com/owner/repo)
* - "local" → Select a local folder via the browser's native directory picker
*/
import { useState, useRef, useEffect, useId } from 'react';
import {
Github,
FolderOpen,
Loader2,
Check,
ArrowRight,
AlertCircle,
Sparkles,
} from '@/lib/lucide-icons';
import {
startAnalyze,
cancelAnalyze,
streamAnalyzeProgress,
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
// ── Helpers ──────────────────────────────────────────────────────────────────
type InputMode = 'github' | 'local';
const GITHUB_RE = /^https?:\/\/(www\.)?github\.com\/[^/\s]+\/[^/\s]+/i;
const IS_WINDOWS = navigator.userAgent.toLowerCase().includes('win');
function isValidGithubUrl(value: string): boolean {
return GITHUB_RE.test(value.trim());
}
// ── Mode tabs ────────────────────────────────────────────────────────────────
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
return (
<div className="flex gap-1 rounded-lg bg-elevated p-1" role="tablist" aria-label="Input type">
<button
role="tab"
aria-selected={mode === 'github'}
onClick={() => onChange('github')}
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'github'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
} `}
>
<Github className="h-3 w-3" />
GitHub URL
</button>
<button
role="tab"
aria-selected={mode === 'local'}
onClick={() => onChange('local')}
className={`flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
mode === 'local'
? 'bg-accent text-white shadow-sm'
: 'text-text-muted hover:text-text-secondary'
} `}
>
<FolderOpen className="h-3 w-3" />
Local Folder
</button>
</div>
);
}
// ── Analyze button ───────────────────────────────────────────────────────────
function AnalyzeButton({
canSubmit,
isLoading,
onClick,
variant,
}: {
canSubmit: boolean;
isLoading: boolean;
onClick: () => void;
variant: 'onboarding' | 'sheet';
}) {
const sizeClass =
variant === 'onboarding' ? 'w-full px-5 py-3.5 text-sm' : 'w-full px-4 py-3 text-sm';
return (
<button
onClick={onClick}
disabled={!canSubmit || isLoading}
className={` ${sizeClass} flex items-center justify-center gap-2.5 rounded-xl font-medium transition-all duration-200 ${
canSubmit && !isLoading
? 'cursor-pointer bg-accent text-white shadow-glow-soft hover:-translate-y-0.5 hover:bg-accent/90 hover:shadow-glow'
: 'cursor-not-allowed border border-border-subtle bg-elevated text-text-muted'
} `}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
<span>{isLoading ? 'Starting analysis...' : 'Analyze Repository'}</span>
{canSubmit && !isLoading && <ArrowRight className="h-3.5 w-3.5" />}
</button>
);
}
// ── Done state ───────────────────────────────────────────────────────────────
function DoneState({ repoName }: { repoName: string }) {
return (
<div
className="flex animate-fade-in flex-col items-center gap-3 py-4"
role="status"
aria-live="polite"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-emerald-500/30 bg-emerald-500/15 shadow-[0_0_20px_rgba(16,185,129,0.15)]">
<Check className="h-6 w-6 text-emerald-400" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-emerald-400">Analysis complete</p>
<p className="mt-0.5 font-mono text-xs text-text-muted">{repoName}</p>
</div>
<p className="text-xs text-text-secondary">Loading graph...</p>
</div>
);
}
// ── RepoAnalyzer ─────────────────────────────────────────────────────────────
type InternalPhase = 'input' | 'starting' | 'analyzing' | 'done' | 'error';
export interface RepoAnalyzerProps {
variant: 'onboarding' | 'sheet';
onComplete: (repoName: string) => void;
onCancel?: () => void;
}
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
const inputId = useId();
const folderInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [githubUrl, setGithubUrl] = useState('');
const [localPath, setLocalPath] = useState('');
const [phase, setPhase] = useState<InternalPhase>('input');
const [validationError, setValidationError] = useState<string | null>(null);
const [progress, setProgress] = useState<JobProgress>({
phase: 'queued',
percent: 0,
message: 'Queued',
});
const [completedRepoName, setCompletedRepoName] = useState('');
const jobIdRef = useRef<string | null>(null);
const sseControllerRef = useRef<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
sseControllerRef.current?.abort();
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
};
}, []);
const handleModeChange = (m: InputMode) => {
setMode(m);
setGithubUrl('');
setLocalPath('');
setValidationError(null);
};
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
// so we use a text input + a "Browse" button that opens a standard file input
// to let users pick files from the folder — the path is typed manually since
// browsers don't expose absolute paths for security reasons).
// For local paths, the user types or pastes the absolute path.
const canSubmit =
mode === 'github'
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
setValidationError('Please enter a valid GitHub repository URL.');
return;
}
if (mode === 'local' && localPath.trim().length < 2) {
setValidationError('Please enter a folder path.');
return;
}
setValidationError(null);
setPhase('starting');
try {
const request = mode === 'github' ? { url: githubUrl.trim() } : { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
jobIdRef.current = jobId;
setPhase('analyzing');
const nameSource = mode === 'github' ? githubUrl.trim() : localPath.trim();
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
(data) => {
const name =
data.repoName ?? nameSource.split(/[/\\]/).filter(Boolean).at(-1) ?? 'repository';
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
}, 1200);
},
(errMsg) => {
setValidationError(errMsg || 'Analysis failed. Check server logs.');
setPhase('error');
},
);
sseControllerRef.current = controller;
} catch (err) {
setValidationError(err instanceof Error ? err.message : 'Failed to start analysis');
setPhase('error');
}
};
const handleCancel = async () => {
sseControllerRef.current?.abort();
sseControllerRef.current = null;
if (jobIdRef.current) {
try {
await cancelAnalyze(jobIdRef.current);
} catch {}
jobIdRef.current = null;
}
setPhase('input');
setProgress({ phase: 'queued', percent: 0, message: 'Queued' });
};
const isLoading = phase === 'starting';
const showInput = phase !== 'analyzing' && phase !== 'done';
const isWindows = IS_WINDOWS;
return (
<div className="space-y-4">
{/* Mode tabs */}
{showInput && <ModeTabs mode={mode} onChange={handleModeChange} />}
{/* GitHub URL input */}
{showInput && mode === 'github' && (
<div className="space-y-2">
<label
htmlFor={inputId}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
GitHub Repository URL
</label>
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: isValidGithubUrl(githubUrl)
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<Github className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={inputId}
type="url"
value={githubUrl}
onChange={(e) => {
setGithubUrl(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder="https://github.com/owner/repo"
autoComplete="url"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{githubUrl.length > 10 && (
<div className="shrink-0">
{isValidGithubUrl(githubUrl) ? (
<Check className="h-3.5 w-3.5 text-emerald-400" />
) : (
<AlertCircle className="h-3.5 w-3.5 text-text-muted" />
)}
</div>
)}
</div>
</div>
)}
{/* Local folder input */}
{showInput && mode === 'local' && (
<div className="space-y-2">
<label
htmlFor={`${inputId}-local`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
>
Local Folder Path
</label>
<div
className={`flex items-center gap-3 rounded-xl border bg-void px-4 py-3.5 transition-all duration-200 ${
validationError && phase === 'error'
? 'border-red-500/50'
: localPath.trim().length > 1
? 'border-accent/50 shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-default focus-within:border-accent/40'
} `}
>
<FolderOpen className="h-4 w-4 shrink-0 text-text-muted" />
<input
id={`${inputId}-local`}
type="text"
value={localPath}
onChange={(e) => {
setLocalPath(e.target.value);
if (validationError) setValidationError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit && !isLoading) {
e.preventDefault();
handleAnalyze();
}
}}
disabled={isLoading}
placeholder={isWindows ? 'C:\\Users\\you\\project' : '/home/you/project'}
autoComplete="off"
spellCheck={false}
className="flex-1 border-none bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted disabled:opacity-50"
/>
{localPath.trim().length > 1 && (
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
</div>
{/* Native folder picker + Browse button — below the input */}
<input
ref={folderInputRef}
type="file"
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
webkitdirectory=""
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
const rel = files[0].webkitRelativePath;
const folderName = rel.split('/')[0];
if (folderName) {
setLocalPath(folderName);
setValidationError(null);
}
}
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
Browse for folder
</button>
</div>
)}
{/* Error message */}
{(phase === 'error' || (phase === 'input' && validationError)) && validationError && (
<p className="flex animate-fade-in items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="h-3 w-3 shrink-0" />
{validationError}
</p>
)}
{/* Live progress */}
{phase === 'analyzing' && (
<div className="animate-slide-up">
<AnalyzeProgress progress={progress} onCancel={handleCancel} />
</div>
)}
{/* Done */}
{phase === 'done' && <DoneState repoName={completedRepoName} />}
{/* CTA button */}
{(phase === 'input' || phase === 'starting') && (
<AnalyzeButton
canSubmit={canSubmit}
isLoading={isLoading}
onClick={handleAnalyze}
variant={variant}
/>
)}
{/* Error retry */}
{phase === 'error' && (
<div className="flex items-center gap-3">
<button
onClick={() => {
setValidationError(null);
setPhase('input');
}}
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
</button>
{onCancel && (
<button
onClick={onCancel}
className="cursor-pointer px-4 py-2.5 text-sm text-text-muted transition-colors hover:text-text-secondary"
>
Dismiss
</button>
)}
</div>
)}
{/* Dismiss for sheet variant while analyzing */}
{phase === 'analyzing' && variant === 'sheet' && onCancel && (
<button
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)
</button>
)}
</div>
);
};
+148
View File
@@ -0,0 +1,148 @@
/**
* RepoLanding
*
* Unified landing screen shown when the backend is connected and at least one
* repository is indexed. Displays pre-indexed repos as selectable cards, plus
* an "Analyze a New Repository" section powered by RepoAnalyzer.
*
* Rendering context:
* DropZone (Crossfade, phase="landing")
* └─ RepoLanding
* ├─ RepoCard (× N)
* └─ RepoAnalyzer (variant="onboarding")
*/
import { Sparkles, ArrowRight, GitBranch, FileCode, Layers } from '@/lib/lucide-icons';
import { RepoAnalyzer } from './RepoAnalyzer';
import type { BackendRepo } from '../services/backend-client';
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatRelativeTime(dateStr: string): 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`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
// ── Repo card ────────────────────────────────────────────────────────────────
function RepoCard({ repo, onClick }: { repo: BackendRepo; onClick: () => void }) {
const stats = repo.stats;
return (
<button
onClick={onClick}
data-testid="landing-repo-card"
className="group w-full cursor-pointer rounded-xl border border-border-default bg-elevated p-4 text-left transition-all duration-200 hover:border-accent/40 hover:bg-hover hover:shadow-glow-soft"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 shrink-0 text-accent" />
<h3 className="truncate text-sm font-semibold text-text-primary transition-colors group-hover:text-accent">
{repo.name}
</h3>
</div>
{repo.indexedAt && (
<p className="mt-1 pl-6 text-xs text-text-muted">
Indexed {formatRelativeTime(repo.indexedAt)}
</p>
)}
</div>
<ArrowRight className="h-4 w-4 shrink-0 text-text-muted opacity-0 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-accent group-hover:opacity-100" />
</div>
{stats && (stats.files || stats.nodes) && (
<div className="mt-3 flex flex-wrap gap-2 pl-6">
{stats.files != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<FileCode className="h-3 w-3" /> {stats.files.toLocaleString()} files
</span>
)}
{stats.nodes != null && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Layers className="h-3 w-3" /> {stats.nodes.toLocaleString()} symbols
</span>
)}
{stats.processes != null && stats.processes > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-void px-2 py-0.5 text-[11px] text-text-muted">
<Sparkles className="h-3 w-3" /> {stats.processes} flows
</span>
)}
</div>
)}
</button>
);
}
// ── RepoLanding ──────────────────────────────────────────────────────────────
interface RepoLandingProps {
repos: BackendRepo[];
onSelectRepo: (repoName: string) => void;
onAnalyzeComplete: (repoName: string) => void;
}
export const RepoLanding = ({ repos, onSelectRepo, onAnalyzeComplete }: RepoLandingProps) => {
return (
<div className="relative animate-fade-in overflow-hidden rounded-3xl border border-border-default bg-surface p-7">
{/* Ambient glows — mirrors OnboardingGuide aesthetic */}
<div className="pointer-events-none absolute -top-28 -right-28 h-72 w-72 rounded-full bg-accent/6 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-24 h-56 w-56 rounded-full bg-node-function/6 blur-3xl" />
{/* Header */}
<div className="relative mb-6">
<div className="text-center">
<div className="mb-2 inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-accent/70" />
<span className="text-[11px] font-medium tracking-widest text-accent/80 uppercase">
GitNexus
</span>
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
Choose a repository
</h2>
<p className="mx-auto mt-1.5 max-w-xs text-sm leading-relaxed text-text-secondary">
Select an indexed repository to explore, or analyze a new one.
</p>
</div>
</div>
{/* Repo list */}
<div className="relative mb-5 space-y-2">
{repos.map((repo) => (
<RepoCard key={repo.name} repo={repo} onClick={() => onSelectRepo(repo.name)} />
))}
</div>
{/* Divider */}
<div className="mb-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border-subtle" />
<span className="text-[11px] tracking-widest text-text-muted uppercase">
or analyze new
</span>
<div className="h-px flex-1 bg-border-subtle" />
</div>
{/* Analyzer form */}
<div className="relative">
<RepoAnalyzer variant="onboarding" onComplete={onAnalyzeComplete} />
</div>
{/* Footer hint */}
<p className="mt-5 text-center text-[11px] leading-relaxed text-text-muted">
Public &amp; private repos &middot; Cloned locally by the server &middot; No data leaves
your machine
</p>
</div>
);
};
+193 -182
View File
@@ -1,9 +1,17 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
Send, Square, Sparkles, User,
PanelRightClose, Loader2, AlertTriangle, GitBranch
Send,
Square,
Sparkles,
User,
PanelRightClose,
Loader2,
AlertTriangle,
GitBranch,
ArrowDown,
} from '@/lib/lucide-icons';
import { useAppState } from '../hooks/useAppState';
import { useAutoScroll } from '../hooks/useAutoScroll';
import { ToolCallCard } from './ToolCallCard';
import { isProviderConfigured } from '../core/llm/settings-service';
import { MarkdownRenderer } from './MarkdownRenderer';
@@ -12,7 +20,6 @@ export const RightPanel = () => {
const {
isRightPanelOpen,
setRightPanelOpen,
fileContents,
graph,
addCodeReference,
// LLM / chat state
@@ -30,133 +37,129 @@ export const RightPanel = () => {
const [chatInput, setChatInput] = useState('');
const [activeTab, setActiveTab] = useState<'chat' | 'processes'>('chat');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
// Keep streamed replies pinned unless the user intentionally scrolls away from the bottom.
const { scrollContainerRef, messagesContainerRef, isAtBottom, scrollToBottom } = useAutoScroll(
chatMessages,
isChatLoading,
);
// Auto-scroll to bottom when messages update or while streaming
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [chatMessages, isChatLoading]);
const resolveFilePathForUI = useCallback((_requestedPath: string): string | null => {
return null;
}, []);
const resolveFilePathForUI = useCallback((requestedPath: string): string | null => {
const req = requestedPath.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (!req) return null;
const findFileNodeIdForUI = useCallback(
(filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) =>
n.label === 'File' &&
n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target,
);
return node?.id;
},
[graph],
);
// Exact match first (case-insensitive)
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm === req) return key;
}
const handleGroundingClick = useCallback(
(inner: string) => {
const raw = inner.trim();
if (!raw) return;
// Ends-with match (best for partial paths)
let best: { path: string; score: number } | null = null;
for (const key of fileContents.keys()) {
const norm = key.replace(/\\/g, '/').replace(/^\.?\//, '').toLowerCase();
if (norm.endsWith(req)) {
const score = 1000 - norm.length;
if (!best || score > best.score) best = { path: key, score };
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
// Match line:num or line:num-num (supports both hyphen - and en dash –)
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
}
return best?.path ?? null;
}, [fileContents]);
const findFileNodeIdForUI = useCallback((filePath: string): string | undefined => {
if (!graph) return undefined;
const target = filePath.replace(/\\/g, '/').replace(/^\.?\//, '');
const node = graph.nodes.find(
(n) => n.label === 'File' && n.properties.filePath.replace(/\\/g, '/').replace(/^\.?\//, '') === target
);
return node?.id;
}, [graph]);
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const handleGroundingClick = useCallback((inner: string) => {
const raw = inner.trim();
if (!raw) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
let rawPath = raw;
let startLine1: number | undefined;
let endLine1: number | undefined;
// Match line:num or line:num-num (supports both hyphen - and en dash –)
const lineMatch = raw.match(/^(.*):(\d+)(?:[-–](\d+))?$/);
if (lineMatch) {
rawPath = lineMatch[1].trim();
startLine1 = parseInt(lineMatch[2], 10);
endLine1 = parseInt(lineMatch[3] || lineMatch[2], 10);
}
const resolvedPath = resolveFilePathForUI(rawPath);
if (!resolvedPath) return;
const nodeId = findFileNodeIdForUI(resolvedPath);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1 ? Math.max(0, endLine1 - 1) : (startLine1 ? Math.max(0, startLine1 - 1) : undefined),
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
}, [addCodeReference, findFileNodeIdForUI, resolveFilePathForUI]);
addCodeReference({
filePath: resolvedPath,
startLine: startLine1 ? Math.max(0, startLine1 - 1) : undefined,
endLine: endLine1
? Math.max(0, endLine1 - 1)
: startLine1
? Math.max(0, startLine1 - 1)
: undefined,
nodeId,
label: 'File',
name: resolvedPath.split('/').pop() ?? resolvedPath,
source: 'ai',
});
},
[addCodeReference, findFileNodeIdForUI, resolveFilePathForUI],
);
// Handler for node grounding: [[Class:View]], [[Function:trigger]], etc.
const handleNodeGroundingClick = useCallback((nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
const handleNodeGroundingClick = useCallback(
(nodeTypeAndName: string) => {
const raw = nodeTypeAndName.trim();
if (!raw || !graph) return;
// Parse Type:Name format
const match = raw.match(/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/);
if (!match) return;
// Parse Type:Name format
const match = raw.match(
/^(Class|Function|Method|Interface|File|Folder|Variable|Enum|Type|CodeElement):(.+)$/,
);
if (!match) return;
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
const [, nodeType, nodeName] = match;
const trimmedName = nodeName.trim();
// Find node in graph by type + name
const node = graph.nodes.find(n =>
n.label === nodeType &&
n.properties.name === trimmedName
);
// Find node in graph by type + name
const node = graph.nodes.find(
(n) => n.label === nodeType && n.properties.name === trimmedName,
);
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
if (!node) {
console.warn(`Node not found: ${nodeType}:${trimmedName}`);
return;
}
}
}, [graph, resolveFilePathForUI, addCodeReference]);
const handleLinkClick = useCallback((href: string) => {
if (href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
handleGroundingClick(inner);
} else if (href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
handleNodeGroundingClick(inner);
}
}, [handleGroundingClick, handleNodeGroundingClick]);
// 1. Highlight in graph (add to AI citation highlights)
// Note: This requires accessing the state setter from parent context
// For now, we'll add to code references which triggers the highlight
// 2. Add to Code Panel (if node has file/line info)
if (node.properties.filePath) {
const resolvedPath = resolveFilePathForUI(node.properties.filePath);
if (resolvedPath) {
addCodeReference({
filePath: resolvedPath,
startLine: node.properties.startLine ? node.properties.startLine - 1 : undefined,
endLine: node.properties.endLine ? node.properties.endLine - 1 : undefined,
nodeId: node.id,
label: node.label,
name: node.properties.name,
source: 'ai',
});
}
}
},
[graph, resolveFilePathForUI, addCodeReference],
);
const handleLinkClick = useCallback(
(href: string) => {
if (href.startsWith('code-ref:')) {
const inner = decodeURIComponent(href.slice('code-ref:'.length));
handleGroundingClick(inner);
} else if (href.startsWith('node-ref:')) {
const inner = decodeURIComponent(href.slice('node-ref:'.length));
handleNodeGroundingClick(inner);
}
},
[handleGroundingClick, handleNodeGroundingClick],
);
// Auto-resize textarea as user types
const adjustTextareaHeight = useCallback(() => {
@@ -208,33 +211,35 @@ export const RightPanel = () => {
if (!isRightPanelOpen) return null;
return (
<aside className="w-[40%] min-w-[400px] max-w-[600px] flex flex-col bg-deep border-l border-border-subtle animate-slide-in relative z-30 flex-shrink-0">
<aside className="relative z-30 flex w-[40%] max-w-[600px] min-w-[400px] flex-shrink-0 animate-slide-in flex-col border-l border-border-subtle bg-deep">
{/* Header with Tabs */}
<div className="flex items-center justify-between px-4 py-2 bg-surface border-b border-border-subtle">
<div className="flex items-center justify-between border-b border-border-subtle bg-surface px-4 py-2">
<div className="flex items-center gap-1">
{/* Chat Tab */}
<button
onClick={() => setActiveTab('chat')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'chat'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === 'chat'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:bg-hover hover:text-text-primary'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
<Sparkles className="h-3.5 w-3.5" />
<span>Nexus AI</span>
</button>
{/* Processes Tab */}
<button
onClick={() => setActiveTab('processes')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'processes'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-hover'
}`}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
activeTab === 'processes'
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:bg-hover hover:text-text-primary'
}`}
>
<GitBranch className="w-3.5 h-3.5" />
<GitBranch className="h-3.5 w-3.5" />
<span>Processes</span>
<span className="text-[10px] px-1.5 py-0.5 bg-gradient-to-r from-violet-500 to-fuchsia-500 text-white rounded-full font-semibold">
<span className="rounded-full bg-gradient-to-r from-violet-500 to-fuchsia-500 px-1.5 py-0.5 text-[10px] font-semibold text-white">
NEW
</span>
</button>
@@ -243,34 +248,34 @@ export const RightPanel = () => {
{/* Close button */}
<button
onClick={() => setRightPanelOpen(false)}
className="p-1.5 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
className="rounded p-1.5 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
title="Close Panel"
>
<PanelRightClose className="w-4 h-4" />
<PanelRightClose className="h-4 w-4" />
</button>
</div>
{/* Processes Tab */}
{activeTab === 'processes' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
<ProcessesPanel />
</div>
)}
{/* Chat Content - only show when chat tab is active */}
{activeTab === 'chat' && (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="relative flex flex-1 flex-col overflow-hidden">
{/* Status bar */}
<div className="flex items-center gap-2.5 px-4 py-3 bg-elevated/50 border-b border-border-subtle">
<div className="flex items-center gap-2.5 border-b border-border-subtle bg-elevated/50 px-4 py-3">
<div className="ml-auto flex items-center gap-2">
{!isAgentReady && (
<span className="text-[11px] px-2 py-1 rounded-full bg-amber-500/15 text-amber-300 border border-amber-500/30">
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-1 text-[11px] text-amber-300">
Configure AI
</span>
)}
{isAgentInitializing && (
<span className="text-[11px] px-2 py-1 rounded-full bg-surface border border-border-subtle flex items-center gap-1 text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" /> Connecting
<span className="flex items-center gap-1 rounded-full border border-border-subtle bg-surface px-2 py-1 text-[11px] text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Connecting
</span>
)}
</div>
@@ -278,33 +283,30 @@ export const RightPanel = () => {
{/* Status / errors */}
{agentError && (
<div className="px-4 py-3 bg-rose-500/10 border-b border-rose-500/30 text-rose-100 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<div className="flex items-center gap-2 border-b border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
<AlertTriangle className="h-4 w-4" />
<span>{agentError}</span>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin">
<div ref={scrollContainerRef} className="scrollbar-thin flex-1 overflow-y-auto p-4">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<div className="w-14 h-14 mb-4 flex items-center justify-center bg-gradient-to-br from-accent to-node-interface rounded-xl shadow-glow text-2xl">
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-node-interface text-2xl shadow-glow">
🧠
</div>
<h3 className="text-base font-medium mb-2">
Ask me anything
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-5">
I can help you understand the architecture, find functions, or explain connections.
<h3 className="mb-2 text-base font-medium">Ask me anything</h3>
<p className="mb-5 text-sm leading-relaxed text-text-secondary">
I can help you understand the architecture, find functions, or explain
connections.
</p>
<div className="flex flex-wrap gap-2 justify-center">
<div className="flex flex-wrap justify-center gap-2">
{chatSuggestions.map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-full text-xs text-text-secondary hover:border-accent hover:text-text-primary transition-colors"
className="rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-accent hover:text-text-primary"
>
{suggestion}
</button>
@@ -312,43 +314,42 @@ export const RightPanel = () => {
</div>
</div>
) : (
<div className="flex flex-col gap-6">
<div ref={messagesContainerRef} className="flex flex-col gap-6">
{chatMessages.map((message) => (
<div
key={message.id}
className="animate-fade-in"
>
<div key={message.id} className="animate-fade-in">
{/* User message - compact label style */}
{message.role === 'user' && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-text-muted" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">You</span>
</div>
<div className="pl-6 text-sm text-text-primary">
{message.content}
<div className="mb-2 flex items-center gap-2">
<User className="h-4 w-4 text-text-muted" />
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
You
</span>
</div>
<div className="pl-6 text-sm text-text-primary">{message.content}</div>
</div>
)}
{/* Assistant message - copilot style */}
{message.role === 'assistant' && (
<div>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-4 h-4 text-accent" />
<span className="text-xs font-medium text-text-muted uppercase tracking-wide">Nexus AI</span>
<div className="mb-3 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-xs font-medium tracking-wide text-text-muted uppercase">
Nexus AI
</span>
{isChatLoading && message === chatMessages[chatMessages.length - 1] && (
<Loader2 className="w-3 h-3 animate-spin text-accent" />
<Loader2 className="h-3 w-3 animate-spin text-accent" />
)}
</div>
<div className="pl-6 chat-prose">
<div className="chat-prose pl-6">
{/* Render steps in order (reasoning, tool calls, content interleaved) */}
{message.steps && message.steps.length > 0 ? (
<div className="space-y-4">
{message.steps.map((step, index) => (
<div key={step.id}>
{step.type === 'reasoning' && step.content && (
<div className="text-text-secondary text-sm italic border-l-2 border-text-muted/30 pl-3 mb-3">
<div className="mb-3 border-l-2 border-text-muted/30 pl-3 text-sm text-text-secondary italic">
<MarkdownRenderer
content={step.content}
onLinkClick={handleLinkClick}
@@ -357,7 +358,10 @@ export const RightPanel = () => {
)}
{step.type === 'tool_call' && step.toolCall && (
<div className="mb-3">
<ToolCallCard toolCall={step.toolCall} defaultExpanded={false} />
<ToolCallCard
toolCall={step.toolCall}
defaultExpanded={false}
/>
</div>
)}
{step.type === 'content' && step.content && (
@@ -384,17 +388,27 @@ export const RightPanel = () => {
)}
</div>
))}
</div>
)}
{/* Scroll anchor for auto-scroll */}
<div ref={messagesEndRef} />
</div>
{/* Scroll to bottom */}
<button
aria-label="Scroll to bottom"
onClick={() => scrollToBottom()}
className={`absolute bottom-20 left-1/2 z-10 -translate-x-1/2 rounded-full border border-border-subtle bg-elevated px-3 py-1.5 text-xs text-text-secondary shadow-lg transition-all duration-200 hover:border-accent hover:text-accent ${
!isAtBottom && chatMessages.length > 0
? 'translate-y-0 opacity-100'
: 'pointer-events-none translate-y-2 opacity-0'
}`}
>
<ArrowDown className="mr-1 inline h-3.5 w-3.5" />
Scroll to bottom
</button>
{/* Input */}
<div className="p-3 bg-surface border-t border-border-subtle">
<div className="flex items-end gap-2 px-3 py-2 bg-elevated border border-border-subtle rounded-xl transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<div className="border-t border-border-subtle bg-surface p-3">
<div className="flex items-end gap-2 rounded-xl border border-border-subtle bg-elevated px-3 py-2 transition-all focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/20">
<textarea
ref={textareaRef}
value={chatInput}
@@ -402,12 +416,12 @@ export const RightPanel = () => {
onKeyDown={handleKeyDown}
placeholder="Ask about the codebase..."
rows={1}
className="flex-1 bg-transparent border-none outline-none text-sm text-text-primary placeholder:text-text-muted resize-none min-h-[36px] scrollbar-thin"
className="scrollbar-thin min-h-[36px] flex-1 resize-none border-none bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted"
style={{ height: '36px', overflowY: 'hidden' }}
/>
<button
onClick={clearChat}
className="px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
className="px-2 py-1 text-xs text-text-muted transition-colors hover:text-text-primary"
title="Clear chat"
>
Clear
@@ -415,24 +429,24 @@ export const RightPanel = () => {
{isChatLoading ? (
<button
onClick={stopChatResponse}
className="w-9 h-9 flex items-center justify-center bg-red-500/80 rounded-md text-white transition-all hover:bg-red-500"
className="flex h-9 w-9 items-center justify-center rounded-md bg-red-500/80 text-white transition-all hover:bg-red-500"
title="Stop response"
>
<Square className="w-3.5 h-3.5 fill-current" />
<Square className="h-3.5 w-3.5 fill-current" />
</button>
) : (
<button
onClick={handleSendMessage}
disabled={!chatInput.trim() || isAgentInitializing}
className="w-9 h-9 flex items-center justify-center bg-accent rounded-md text-white transition-all hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
className="flex h-9 w-9 items-center justify-center rounded-md bg-accent text-white transition-all hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<Send className="h-3.5 w-3.5" />
</button>
)}
</div>
{!isAgentReady && !isAgentInitializing && (
<div className="mt-2 text-xs text-amber-200 flex items-center gap-2">
<AlertTriangle className="w-3.5 h-3.5" />
<div className="mt-2 flex items-center gap-2 text-xs text-amber-200">
<AlertTriangle className="h-3.5 w-3.5" />
<span>
{isProviderConfigured()
? 'Initializing AI agent...'
@@ -446,6 +460,3 @@ export const RightPanel = () => {
</aside>
);
};
+304 -207
View File
@@ -1,5 +1,18 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, ChevronDown, Loader2, Search } from '@/lib/lucide-icons';
import {
X,
Key,
Server,
Brain,
Check,
AlertCircle,
Eye,
EyeOff,
RefreshCw,
ChevronDown,
Loader2,
Search,
} from '@/lib/lucide-icons';
import {
loadSettings,
saveSettings,
@@ -31,7 +44,13 @@ interface OpenRouterModelComboboxProps {
onLoadModels: () => void;
}
const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadModels }: OpenRouterModelComboboxProps) => {
const OpenRouterModelCombobox = ({
value,
onChange,
models,
isLoading,
onLoadModels,
}: OpenRouterModelComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
@@ -41,16 +60,15 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
const filteredModels = useMemo(() => {
if (!searchTerm.trim()) return models;
const lower = searchTerm.toLowerCase();
return models.filter(m =>
m.id.toLowerCase().includes(lower) ||
m.name.toLowerCase().includes(lower)
return models.filter(
(m) => m.id.toLowerCase().includes(lower) || m.name.toLowerCase().includes(lower),
);
}, [models, searchTerm]);
// Find display name for current value
const displayValue = useMemo(() => {
if (!value) return '';
const found = models.find(m => m.id === value);
const found = models.find((m) => m.id === value);
return found ? found.name : value;
}, [value, models]);
@@ -93,7 +111,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && searchTerm) {
// If exact match in filtered, select it; otherwise use raw input
const exact = filteredModels.find(m => m.id.toLowerCase() === searchTerm.toLowerCase());
const exact = filteredModels.find((m) => m.id.toLowerCase() === searchTerm.toLowerCase());
if (exact) {
handleSelect(exact.id);
} else if (filteredModels.length === 1) {
@@ -115,8 +133,7 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
{/* Main input/button */}
<div
onClick={handleOpen}
className={`w-full px-4 py-3 bg-elevated border rounded-xl cursor-pointer transition-all flex items-center gap-2
${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
className={`flex w-full cursor-pointer items-center gap-2 rounded-xl border bg-elevated px-4 py-3 transition-all ${isOpen ? 'border-accent ring-2 ring-accent/20' : 'border-border-subtle hover:border-accent/50'}`}
>
{isOpen ? (
<input
@@ -126,58 +143,61 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Search or type model ID..."
className="flex-1 bg-transparent text-text-primary placeholder:text-text-muted outline-none font-mono text-sm"
onClick={e => e.stopPropagation()}
className="flex-1 bg-transparent font-mono text-sm text-text-primary outline-none placeholder:text-text-muted"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={`flex-1 font-mono text-sm truncate ${value ? 'text-text-primary' : 'text-text-muted'}`}>
<span
className={`flex-1 truncate font-mono text-sm ${value ? 'text-text-primary' : 'text-text-muted'}`}
>
{displayValue || 'Select or type a model...'}
</span>
)}
<div className="flex items-center gap-1">
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-text-muted" />}
<ChevronDown className={`w-4 h-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-text-muted" />}
<ChevronDown
className={`h-4 w-4 text-text-muted transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-elevated border border-border-subtle rounded-xl shadow-xl overflow-hidden">
<div className="absolute z-50 mt-1 w-full overflow-hidden rounded-xl border border-border-subtle bg-elevated shadow-xl">
{isLoading ? (
<div className="px-4 py-6 text-center text-text-muted text-sm flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
<div className="flex items-center justify-center gap-2 px-4 py-6 text-center text-sm text-text-muted">
<Loader2 className="h-4 w-4 animate-spin" />
Loading models...
</div>
) : filteredModels.length === 0 ? (
<div className="px-4 py-4 text-center">
{models.length === 0 ? (
<div className="text-text-muted text-sm">
<Search className="w-5 h-5 mx-auto mb-2 opacity-50" />
<div className="text-sm text-text-muted">
<Search className="mx-auto mb-2 h-5 w-5 opacity-50" />
<p>Type a model ID or press Enter</p>
<p className="text-xs mt-1">e.g. openai/gpt-4o</p>
<p className="mt-1 text-xs">e.g. openai/gpt-4o</p>
</div>
) : (
<div className="text-text-muted text-sm">
<div className="text-sm text-text-muted">
<p>No models match "{searchTerm}"</p>
<p className="text-xs mt-1">Press Enter to use as custom ID</p>
<p className="mt-1 text-xs">Press Enter to use as custom ID</p>
</div>
)}
</div>
) : (
<div className="max-h-64 overflow-y-auto">
{filteredModels.slice(0, 50).map(model => (
{filteredModels.slice(0, 50).map((model) => (
<button
key={model.id}
onClick={() => handleSelect(model.id)}
className={`w-full px-4 py-2.5 text-left hover:bg-hover transition-colors flex flex-col
${model.id === value ? 'bg-accent/10' : ''}`}
className={`flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-hover ${model.id === value ? 'bg-accent/10' : ''}`}
>
<span className="text-text-primary text-sm truncate">{model.name}</span>
<span className="text-text-muted text-xs font-mono truncate">{model.id}</span>
<span className="truncate text-sm text-text-primary">{model.name}</span>
<span className="truncate font-mono text-xs text-text-muted">{model.id}</span>
</button>
))}
{filteredModels.length > 50 && (
<div className="px-4 py-2 text-xs text-text-muted text-center border-t border-border-subtle">
<div className="border-t border-border-subtle px-4 py-2 text-center text-xs text-text-muted">
+{filteredModels.length - 50} more • Refine your search
</div>
)}
@@ -192,7 +212,9 @@ const OpenRouterModelCombobox = ({ value, onChange, models, isLoading, onLoadMod
/**
* Check connection to local Ollama instance
*/
const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error: string | null }> => {
const checkOllamaStatus = async (
baseUrl: string,
): Promise<{ ok: boolean; error: string | null }> => {
try {
const response = await fetch(`${baseUrl}/api/tags`, {
method: 'GET',
@@ -201,7 +223,10 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
if (!response.ok) {
if (response.status === 0 || response.status === 404) {
return { ok: false, error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`' };
return {
ok: false,
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
};
}
return { ok: false, error: `Ollama API error: ${response.status}` };
}
@@ -210,12 +235,19 @@ const checkOllamaStatus = async (baseUrl: string): Promise<{ ok: boolean; error:
} catch (error) {
return {
ok: false,
error: 'Cannot connect to Ollama. Make sure it\'s running with `ollama serve`'
error: "Cannot connect to Ollama. Make sure it's running with `ollama serve`",
};
}
};
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, isBackendConnected, onBackendUrlChange }: SettingsPanelProps) => {
export const SettingsPanel = ({
isOpen,
onClose,
onSettingsSaved,
backendUrl,
isBackendConnected,
onBackendUrlChange,
}: SettingsPanelProps) => {
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
@@ -274,7 +306,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
}, [settings.ollama?.baseUrl, settings.activeProvider, checkOllamaConnection]);
const handleProviderChange = (provider: LLMProvider) => {
setSettings(prev => ({ ...prev, activeProvider: provider }));
setSettings((prev) => ({ ...prev, activeProvider: provider }));
};
const handleSave = () => {
@@ -292,29 +324,34 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
};
const toggleApiKeyVisibility = (key: string) => {
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
setShowApiKey((prev) => ({ ...prev, [key]: !prev[key] }));
};
if (!isOpen) return null;
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai', 'ollama', 'openrouter', 'minimax', 'glm'];
const providers: LLMProvider[] = [
'openai',
'gemini',
'anthropic',
'azure-openai',
'ollama',
'openrouter',
'minimax',
'glm',
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Panel */}
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden max-h-[90vh] flex flex-col">
<div className="relative mx-4 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-elevated/50">
<div className="flex items-center justify-between border-b border-border-subtle bg-elevated/50 px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 flex items-center justify-center bg-accent/20 rounded-xl">
<Brain className="w-5 h-5 text-accent" />
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
<Brain className="h-5 w-5 text-accent" />
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
@@ -323,25 +360,25 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
<button
onClick={onClose}
className="p-2 text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
className="rounded-lg p-2 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
>
<X className="w-5 h-5" />
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* Local Server */}
{backendUrl !== undefined && onBackendUrlChange && (
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
Local Server
</label>
<label className="block text-sm font-medium text-text-secondary">Local Server</label>
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
<Server className="w-4 h-4 text-text-muted" />
<div className="mb-2 flex items-center gap-2">
<Server className="h-4 w-4 text-text-muted" />
<span className="text-sm text-text-secondary">Backend URL</span>
<span className={`w-2 h-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`} />
<span
className={`h-2 w-2 rounded-full ${isBackendConnected ? 'bg-green-400' : 'bg-red-400'}`}
/>
<span className="text-xs text-text-muted">
{isBackendConnected ? 'Connected' : 'Not connected'}
</span>
@@ -351,10 +388,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
value={backendUrl}
onChange={(e) => onBackendUrlChange(e.target.value)}
placeholder="http://localhost:4747"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Run <code className="px-1 py-0.5 bg-elevated rounded">gitnexus serve</code> to start the local server
Run <code className="rounded bg-elevated px-1 py-0.5">gitnexus serve</code> to
start the local server
</p>
</div>
</div>
@@ -362,27 +400,36 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* Provider Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
Provider
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{providers.map(provider => (
<label className="block text-sm font-medium text-text-secondary">Provider</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{providers.map((provider) => (
<button
key={provider}
onClick={() => handleProviderChange(provider)}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all
${settings.activeProvider === provider
className={`flex items-center gap-3 rounded-xl border-2 p-4 transition-all ${
settings.activeProvider === provider
? 'border-accent bg-accent/10 text-text-primary'
: 'border-border-subtle bg-elevated hover:border-accent/50 text-text-secondary'
}
`}
: 'border-border-subtle bg-elevated text-text-secondary hover:border-accent/50'
} `}
>
<div className={`
w-8 h-8 rounded-lg flex items-center justify-center text-lg
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
`}>
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : provider === 'ollama' ? '🦙' : provider === 'openrouter' ? '🌐' : provider === 'minimax' ? '⚡' : provider === 'glm' ? '🔮' : '☁️'}
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg text-lg ${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'} `}
>
{provider === 'openai'
? '🤖'
: provider === 'gemini'
? '💎'
: provider === 'anthropic'
? '🧠'
: provider === 'ollama'
? '🦙'
: provider === 'openrouter'
? '🌐'
: provider === 'minimax'
? '⚡'
: provider === 'glm'
? '🔮'
: '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
@@ -390,7 +437,7 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
</div>
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl text-xs text-amber-200">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
API keys are stored in session storage and will be cleared when you close this tab.
</div>
@@ -405,38 +452,43 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://platform.openai.com/api-keys',
helperLinkLabel: 'OpenAI Platform',
isVisible: !!showApiKey['openai'],
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('openai'),
}}
model={{
value: settings.openai?.model ?? 'gpt-5.2-chat',
placeholder: 'e.g., gpt-4o, gpt-4-turbo, gpt-3.5-turbo',
onChange: (value) => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, model: value },
})),
}}
>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
Base URL <span className="text-text-muted font-normal">(optional)</span>
<Server className="h-4 w-4" />
Base URL <span className="font-normal text-text-muted">(optional)</span>
</label>
<input
type="url"
value={settings.openai?.baseUrl ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
openai: { ...prev.openai!, baseUrl: e.target.value },
}))
}
placeholder="https://api.openai.com/v1 (default)"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
Leave empty to use the default OpenAI API. Set a custom URL for proxies or
compatible APIs.
</p>
</div>
</ProviderConfigCard>
@@ -453,19 +505,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://aistudio.google.com/app/apikey',
helperLinkLabel: 'Google AI Studio',
isVisible: !!showApiKey['gemini'],
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
gemini: { ...prev.gemini!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('gemini'),
}}
model={{
value: settings.gemini?.model ?? 'gemini-2.0-flash',
placeholder: 'e.g., gemini-2.0-flash, gemini-1.5-pro',
onChange: (value) => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
gemini: { ...prev.gemini!, model: value },
})),
}}
/>
)}
@@ -481,66 +535,76 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://console.anthropic.com/settings/keys',
helperLinkLabel: 'Anthropic Console',
isVisible: !!showApiKey['anthropic'],
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('anthropic'),
}}
model={{
value: settings.anthropic?.model ?? 'claude-sonnet-4-20250514',
placeholder: 'e.g., claude-sonnet-4-20250514, claude-3-opus',
onChange: (value) => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
anthropic: { ...prev.anthropic!, model: value },
})),
}}
/>
)}
{/* Azure OpenAI Settings */}
{settings.activeProvider === 'azure-openai' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['azure'] ? 'text' : 'password'}
value={settings.azureOpenAI?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value },
}))
}
placeholder="Enter your Azure OpenAI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('azure')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{showApiKey['azure'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showApiKey['azure'] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
<Server className="h-4 w-4" />
Endpoint
</label>
<input
type="url"
value={settings.azureOpenAI?.endpoint ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value },
}))
}
placeholder="https://your-resource.openai.azure.com"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@@ -549,12 +613,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.deploymentName ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value },
}))
}
placeholder="e.g., gpt-4o-deployment"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@@ -564,12 +630,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value },
}))
}
placeholder="gpt-4o"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
@@ -578,12 +646,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value },
}))
}
placeholder="2024-08-01-preview"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
</div>
</div>
@@ -604,10 +674,10 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* Ollama Settings */}
{settings.activeProvider === 'ollama' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
{/* How to run Ollama */}
<div className="p-3 bg-amber-500/10 border border-amber-500/30 rounded-xl">
<p className="text-xs text-amber-300 leading-relaxed">
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-xs leading-relaxed text-amber-300">
<span className="font-medium">📋 Quick Start:</span> Install Ollama from{' '}
<a
href="https://ollama.ai"
@@ -616,41 +686,46 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
className="text-accent hover:underline"
>
ollama.ai
</a>, then run:
</a>
, then run:
</p>
<code className="block mt-2 px-3 py-2 bg-black/30 rounded-lg text-amber-200 font-mono text-sm">
<code className="mt-2 block rounded-lg bg-black/30 px-3 py-2 font-mono text-sm text-amber-200">
ollama serve
</code>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
<Server className="h-4 w-4" />
Base URL
</label>
<div className="flex gap-2">
<input
type="url"
value={settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ollama: { ...prev.ollama!, baseUrl: e.target.value },
}))
}
placeholder={DEFAULT_OLLAMA_BASE_URL}
className="flex-1 px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="flex-1 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"
/>
<button
type="button"
onClick={() => checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)}
onClick={() =>
checkOllamaConnection(settings.ollama?.baseUrl ?? DEFAULT_OLLAMA_BASE_URL)
}
disabled={isCheckingOllama}
className="px-3 py-3 bg-elevated border border-border-subtle rounded-xl text-text-secondary hover:text-text-primary hover:border-accent/50 transition-colors disabled:opacity-50"
className="rounded-xl border border-border-subtle bg-elevated px-3 py-3 text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary disabled:opacity-50"
title="Check connection"
>
<RefreshCw className={`w-4 h-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
<RefreshCw className={`h-4 w-4 ${isCheckingOllama ? 'animate-spin' : ''}`} />
</button>
</div>
<p className="text-xs text-text-muted">
Default port is <code className="px-1 py-0.5 bg-elevated rounded">11434</code>.
Default port is <code className="rounded bg-elevated px-1 py-0.5">11434</code>.
</p>
</div>
@@ -658,9 +733,9 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
{ollamaError && !isCheckingOllama && (
<div className="p-2 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2">
<p className="flex items-center gap-1 text-xs text-red-400">
<AlertCircle className="h-3 w-3" />
{ollamaError}
</p>
</div>
@@ -669,15 +744,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.ollama?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
ollama: { ...prev.ollama!, model: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
ollama: { ...prev.ollama!, model: e.target.value },
}))
}
placeholder="e.g., llama3.2, mistral, codellama"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Pull a model with <code className="px-1 py-0.5 bg-elevated rounded">ollama pull llama3.2</code>
Pull a model with{' '}
<code className="rounded bg-elevated px-1 py-0.5">ollama pull llama3.2</code>
</p>
</div>
</div>
@@ -694,10 +772,11 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://openrouter.ai/keys',
helperLinkLabel: 'OpenRouter Keys',
isVisible: !!showApiKey['openrouter'],
onChange: (value) => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
openrouter: { ...prev.openrouter!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('openrouter'),
}}
>
@@ -705,10 +784,12 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
<OpenRouterModelCombobox
value={settings.openrouter?.model ?? ''}
onChange={(model) => setSettings(prev => ({
...prev,
openrouter: { ...prev.openrouter!, model }
}))}
onChange={(model) =>
setSettings((prev) => ({
...prev,
openrouter: { ...prev.openrouter!, model },
}))
}
models={openRouterModels}
isLoading={isLoadingModels}
onLoadModels={loadOpenRouterModels}
@@ -739,19 +820,21 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
helperLink: 'https://platform.minimax.io',
helperLinkLabel: 'MiniMax Platform',
isVisible: !!showApiKey['minimax'],
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, apiKey: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
minimax: { ...prev.minimax!, apiKey: value },
})),
onToggleVisibility: () => toggleApiKeyVisibility('minimax'),
}}
model={{
value: settings.minimax?.model ?? 'MiniMax-M2.5',
placeholder: 'e.g., MiniMax-M2.5, MiniMax-M2.5-highspeed',
onChange: (value) => setSettings(prev => ({
...prev,
minimax: { ...prev.minimax!, model: value }
})),
onChange: (value) =>
setSettings((prev) => ({
...prev,
minimax: { ...prev.minimax!, model: value },
})),
helperText: 'Available: MiniMax-M2.5 (default), MiniMax-M2.5-highspeed (faster)',
}}
/>
@@ -759,29 +842,35 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
{/* GLM Settings */}
{settings.activeProvider === 'glm' && (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['glm'] ? 'text' : 'password'}
value={settings.glm?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, apiKey: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, apiKey: e.target.value },
}))
}
placeholder="Enter your Z.AI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('glm')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{showApiKey['glm'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{showApiKey['glm'] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<p className="text-xs text-text-muted">
@@ -801,14 +890,18 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<label className="text-sm font-medium text-text-secondary">Model</label>
<select
value={settings.glm?.model ?? 'GLM-5'}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, model: e.target.value },
}))
}
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
>
{getAvailableModels('glm').map(model => (
<option key={model} value={model}>{model}</option>
{getAvailableModels('glm').map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
@@ -818,12 +911,14 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<input
type="text"
value={settings.glm?.baseUrl ?? 'https://api.z.ai/api/coding/paas/v4'}
onChange={e => setSettings(prev => ({
...prev,
glm: { ...prev.glm!, baseUrl: e.target.value }
}))}
onChange={(e) =>
setSettings((prev) => ({
...prev,
glm: { ...prev.glm!, baseUrl: e.target.value },
}))
}
placeholder="https://api.z.ai/api/coding/paas/v4"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<p className="text-xs text-text-muted">
Coding API (default). Use https://api.z.ai/api/paas/v4 for the general API.
@@ -833,30 +928,33 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
)}
{/* Privacy Note */}
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
<div className="rounded-xl border border-border-subtle bg-elevated/50 p-4">
<div className="flex gap-3">
<div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-lg text-green-400 flex-shrink-0">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-green-500/20 text-green-400">
🔒
</div>
<div className="text-xs text-text-muted leading-relaxed">
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's session storage and are cleared when the tab closes. They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
<div className="text-xs leading-relaxed text-text-muted">
<span className="font-medium text-text-secondary">Privacy:</span> Your API keys are
stored only in your browser's session storage and are cleared when the tab closes.
They're sent directly to the LLM provider when you chat. Your code never leaves your
machine.
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border-subtle bg-elevated/30">
<div className="flex items-center justify-between border-t border-border-subtle bg-elevated/30 px-6 py-4">
<div className="flex items-center gap-2 text-sm">
{saveStatus === 'saved' && (
<span className="flex items-center gap-1.5 text-green-400 animate-fade-in">
<Check className="w-4 h-4" />
<span className="flex animate-fade-in items-center gap-1.5 text-green-400">
<Check className="h-4 w-4" />
Settings saved
</span>
)}
{saveStatus === 'error' && (
<span className="flex items-center gap-1.5 text-red-400 animate-fade-in">
<AlertCircle className="w-4 h-4" />
<span className="flex animate-fade-in items-center gap-1.5 text-red-400">
<AlertCircle className="h-4 w-4" />
Failed to save
</span>
)}
@@ -864,13 +962,13 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
className="px-4 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-5 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
className="rounded-lg bg-accent px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-dim"
>
Save Settings
</button>
@@ -880,4 +978,3 @@ export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved, backendUrl, is
</div>
);
};
+19 -16
View File
@@ -11,28 +11,29 @@ export const StatusBar = () => {
// Detect primary language
const primaryLanguage = useMemo(() => {
if (!graph) return null;
const languages = graph.nodes
.map(n => n.properties.language)
.filter(Boolean);
const languages = graph.nodes.map((n) => n.properties.language).filter(Boolean);
if (languages.length === 0) return null;
const counts = languages.reduce((acc, lang) => {
acc[lang!] = (acc[lang!] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const counts = languages.reduce(
(acc, lang) => {
acc[lang!] = (acc[lang!] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0];
}, [graph]);
return (
<footer className="flex items-center justify-between px-5 py-2 bg-deep border-t border-dashed border-border-subtle text-[11px] text-text-muted">
<footer className="flex items-center justify-between border-t border-dashed border-border-subtle bg-deep px-5 py-2 text-[11px] text-text-muted">
{/* Left - Status */}
<div className="flex items-center gap-4">
{progress && progress.phase !== 'complete' ? (
<>
<div className="w-28 h-1 bg-elevated rounded-full overflow-hidden">
<div className="h-1 w-28 overflow-hidden rounded-full bg-elevated">
<div
className="h-full bg-gradient-to-r from-accent to-node-interface rounded-full transition-all duration-300"
className="h-full rounded-full bg-gradient-to-r from-accent to-node-interface transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
@@ -40,7 +41,7 @@ export const StatusBar = () => {
</>
) : (
<div className="flex items-center gap-1.5" data-testid="status-ready">
<span className="w-1.5 h-1.5 bg-node-function rounded-full" />
<span className="h-1.5 w-1.5 rounded-full bg-node-function" />
<span>Ready</span>
</div>
)}
@@ -51,17 +52,19 @@ export const StatusBar = () => {
href="https://github.com/sponsors/abhigyanpatwari"
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 hover:bg-pink-500/20 hover:border-pink-500/40 hover:scale-[1.02] transition-all duration-200 cursor-pointer"
className="group flex cursor-pointer items-center gap-2 rounded-full border border-pink-500/20 bg-pink-500/10 px-3 py-1 transition-all duration-200 hover:scale-[1.02] hover:border-pink-500/40 hover:bg-pink-500/20"
>
<Heart className="w-3.5 h-3.5 text-pink-500 fill-pink-500/40 group-hover:fill-pink-500 group-hover:scale-110 transition-all duration-200 animate-pulse" />
<span className="text-[11px] font-medium text-pink-400 group-hover:text-pink-300 transition-colors">Sponsor</span>
<span className="text-[10px] text-pink-300/50 group-hover:text-pink-300/80 italic hidden md:inline transition-colors">
<Heart className="h-3.5 w-3.5 animate-pulse fill-pink-500/40 text-pink-500 transition-all duration-200 group-hover:scale-110 group-hover:fill-pink-500" />
<span className="text-[11px] font-medium text-pink-400 transition-colors group-hover:text-pink-300">
Sponsor
</span>
<span className="hidden text-[10px] text-pink-300/50 italic transition-colors group-hover:text-pink-300/80 md:inline">
need to buy some API credits to run SWE-bench 😅
</span>
</a>
{/* Right - Stats */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3" data-testid="graph-stats">
{graph && (
<>
<span>{nodeCount} nodes</span>
+40 -27
View File
@@ -1,12 +1,19 @@
/**
* ToolCallCard Component
*
*
* Displays a tool call with expand/collapse functionality.
* Shows the tool name, status, and when expanded, the query/args and result.
*/
import { useState } from 'react';
import { ChevronDown, ChevronRight, Sparkles, Check, Loader2, AlertCircle } from '@/lib/lucide-icons';
import {
ChevronDown,
ChevronRight,
Sparkles,
Check,
Loader2,
AlertCircle,
} from '@/lib/lucide-icons';
import type { ToolCallInfo } from '../core/llm/types';
interface ToolCallCardProps {
@@ -49,28 +56,28 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
switch (status) {
case 'running':
return {
icon: <Loader2 className="w-3.5 h-3.5 animate-spin" />,
icon: <Loader2 className="h-3.5 w-3.5 animate-spin" />,
color: 'text-amber-400',
bgColor: 'bg-amber-500/10',
borderColor: 'border-amber-500/30',
};
case 'completed':
return {
icon: <Check className="w-3.5 h-3.5" />,
icon: <Check className="h-3.5 w-3.5" />,
color: 'text-emerald-400',
bgColor: 'bg-emerald-500/10',
borderColor: 'border-emerald-500/30',
};
case 'error':
return {
icon: <AlertCircle className="w-3.5 h-3.5" />,
icon: <AlertCircle className="h-3.5 w-3.5" />,
color: 'text-rose-400',
bgColor: 'bg-rose-500/10',
borderColor: 'border-rose-500/30',
};
default:
return {
icon: <Sparkles className="w-3.5 h-3.5" />,
icon: <Sparkles className="h-3.5 w-3.5" />,
color: 'text-text-muted',
bgColor: 'bg-surface',
borderColor: 'border-border-subtle',
@@ -84,13 +91,13 @@ const getStatusDisplay = (status: ToolCallInfo['status']) => {
const getToolDisplayName = (name: string): string => {
const names: Record<string, string> = {
// Current 7-tool architecture
'search': '🔍 Search Code',
'cypher': '🔗 Cypher Query',
'grep': '🔎 Pattern Search',
'read': '📄 Read File',
'overview': '🗺️ Codebase Overview',
'explore': '🔬 Deep Dive',
'impact': '💥 Impact Analysis',
search: '🔍 Search Code',
cypher: '🔗 Cypher Query',
grep: '🔎 Pattern Search',
read: '📄 Read File',
overview: '🗺️ Codebase Overview',
explore: '🔬 Deep Dive',
impact: '💥 Impact Analysis',
};
return names[name] || name;
};
@@ -101,18 +108,25 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
const formattedArgs = formatArgs(toolCall.args);
return (
<div className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}>
<div
className={`rounded-lg border ${status.borderColor} ${status.bgColor} overflow-hidden transition-all`}
>
{/* Header - always visible */}
<div
role="button"
tabIndex={0}
onClick={() => setIsExpanded(!isExpanded)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsExpanded(!isExpanded); } }}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-white/5 transition-colors cursor-pointer select-none"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsExpanded(!isExpanded);
}
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors select-none hover:bg-white/5"
>
{/* Expand/collapse icon */}
<span className="text-text-muted">
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</span>
{/* Tool name */}
@@ -132,11 +146,11 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
<div className="border-t border-border-subtle/50">
{/* Arguments/Query */}
{formattedArgs && (
<div className="px-3 py-2 border-b border-border-subtle/50">
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
<div className="border-b border-border-subtle/50 px-3 py-2">
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
{toolCall.name === 'cypher' ? 'Query' : 'Input'}
</div>
<pre className="text-xs text-text-secondary bg-surface/50 rounded p-2 overflow-x-auto whitespace-pre-wrap font-mono">
<pre className="overflow-x-auto rounded bg-surface/50 p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
{formattedArgs}
</pre>
</div>
@@ -145,15 +159,14 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
{/* Result */}
{toolCall.result && (
<div className="px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-1.5">
<div className="mb-1.5 text-[10px] tracking-wider text-text-muted uppercase">
Result
</div>
<div className="max-h-[400px] overflow-y-auto bg-surface/50 rounded">
<pre className="text-xs text-text-secondary p-2 whitespace-pre-wrap font-mono">
<div className="max-h-[400px] overflow-y-auto rounded bg-surface/50">
<pre className="p-2 font-mono text-xs whitespace-pre-wrap text-text-secondary">
{toolCall.result.length > 3000
? toolCall.result.slice(0, 3000) + '\n\n... (truncated)'
: toolCall.result
}
: toolCall.result}
</pre>
</div>
</div>
@@ -161,8 +174,8 @@ export const ToolCallCard = ({ toolCall, defaultExpanded = false }: ToolCallCard
{/* Loading state for in-progress */}
{toolCall.status === 'running' && !toolCall.result && (
<div className="px-3 py-3 flex items-center gap-2 text-xs text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
<div className="flex items-center gap-2 px-3 py-3 text-xs text-text-muted">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Executing...</span>
</div>
)}
@@ -41,27 +41,27 @@ export const WebGPUFallbackDialog = ({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
<div
className={`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
onClick={onClose}
/>
{/* Dialog */}
<div
className={`relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-md w-full mx-4 overflow-hidden transition-all duration-200 ${isVisible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'}`}
<div
className={`relative mx-4 w-full max-w-md overflow-hidden rounded-2xl border border-border-subtle bg-surface shadow-2xl transition-all duration-200 ${isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'}`}
>
{/* Header with scratching emoji */}
<div className="relative bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5 border-b border-border-subtle">
<div className="relative border-b border-border-subtle bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-5">
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-4 right-4 p-1 text-text-muted transition-colors hover:text-text-primary"
>
<X className="w-5 h-5" />
<X className="h-5 w-5" />
</button>
<div className="flex items-center gap-4">
{/* Animated emoji */}
<div
<div
className={`text-5xl ${isAnimating ? 'animate-bounce' : ''}`}
onAnimationEnd={() => setIsAnimating(false)}
onClick={() => setIsAnimating(true)}
@@ -69,10 +69,8 @@ export const WebGPUFallbackDialog = ({
🤔
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">
WebGPU said "nope"
</h2>
<p className="text-sm text-text-muted mt-0.5">
<h2 className="text-lg font-semibold text-text-primary">WebGPU said "nope"</h2>
<p className="mt-0.5 text-sm text-text-muted">
Your browser doesn't support GPU acceleration
</p>
</div>
@@ -80,65 +78,68 @@ export const WebGPUFallbackDialog = ({
</div>
{/* Content */}
<div className="px-6 py-5 space-y-4">
<p className="text-sm text-text-secondary leading-relaxed">
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG)
won't be as smart. The graph still works fine though!
<div className="space-y-4 px-6 py-5">
<p className="text-sm leading-relaxed text-text-secondary">
Couldn't create embeddings with WebGPU, so semantic search (Graph RAG) won't be as
smart. The graph still works fine though!
</p>
<div className="bg-elevated/50 rounded-lg p-4 border border-border-subtle">
<div className="rounded-lg border border-border-subtle bg-elevated/50 p-4">
<p className="text-sm text-text-secondary">
<span className="font-medium text-text-primary">Your options:</span>
</p>
<ul className="mt-2 space-y-1.5 text-sm text-text-muted">
<li className="flex items-start gap-2">
<Snail className="w-4 h-4 mt-0.5 text-amber-400 flex-shrink-0" />
<Snail className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-400" />
<span>
<strong className="text-text-secondary">Use CPU</strong> — Works but {isSmallCodebase ? 'a bit' : 'way'} slower
<strong className="text-text-secondary">Use CPU</strong> — Works but{' '}
{isSmallCodebase ? 'a bit' : 'way'} slower
{nodeCount > 0 && (
<span className="text-text-muted"> (~{estimatedMinutes} min for {nodeCount} nodes)</span>
<span className="text-text-muted">
{' '}
(~{estimatedMinutes} min for {nodeCount} nodes)
</span>
)}
</span>
</li>
<li className="flex items-start gap-2">
<SkipForward className="w-4 h-4 mt-0.5 text-blue-400 flex-shrink-0" />
<SkipForward className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-400" />
<span>
<strong className="text-text-secondary">Skip it</strong> — Graph works, just no AI semantic search
<strong className="text-text-secondary">Skip it</strong> — Graph works, just no AI
semantic search
</span>
</li>
</ul>
</div>
{isSmallCodebase && (
<p className="text-xs text-node-function flex items-center gap-1.5 bg-node-function/10 px-3 py-2 rounded-lg">
<Rocket className="w-3.5 h-3.5" />
<p className="flex items-center gap-1.5 rounded-lg bg-node-function/10 px-3 py-2 text-xs text-node-function">
<Rocket className="h-3.5 w-3.5" />
Small codebase detected! CPU should be fine.
</p>
)}
<p className="text-xs text-text-muted">
💡 Tip: Try Chrome or Edge for WebGPU support
</p>
<p className="text-xs text-text-muted">💡 Tip: Try Chrome or Edge for WebGPU support</p>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-elevated/30 border-t border-border-subtle flex gap-3">
<div className="flex gap-3 border-t border-border-subtle bg-elevated/30 px-6 py-4">
<button
onClick={onSkip}
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary bg-surface border border-border-subtle rounded-lg hover:bg-hover hover:text-text-primary transition-all flex items-center justify-center gap-2"
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2.5 text-sm font-medium text-text-secondary transition-all hover:bg-hover hover:text-text-primary"
>
<SkipForward className="w-4 h-4" />
<SkipForward className="h-4 w-4" />
Skip Embeddings
</button>
<button
onClick={onUseCPU}
className={`flex-1 px-4 py-2.5 text-sm font-medium rounded-lg transition-all flex items-center justify-center gap-2 ${
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium transition-all ${
isSmallCodebase
? 'bg-node-function text-white hover:bg-node-function/90'
: 'bg-amber-500/20 text-amber-300 border border-amber-500/30 hover:bg-amber-500/30'
: 'border border-amber-500/30 bg-amber-500/20 text-amber-300 hover:bg-amber-500/30'
}`}
>
<Snail className="w-4 h-4" />
<Snail className="h-4 w-4" />
Use CPU {isSmallCodebase ? '(Recommended)' : '(Slow)'}
</button>
</div>
@@ -146,4 +147,3 @@ export const WebGPUFallbackDialog = ({
</div>
);
};
@@ -36,36 +36,34 @@ export const ProviderConfigCard = ({
children,
}: ProviderConfigCardProps) => {
return (
<div className="space-y-4 animate-fade-in">
<div className="animate-fade-in space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{title}</h3>
{description ? (
<p className="text-xs text-text-muted">{description}</p>
) : null}
{description ? <p className="text-xs text-text-muted">{description}</p> : null}
</div>
</div>
{apiKey && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
<Key className="h-4 w-4" />
API Key
</label>
<div className="relative">
<input
type={apiKey.isVisible ? 'text' : 'password'}
value={apiKey.value}
onChange={e => apiKey.onChange(e.target.value)}
onChange={(e) => apiKey.onChange(e.target.value)}
placeholder={apiKey.placeholder}
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-12 text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={apiKey.onToggleVisibility}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
className="absolute top-1/2 right-3 -translate-y-1/2 p-1 text-text-muted transition-colors hover:text-text-primary"
>
{apiKey.isVisible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{apiKey.isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{apiKey.helperText && (
@@ -94,13 +92,11 @@ export const ProviderConfigCard = ({
<input
type="text"
value={model.value}
onChange={e => model.onChange(e.target.value)}
onChange={(e) => model.onChange(e.target.value)}
placeholder={model.placeholder}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
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"
/>
{model.helperText ? (
<p className="text-xs text-text-muted">{model.helperText}</p>
) : null}
{model.helperText ? <p className="text-xs text-text-muted">{model.helperText}</p> : null}
</div>
)}

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